SLOPSHOPPER

work-pane

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

newpanerowsguardcommandtoast
v0.4.0no licenseupdated 2026-10-08cpeaustriajc/cj-stack/mods/work-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-pane
│ ┃ Tests ✕ › fix the failing auth test and add an audit log call │ ┃ No test runs yet │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tests │ ⎿ work-pane: Tests pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tests
No test runs yet
README

cj-stack

CJ's skills and mods for Claude Code.

Install

/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.

Plugins

PluginContentsInstall
cj-stackthe shipped skills belowby default
work-panea mod: holds Linear writes until you allow them; test runs in a paneby default
cj-paintpainting-craftwhere you paint in code
cj-jiraarchived Jira-era skillsonly for a Jira client

Skills

All skills are model-invoked: Claude picks them from their description.

SkillBucketWhat it is for
work-planningplanningshaping work in any tracker (Linear, Jira, GitHub, others): specs, work items, phases, projects, updates
project-knowledgeknowledgea decision log and research pages in the team's wiki, Notion, Linear, Obsidian or Confluence
painting-craftcraftdrawing and painting in code to a gallery standard, with a screenshot review loop
jira-ticketarchivedJira tickets and acceptance criteria
draft-ticketarchivedend-to-end Jira ticket drafting
github-issuearchivedGitHub issues holding a Jira ticket's engineering detail
linear-planningarchivedLinear-only predecessor of work-planning (not shipped)
linear-projectarchivedLinear-only predecessor of work-planning (not shipped)
project-wikiarchivedGitHub-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.

painting-craft: before and after

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."

Knight at dusk, full picture: before and after

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:

Knight at dusk, 2x crop: before and after

"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."

Night street, full picture: before and after

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:

Night street, 2x crop of a tree: before and after

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.

Source 2 files
hooks/register.tsx 442 lines
1import { 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}
442
types/index.d.ts 18 lines
1export 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