Holds every Linear write until you allow it, and shows test runs in a pane

CJ's skills and mods for Claude Code.
/plugin marketplace add cpeaustriajc/cj-stack
/plugin install cj-stack@cj-stack
/plugin install work-pane@cj-stack
While editing a mod, add the local clone instead (/plugin marketplace add ~/Projects/cj-stack): a directory marketplace hot-reloads.
Other agents (Codex and anything that reads ~/.agents/skills): scripts/link-skills.sh.
| Plugin | Contents | Install |
|---|---|---|
cj-stack | the shipped skills below | by default |
work-pane | a mod: holds Linear writes until you allow them; test runs in a pane | by default |
cj-paint | painting-craft | where you paint in code |
cj-jira | archived Jira-era skills | only for a Jira client |
All skills are model-invoked: Claude picks them from their description.
| Skill | Bucket | What it is for |
|---|---|---|
| work-planning | planning | shaping work in any tracker (Linear, Jira, GitHub, others): specs, work items, phases, projects, updates |
| project-knowledge | knowledge | a decision log and research pages in the team's wiki, Notion, Linear, Obsidian or Confluence |
| painting-craft | craft | drawing and painting in code to a gallery standard, with a screenshot review loop |
| jira-ticket | archived | Jira tickets and acceptance criteria |
| draft-ticket | archived | end-to-end Jira ticket drafting |
| github-issue | archived | GitHub issues holding a Jira ticket's engineering detail |
| linear-planning | archived | Linear-only predecessor of work-planning (not shipped) |
| linear-project | archived | Linear-only predecessor of work-planning (not shipped) |
| project-wiki | archived | GitHub-wiki-only predecessor of project-knowledge |
The archived skills were written for one project and still name its files. Shipped skills stay tool-neutral: tool specifics go in a skill's adapters, and a project's own facts stay in that project.
The same two prompts, each run once in a fresh session: the original skill on the left, the current painting-craft on the right. Nothing was touched up by hand.
"Paint a 1600x1000 SVG of a knight asleep against an oak tree at dusk, in a Romantic oil-painting look."

At 2× zoom, where the viewer looks. The knight is a 3D figure lit by the low sun, with separate plates, mail in rows and a visor, instead of a mannequin of tubes:

"Draw a night city street as a 1600x1000 SVG: a car passes behind a row of trees on the near verge. Flat vector style, but it must not look cheap."

At 2× zoom, a crown is clumps hung on a branch skeleton, in three values with broken edges, instead of a pile of round blobs:

What changed: build recipes for the parts that fail up close (references/recipes.md), studies of each hero element against fetched public-domain references before composing (scripts/refs.py, scripts/compare.py), the bundled 3D figure renderer, a paint pass that unifies painted styles (scripts/paintpass.py), and delivery at twice the display size.
hooks/register.tsx 442 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { TestRun } from '../types'
5
6const TESTS_PANE = 'tests'
7const BAR_CELLS = 16
8
9const runs = atom({ plugin: 'work-pane', key: 'runs' } as const, [] as TestRun[])
10const asks = atom({ plugin: 'work-pane', key: 'asks' } as const, {} as Record<string, string>)
11
12const SHOWN_RUNS = 3
13const TEE = /\|\s*tee\s+(?:-a\s+)?(['"]?)([^\s'"|;&]+)\1/
14const UNIT = /\[(\d+)\/(\d+)\]/g
15
16function teeLog(command: string): string | undefined {
17 return TEE.exec(command.split("\n")[0])?.[2]
18}
19
20function parseLog(text: string): Pick<TestRun, 'done' | 'total' | 'pass' | 'fail'> {
21 const units = [...text.matchAll(UNIT)]
22 const last = units.at(-1)
23 if (!last) return {}
24 const line = text.slice(last.index).split('\n')[0]
25 const pass = /pass (\d+)/.exec(line)?.[1]
26 const fail = /fail (\d+)/.exec(line)?.[1]
27 return {
28 done: Number(last[1]),
29 total: Number(last[2]),
30 pass: pass === undefined ? undefined : Number(pass),
31 fail: fail === undefined ? undefined : Number(fail),
32 }
33}
34
35async function readRun($: Parameters<typeof read>[0], run: TestRun): Promise<TestRun> {
36 try {
37 const { stdout } = await $.process.run(['tail', '-n', '40', run.log], { timeoutMs: 2000 })
38 return { ...run, ...parseLog(stdout) }
39 } catch {
40 return run
41 }
42}
43
44const LINEAR_WRITES = new Set([
45 'save_issue', 'save_comment', 'save_document', 'save_project', 'save_milestone',
46 'save_initiative', 'save_initiative_label', 'save_issue_label', 'save_project_label',
47 'save_release', 'save_release_note', 'save_status_update', 'save_diff_comment',
48 'create_attachment', 'create_attachment_from_upload', 'create_issue_label',
49 'create_initiative_label', 'delete_attachment', 'delete_comment', 'delete_diff_comment',
50 'delete_status_update', 'merge_diff', 'update_diff', 'submit_diff_review',
51 'resolve_diff_thread', 'retire_issue_label', 'retire_initiative_label',
52 'retire_project_label', 'restore_issue_label', 'restore_initiative_label',
53 'restore_project_label', 'share_issue', 'unshare_issue', 'mark_notification',
54])
55const LINEAR_SERVER_WRITE = /^(save|delete|create|update|merge|submit|resolve|retire|restore|share|unshare|mark)_/
56const LEAVE = 'Leave it'
57const HEADER = 'Linear'
58const CONFIRM = 'Apply this change?'
59const LOOKUP_MS = 3000
60const BUTTON_MAX = 40
61// The engine refuses a dialog with more rows than this around it and draws its own,
62// which would show only the staged question and hide the body.
63const ROWS_MAX = 12
64const NARROW = 60
65
66function linearWrite(tool: string): string | undefined {
67 const m = /^mcp__(.+?)__(.+)$/.exec(tool)
68 if (!m) return undefined
69 const [, server, name] = m
70 if (LINEAR_WRITES.has(name)) return name
71 if (server === 'linear-server' && LINEAR_SERVER_WRITE.test(name)) return name
72 return undefined
73}
74
75const EVENT_KEYS = new Set(['tool', 'tool_use_id', 'agentId'])
76const TEXT_KEYS = ['body', 'description', 'content']
77const PR_STATUS: Record<string, string> = {
78 markReadyForReview: 'ready for review', convertToDraft: 'back to draft', close: 'closed', reopen: 'reopened',
79}
80const REVIEW: Record<string, string> = { approved: 'Approve', changesRequested: 'Request changes on', commented: 'Leave a review comment on' }
81
82const HEALTH: Record<string, string> = { onTrack: 'on track', atRisk: 'at risk', offTrack: 'off track' }
83const clip = (s: string, max: number) => (s.length > max ? `${s.slice(0, max)}...` : s)
84const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
85const short = (v: string) => (UUID.test(v) ? `${v.slice(0, 8)}…` : v)
86const flat = (t: string) => t.replace(/^\s*(#+|[-*>]|\d+\.)\s+/gm, '').replace(/[*_`]/g, '').replace(/\s+/g, ' ').trim()
87const isNames = (v: unknown): v is string[] => Array.isArray(v) && v.every(x => typeof x === 'string')
88const shown = (v: unknown) =>
89 clip(typeof v === 'string' ? short(flat(v)) : isNames(v) ? v.map(short).join(', ') : JSON.stringify(v), 80)
90const words = (k: string) => k.replace(/Id$/, '').replace(/([A-Z])/g, ' $1').replace(/_/g, ' ').toLowerCase().trim()
91const FIELD: Record<string, string> = { state: 'Status' }
92const heading = (k: string) => FIELD[k] ?? words(k).replace(/^./, c => c.toUpperCase())
93const STATE_VERB: Record<string, string> = { canceled: 'Cancel', cancelled: 'Cancel', done: 'Complete', completed: 'Complete' }
94const people = (v: unknown) =>
95 (Array.isArray(v) ? v : []).map(r => String(r.user ?? r.externalUserId ?? r.githubTeamId)).join(', ')
96
97function whatItDoes(name: string, a: Record<string, unknown>): [string[], string[]] {
98 const s = (k: string) => (typeof a[k] === 'string' ? (a[k] as string) : undefined)
99 const pr = `PR ${s('urlOrId')}`
100 switch (name) {
101 case 'save_issue': {
102 if (s('id')) return [[`${STATE_VERB[s('state')?.toLowerCase() ?? ''] ?? 'Update'} issue ${short(s('id')!)}`], ['id']]
103 const template = s('template') ? `with template ${s('template')}` : 'with no template'
104 return [[`Create issue "${s('title')}" in ${s('team')} ${template}`], ['title', 'team', 'template']]
105 }
106 case 'save_comment': {
107 const parent = ['issueId', 'projectId', 'documentId', 'initiativeId', 'milestoneId', 'statusUpdateId'].find(k => s(k))
108 const noun = parent?.replace(/Id$/, '').replace(/([A-Z])/g, ' $1').toLowerCase()
109 const where = parent ? ` on ${noun} ${short(s(parent)!)}` : ''
110 if (s('id')) return [[`Edit comment ${short(s('id')!)}`], ['id', ...(parent ? [parent] : [])]]
111 if (s('parentId')) return [[`Reply to a comment${where}`], ['parentId', ...(parent ? [parent] : [])]]
112 return parent ? [[`Comment${where}`], [parent]] : [[], []]
113 }
114 case 'save_status_update': {
115 const health = s('health') ? `, health ${HEALTH[s('health')!] ?? s('health')}` : ''
116 const lines = s('id')
117 ? [`Edit the posted ${s('type')} status update ${s('id')}${health}`]
118 : [`Post a ${s('type')} status update on ${s('project') ?? s('initiative')}${health}`]
119 return [lines, ['type', 'id', 'health', 'project', 'initiative']]
120 }
121 case 'save_project': case 'save_milestone': case 'save_initiative': case 'save_document': {
122 const noun = name.split('_')[1]
123 if (s('id')) return [[`${STATE_VERB[s('state')?.toLowerCase() ?? ''] ?? 'Update'} ${noun} ${short(s('id')!)}`], ['id']]
124 const key = s('name') ? 'name' : 'title'
125 return [[`Create ${noun} "${s(key)}"`], [key]]
126 }
127 case 'delete_status_update':
128 return [[`Delete the ${s('type')} status update ${s('id')}`], ['type', 'id']]
129 case 'update_diff': {
130 const lines: string[] = []
131 if (a.addedReviewRequests) lines.push(`Request review from ${people(a.addedReviewRequests)} on ${pr}`)
132 if (a.removedReviewRequests) lines.push(`Withdraw the review request to ${people(a.removedReviewRequests)} on ${pr}`)
133 for (const l of (Array.isArray(a.addedIssueLinks) ? a.addedIssueLinks : []) as { issue: string; type: string }[])
134 lines.push(`Link ${l.issue} (${l.type}) to ${pr}`)
135 if (a.removedIssueLinks) lines.push(`Unlink ${(a.removedIssueLinks as string[]).join(', ')} from ${pr}`)
136 if (s('statusAction')) lines.push(`Mark ${pr} ${PR_STATUS[s('statusAction')!] ?? s('statusAction')}`)
137 if (!lines.length) lines.push(`Edit ${pr}`)
138 return [lines, ['urlOrId', 'addedReviewRequests', 'removedReviewRequests', 'addedIssueLinks', 'removedIssueLinks', 'statusAction']]
139 }
140 case 'merge_diff':
141 return [[`Merge ${pr}${s('mergeMethod') ? ` (${s('mergeMethod')!.toLowerCase()})` : ''}`], ['urlOrId', 'mergeMethod']]
142 case 'submit_diff_review':
143 return [[`${REVIEW[s('decision') ?? ''] ?? 'Review'} ${pr}`], ['urlOrId', 'decision']]
144 case 'save_diff_comment': {
145 const verb = a.draft ? 'Save a draft comment on' : s('commentId') ? 'Edit a comment on' : s('parentId') ? 'Reply on' : 'Comment on'
146 return [[`${verb} ${pr}`], ['urlOrId', 'draft', 'commentId', 'parentId', 'draftId', 'anchor', 'anchorContent']]
147 }
148 case 'create_issue_label': case 'create_initiative_label':
149 case 'save_issue_label': case 'save_initiative_label': case 'save_project_label': {
150 const noun = `${name.split('_')[1]} label`
151 if (name.startsWith('save_') && s('id')) return [[`Edit ${noun} ${s('id')}`], ['id']]
152 const where = s('teamId') ? `in team ${short(s('teamId')!)}` : 'for the whole workspace'
153 return [[`Create ${noun} "${s('name')}" ${where}`], ['name', 'teamId']]
154 }
155 case 'retire_issue_label': case 'retire_initiative_label': case 'retire_project_label':
156 case 'restore_issue_label': case 'restore_initiative_label': case 'restore_project_label': {
157 const [verb, kind] = name.split('_')
158 return [[`${heading(verb)} ${kind} label ${s('id') ?? s('name')}`], ['id', 'name']]
159 }
160 case 'resolve_diff_thread':
161 return [[`${a.resolved === false ? 'Reopen' : 'Resolve'} PR thread ${s('threadId')}`], ['threadId', 'resolved']]
162 default:
163 return [[], []]
164 }
165}
166
167const SHOWN_EDITS = 5
168const row = (t: string) => clip(t.replace(/\n/g, ' ⏎ ').replace(/[ \t]+/g, ' ').trim(), 160)
169const quote = (t: string) => `"${clip(row(t), 40)}"`
170const WRAP = 72
171const WRAP_ROWS = 11
172
173function wrapped(mark: string, text: string): string[] {
174 return block(` ${mark} `, ' ', text, l => l)
175}
176
177const unmark = (l: string) => l.replace(/^\s*(#+|[-*>]|\d+\.)\s+/, '').replace(/[*_`]/g, '')
178
179function block(first: string, rest: string, text: string, tidy: (line: string) => string): string[] {
180 const rows = text.split('\n').map(l => tidy(l).replace(/\s+/g, ' ').trim()).filter(Boolean).flatMap(line => {
181 const out: string[] = []
182 let cur = ''
183 for (let word of line.split(' ')) {
184 while (word.length > WRAP) {
185 if (cur) out.push(cur), (cur = '')
186 out.push(word.slice(0, WRAP)), (word = word.slice(WRAP))
187 }
188 if (cur && cur.length + 1 + word.length > WRAP) out.push(cur), (cur = '')
189 cur = cur ? `${cur} ${word}` : word
190 }
191 return cur ? [...out, cur] : out
192 })
193 const kept = rows.slice(0, WRAP_ROWS).map((r, i) => (i ? `${rest}${r}` : `${first}${r}`))
194 const hidden = rows.slice(WRAP_ROWS).join(' ').length
195 return hidden ? [...kept, `${rest}… ${hidden} more characters`] : kept
196}
197
198function patchLines(patch: unknown[]): string[] {
199 const lines = patch.slice(0, SHOWN_EDITS).flatMap((raw, i) => {
200 const op = (raw ?? {}) as Record<string, string>
201 const added = (t: string | undefined) => (t ? wrapped('+', t) : [])
202 const body = (() => {
203 switch (op.op) {
204 case 'replace':
205 return [...(op.replace_all ? ['Every match:'] : []), ...wrapped('−', op.old_string), ...added(op.new_string)]
206 case 'insert_before': return [`Before ${quote(op.anchor)}:`, ...added(op.text)]
207 case 'insert_after': return [`After ${quote(op.anchor)}:`, ...added(op.text)]
208 case 'prepend': return ['At the start:', ...added(op.text)]
209 case 'append': return ['At the end:', ...added(op.text)]
210 case 'replace_range':
211 return [`From ${quote(op.from)} up to ${quote(op.to)}:`, ...added(op.new_string)]
212 default: return [` ${shown(raw)}`]
213 }
214 })()
215 return [...(i ? [''] : []), ...body.map(l => (l.startsWith(' ') ? l : ` ${l}`))]
216 })
217 const rest = patch.length - SHOWN_EDITS
218 return rest > 0 ? [...lines, '', ` …and ${rest} more edit${rest === 1 ? '' : 's'}`] : lines
219}
220
221function actionOf(name: string, args: Record<string, unknown>): string[] {
222 const [lines] = whatItDoes(name, args)
223 return lines.length ? lines : [`${heading(name)} (${name})`]
224}
225
226function isChange(name: string, args: Record<string, unknown>): boolean {
227 return /^(update|merge|submit|resolve)_/.test(name) || (/^save_/.test(name) && typeof args.id === 'string')
228}
229
230function isDrastic(name: string, args: Record<string, unknown>): boolean {
231 return /^(delete|retire)_/.test(name) || STATE_VERB[String(args.state).toLowerCase()] === 'Cancel'
232}
233
234function describeWrite(name: string, args: Record<string, unknown>): string {
235 const [, covered] = whatItDoes(name, args)
236 const change = isChange(name, args)
237 const sep = change ? ' → ' : ': '
238 const label = (k: string) => (change ? heading(k) : heading(k.replace(/^add([A-Z])/, (_, c: string) => c.toLowerCase())))
239 const textKey = TEXT_KEYS.find(k => typeof args[k] === 'string')
240 const patch = Array.isArray(args.patch) ? args.patch : undefined
241 const fields = Object.keys(args)
242 .filter(k => !EVENT_KEYS.has(k) && !covered.includes(k) && k !== textKey && !(patch && k === 'patch'))
243 .map(k => ` ${label(k)}${sep}${shown(args[k])}`)
244 if (textKey) {
245 const text = args[textKey] as string
246 if (name === 'save_comment') fields.push(...(fields.length ? [''] : []), ...block(' ', ' ', text, unmark))
247 else {
248 const head = ` ${label(textKey)}${change ? ' →' : ':'}`
249 const rows = block(' ', ' ', text, unmark)
250 const inline = rows.length === 1 && head.length + rows[0].length - 3 <= 76
251 fields.push(...(inline ? [`${head} ${rows[0].trim()}`] : [...(fields.length ? [''] : []), head, ...rows]))
252 }
253 }
254 if (patch) fields.push(...(fields.length ? [''] : []), ...patchLines(patch))
255 const warning = isDrastic(name, args) ? ['', '⚠ Everyone on the team sees this.'] : []
256 return [...actionOf(name, args), ...(fields.length ? ['', ...fields] : []), ...warning, '', CONFIRM].join('\n')
257}
258
259function allowLabel(name: string, args: Record<string, unknown>): string {
260 if (name === 'save_comment') return args.id ? 'Save edit' : args.parentId ? 'Post reply' : 'Post comment'
261 const line = actionOf(name, args)[0]
262 if (line.length <= BUTTON_MAX) return line
263 const cut = line.includes('"') ? -1 : line.search(/ (in|with|on|for) /)
264 const head = cut > 0 && cut <= BUTTON_MAX ? line.slice(0, cut) : line
265 return head.length <= BUTTON_MAX ? head : `${head.slice(0, BUTTON_MAX - 1)}…`
266}
267
268function failure(out: { text?: string; isError?: boolean }): string | undefined {
269 const text = out.text ?? ''
270 if (out.isError) return clip(text || 'the tool reported an error', 100)
271 if (/"success"\s*:\s*false/.test(text)) return clip(text, 100)
272 return undefined
273}
274
275function bodyOf(description: string): string[] {
276 return description.split('\n').slice(2, -2)
277}
278
279function drawnRows(description: string): number {
280 return bodyOf(description).reduce((n, line) => n + Math.max(1, Math.ceil(line.length / NARROW)), 0)
281}
282
283// The dialog's question must reach the engine unchanged: a free-text answer is keyed by
284// the question the dialog drew, and $.ui.ask drops one keyed by anything else.
285async function stage($: Parameters<typeof read>[0], description: string): Promise<string> {
286 const base = `${description.split('\n')[0]} — ${CONFIRM.toLowerCase()}`
287 let question = base
288 await update($, asks, pending => {
289 question = base
290 for (let n = 2; question in pending; n++) question = `${base} (${n})`
291 return { ...pending, [question]: description }
292 })
293 return question
294}
295
296async function documentTitle($: Parameters<typeof read>[0], server: string, id: string): Promise<string | undefined> {
297 try {
298 const timeout = $.clock.sleep(LOOKUP_MS).then(() => undefined, () => new Promise<never>(() => {}))
299 const out = await Promise.race([$.mcp.call(server, 'get_document', { id }), timeout])
300 if (!out || out.isError) return undefined
301 for (const block of out.content) {
302 if (block.type !== 'text') continue
303 const title = (JSON.parse((block as { text: string }).text) as { title?: unknown }).title
304 if (typeof title === 'string' && title) return title
305 }
306 } catch {}
307 return undefined
308}
309
310function diffColor(line: string, current: string | undefined): string | undefined {
311 if (line.startsWith(' − ')) return 'red'
312 if (line.startsWith(' + ')) return 'green'
313 if (line.startsWith(' ') && line.trim()) return current
314 if (line.startsWith('⚠')) return 'yellow'
315 return undefined
316}
317
318export const register: Register = on => {
319 let isInteractive = true
320
321 on('session.start', async ($, e, next) => {
322 isInteractive = e.isInteractive !== false
323 if (!isInteractive) return next(e)
324 await $.command.register({ name: 'tests', description: 'Open the Tests pane' })
325 $.clock.every(1000, async () => {
326 const runningRuns = (await read($, runs)).filter(r => r.endedAt === undefined)
327 if (runningRuns.length === 0) return
328 for (const run of runningRuns) {
329 const read_ = await readRun($, run)
330 const isComplete = read_.total !== undefined && read_.done === read_.total
331 const next_ = isComplete ? { ...read_, endedAt: Date.now(), isFailed: (read_.fail ?? 0) > 0 } : read_
332 await update($, runs, list => list.map(r => (r.id === run.id ? next_ : r)))
333 }
334 $.ui.invalidate('ui.render')
335 })
336 return next(e)
337 })
338
339 on('command.run', { command: 'tests' }, async $ => {
340 await $.ui.open({ id: TESTS_PANE, title: 'Tests' })
341 return { text: 'Tests pane opened.' }
342 })
343
344 on('tool.call', async ($, e, next) => {
345 let args = e as unknown as Record<string, unknown>
346
347 const log = isInteractive && e.tool === 'Bash' && typeof args.command === 'string' ? teeLog(args.command) : undefined
348 if (log) {
349 const command = args.command as string
350 const run: TestRun = {
351 id: e.tool_use_id ?? `${Date.now()}`,
352 label: command.split('|')[0].trim().slice(0, 48),
353 log,
354 }
355 await update($, runs, list => [...list, run].slice(-10))
356 void $.ui.open({ id: TESTS_PANE, title: 'Tests' })
357 const ran = await next(e)
358 if (args.run_in_background !== true) {
359 const final = await readRun($, run)
360 const isFailed = ran.deny !== undefined || ran.isError === true || (final.fail ?? 0) > 0
361 await update($, runs, list => list.map(r => (r.id === run.id ? { ...final, endedAt: Date.now(), isFailed } : r)))
362 }
363 return ran
364 }
365
366 const name = linearWrite(e.tool)
367 if (!name) return next(e)
368
369 const server = /^mcp__(.+?)__/.exec(e.tool)![1]
370 const title =
371 name === 'save_document' && typeof args.id === 'string' ? await documentTitle($, server, args.id) : undefined
372 if (title) args = { ...args, id: `"${title}"` }
373 const allow = allowLabel(name, args)
374 const description = describeWrite(name, args)
375 const question = drawnRows(description) <= ROWS_MAX ? await stage($, description) : description
376 let answer: string
377 try {
378 answer = await $.ui.ask(question, { options: [allow, LEAVE], header: HEADER })
379 } catch {
380 return { deny: `${$.plugin.name}: the Linear write was not approved (dialog closed or no one to ask).` }
381 } finally {
382 await update($, asks, ({ [question]: _, ...rest }) => rest)
383 }
384 const action = actionOf(name, args).join('; ')
385 if (answer !== allow) {
386 const reason = answer && answer !== LEAVE ? ` Their note: ${answer}` : ''
387 return { deny: `${$.plugin.name}: the user denied this Linear write (${action}).${reason}` }
388 }
389 const out = await next(e)
390 if (out.deny) return out
391 const failed = failure(out)
392 await $.ui.toast(failed ? `✘ ${action} failed: ${failed}` : `✔ ${action}`)
393 return out
394 })
395
396 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
397 const questions = e.props.questions as { question?: unknown; header?: unknown }[]
398 const q = questions.length === 1 ? questions[0] : undefined
399 const description = q?.header === HEADER && typeof q.question === 'string' ? (await read($, asks))[q.question] : undefined
400 if (!description) return next(e)
401 const { Box, Text } = $.ui.resolve(e)
402 const dialog = await next(e)
403 let color: string | undefined
404 const rows = bodyOf(description).map(line => {
405 color = diffColor(line, color)
406 return <Text color={color}>{line || ' '}</Text>
407 })
408 return (
409 <Box flexDirection="column">
410 {rows}
411 {dialog}
412 </Box>
413 )
414 })
415
416 on('ui.render', { component: 'Pane', requestId: TESTS_PANE }, async ($, e) => {
417 const { Box, Text } = $.ui.resolve(e)
418 const testRows = (await read($, runs)).slice(-SHOWN_RUNS)
419 if (testRows.length === 0) return <Text dimColor>No test runs yet</Text>
420
421 return (
422 <Box flexDirection="column">
423 {testRows.map(r => {
424 const mark = r.endedAt === undefined ? '●' : r.isFailed ? '✗' : '✓'
425 const hasCount = r.total !== undefined && r.done !== undefined
426 const cells = hasCount ? Math.round((r.done! / Math.max(1, r.total!)) * BAR_CELLS) : 0
427 const counts = r.pass !== undefined ? ` · pass ${r.pass} fail ${r.fail ?? 0}` : ''
428 const state = hasCount
429 ? `${'█'.repeat(cells)}${'░'.repeat(BAR_CELLS - cells)} ${r.done}/${r.total}${counts}`
430 : r.endedAt === undefined ? 'running' : 'finished'
431 return (
432 <Box flexDirection="column" marginBottom={1}>
433 <Text dimColor={r.endedAt !== undefined}>{`${mark} ${r.label}`}</Text>
434 <Text dimColor>{` ${state}`}</Text>
435 </Box>
436 )
437 })}
438 </Box>
439 )
440 })
441}
442types/index.d.ts 18 lines1export type TestRun = {
2 id: string
3 label: string
4 log: string
5 done?: number
6 total?: number
7 pass?: number
8 fail?: number
9 endedAt?: number
10 isFailed?: boolean
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'work-pane': { runs: TestRun[]; asks: Record<string, string> }
16 }
17}
18