Claude logs its judgement calls as it works; approve or reject each one with a comment from a side pane

A Claude Code mod that makes Claude's judgement calls visible while it works, so you can follow the ramifications of a task as it happens instead of finding them in the diff afterwards.
log_decision tool and a system prompt section asking it to log each choice you did not spell out: picking an approach or library, resolving an ambiguity with an assumption, skipping or stubbing something, changing scope, anything costly to undo.●○○ gauge coloured by how hard the decision is to undo (green easy, yellow moderate, red hard), and a bar under the tabs fills green and red as you approve and reject. When nothing is waiting, the pane shows a session overview: your verdict counts, how hard the decisions are to undo, and the most recent ones, kept company by Claude Code's own critter, beside the stats with a word under it saying whether Claude is idle or working: while Claude works it glances about, lets a thought bubble slowly form, then decides with a tick or a cross; otherwise it sits and blinks now and then. Before the first decision it sits in the middle of the pane on its own. While Claude works, a light sweeps along the title bar. In the terminal the title bar and the critter are drawn in coloured block characters and animate only while they are on screen; the desktop app and VS Code get a text version./decisions, which also gives the pane the keyboard.ctrl+x tab (or a click) gives the pane the keyboard. Then:| Key | Does |
|---|---|
n / p | next / previous decision |
| arrows | walk the tabs, rows and buttons; landing on a row shows it |
a | approve the selected decision |
r | reject it |
c | open a comment box; Enter keeps the comment |
u | undo your most recent verdict (an accidental approve or reject) |
o | reopen the selected reviewed decision, back to To review |
Esc | back to the prompt |
The hotkeys switch off while the comment box is open, so typing never presses a button.
/decisions clear (or clear reviewed) drops reviewed decisions from the list./decisions again.Decisions last for the session only.
Type this at the Claude Code prompt:
/plugin install decision-log --marketplace HectorBailey/claude-mods
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). See the repository README for updating and requirements.
Run Claude Code with the mod loaded straight from this folder; it reloads whenever a file here changes:
claude --plugin-dir ./decision-log
Check and test it with:
claude plugin validate ./decision-log
claude plugin test ./decision-log
The critter is a fan homage to Claude Code's mascot, drawn from the block-character logo on its welcome screen. The character belongs to Anthropic and is not covered by this repository's licence.
hooks/register.tsx 915 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Decision, DecisionTab, Reversibility, Verdict, VerdictChange } from '../types'
5
6import { banner, MASCOT_IDLE_FRAMES, MASCOT_SIZE, MASCOT_WORKING_FRAMES } from './graphics'
7
8const PANE = 'decisions'
9const TITLE = ' ◆ Decision log'
10const TOOL = 'mcp__decision-log__log_decision'
11// Matched by pattern: the engine types tool names by the MCP tools connected at load, which may not list this one.
12const TOOL_PATTERN = /^mcp__decision-log__log_decision$/
13
14const decisions = atom({ plugin: 'decision-log', key: 'decisions' } as const, [] as Decision[])
15const isWorking = atom({ plugin: 'decision-log', key: 'isWorking' } as const, false)
16const selectedId = atom({ plugin: 'decision-log', key: 'selectedId' } as const, null as number | null)
17const currentTab = atom({ plugin: 'decision-log', key: 'tab' } as const, 'pending' as DecisionTab)
18const lastChange = atom(
19 { plugin: 'decision-log', key: 'lastChange' } as const,
20 null as VerdictChange | null,
21)
22const commentingId = atom(
23 { plugin: 'decision-log', key: 'commentingId' } as const,
24 null as number | null,
25)
26
27const INSTRUCTIONS = `# Decision log
28
29The user reviews your judgement calls in a decision log pane, so they can follow the ramifications of the work as it happens. While you work, call ${TOOL} each time you make a choice the user did not spell out and could reasonably have made differently, for example:
30- picking between approaches, libraries, data models, file layouts or APIs
31- resolving an ambiguity in the request with an assumption
32- skipping, deferring, stubbing or working around something
33- widening or narrowing the scope of the task
34- anything that is costly to reverse later (schema, public interface, deleted data, dependencies)
35
36Log the decision when you make it, not in a batch at the end, and keep working: do not wait for approval. Skip mechanics nobody would question (local variable names, the order you read files in, formatting). A typical task has between zero and five of these.
37
38If a decision log message says the user rejected a decision, stop and deal with it before going further: undo or change what followed from that decision, then log the replacement decision. If they approved one with a comment, take the comment into account.`
39
40const TOOL_DESCRIPTION =
41 'Record a judgement call you just made so the user can approve or reject it in their decision log pane. Returns at once; keep working.'
42
43const INPUT_SCHEMA = {
44 type: 'object',
45 properties: {
46 choice: {
47 type: 'string',
48 description: 'What you decided, as one short imperative line ("Store sessions in SQLite").',
49 },
50 why: { type: 'string', description: 'Why, in a sentence.' },
51 alternatives: {
52 type: 'string',
53 description: 'What else you considered, briefly. Omit when there was no real alternative.',
54 },
55 impact: {
56 type: 'string',
57 description:
58 'The ramifications: what this affects, what it locks in, what it would cost to change later.',
59 },
60 reversibility: {
61 type: 'string',
62 enum: ['easy', 'moderate', 'hard'],
63 description: 'How hard it is to undo later.',
64 },
65 },
66 required: ['choice', 'why', 'impact', 'reversibility'],
67}
68
69const REVERSIBILITY: Record<Reversibility, { label: string; color: string; level: number }> = {
70 easy: { label: 'easy to undo', color: 'success', level: 1 },
71 moderate: { label: 'moderate to undo', color: 'warning', level: 2 },
72 hard: { label: 'hard to undo', color: 'error', level: 3 },
73}
74
75const STATUS: Record<Verdict, { icon: string; color: string; label: string }> = {
76 pending: { icon: '●', color: 'warning', label: 'to review' },
77 approved: { icon: '✓', color: 'success', label: 'approved' },
78 rejected: { icon: '✗', color: 'error', label: 'rejected' },
79}
80
81const TABS: readonly { value: DecisionTab; label: string }[] = [
82 { value: 'all', label: 'All' },
83 { value: 'pending', label: 'To review' },
84 { value: 'approved', label: 'Approved' },
85 { value: 'rejected', label: 'Rejected' },
86]
87
88const asText = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
89
90const asReversibility = (value: unknown): Reversibility =>
91 value === 'easy' || value === 'hard' ? value : 'moderate'
92
93// What Claude reads when a verdict changes; undefined when there is nothing to tell it.
94const feedbackFor = (before: Decision, after: Decision): string | undefined => {
95 const name = `D${after.id} ("${after.choice}")`
96 const comment = after.comment === '' ? '' : ` Their comment: ${after.comment}`
97
98 if (after.verdict === 'rejected') {
99 const opening =
100 before.verdict === 'approved'
101 ? `the user changed their mind and rejected ${name}, which they had approved.`
102 : `the user rejected ${name}.`
103 const ask =
104 after.comment === ''
105 ? ' They left no comment; ask them what they want instead if it is not clear.'
106 : comment
107
108 return `Decision log: ${opening}${ask} Revisit it before going further: undo or change what followed from it, then log the replacement decision.`
109 }
110
111 if (after.verdict === 'approved') {
112 if (before.verdict === 'rejected') {
113 return `Decision log: the user changed their mind and approved ${name}, which they had rejected.${comment} If you have not acted on the rejection yet, keep the decision as it was. If you already reworked it, ask them before putting it back.`
114 }
115
116 return after.comment === '' ? undefined : `Decision log: the user approved ${name} and commented: ${after.comment}`
117 }
118
119 if (before.verdict === 'rejected') {
120 return `Decision log: the user withdrew their rejection of ${name} and is reviewing it again. Do not act on that rejection unless they reject it again.`
121 }
122
123 // A plain approval was never sent, so taking it back needs no word to Claude.
124 return before.comment === ''
125 ? undefined
126 : `Decision log: the user reopened ${name}, which they had approved with a comment. It is under review again, so hold off on that comment until they decide.`
127}
128
129const clip = (text: string, room: number): string =>
130 text.length <= room ? text : `${text.slice(0, Math.max(1, room - 1))}…`
131
132const shownIn = (list: readonly Decision[], tab: DecisionTab): readonly Decision[] =>
133 tab === 'all' ? list : list.filter(one => one.verdict === tab)
134
135// The stored selection when it is on this tab, else the first decision waiting, else the newest.
136const selectedIn = (shown: readonly Decision[], stored: number | null): Decision | undefined =>
137 shown.find(one => one.id === stored) ??
138 shown.find(one => one.verdict === 'pending') ??
139 shown[shown.length - 1]
140
141// Comments typed in the pane, by decision id, until a verdict button takes them.
142const drafts = new Map<number, string>()
143
144// The person closed the pane: a new decision updates the status line instead of reopening it.
145let isPaneDismissed = false
146
147// Pixel animation: one timer runs while the pane shows something that moves, and stops once
148// nothing does. The terminal's last drawing says what that is; the timer repaints between drawings.
149const TICK_MS = 100
150const TICKS_PER_MASCOT_FRAME = 3
151let animation: Timer | undefined
152let tick = 0
153let mascotFrame = 0
154let isMascotShown = false
155// The mascot acts out decisions while Claude works and sits blinking otherwise.
156let isMascotBusy = false
157// What the mascot shows now, so the timer repaints only when a frame differs.
158let mascotShowing = ''
159let shimmer: { width: number; left: string; right: string } | undefined
160
161const mascotFrames = (): readonly string[] => (isMascotBusy ? MASCOT_WORKING_FRAMES : MASCOT_IDLE_FRAMES)
162
163// The frame a drawing shows now; the timer carries on from it.
164const mascotNow = (): string => {
165 mascotShowing = mascotFrames()[mascotFrame] ?? ''
166
167 return mascotShowing
168}
169
170// Where the light sweeping the title bar is at a tick: in from the left, out past the right.
171const glowAt = (at: number, width: number): number => ((at * 1.5) % (width + 24)) - 12
172
173function stopAnimation() {
174 animation?.cancel()
175 animation = undefined
176}
177
178async function repaint($: EngineInterface, key: string, cells: string) {
179 try {
180 const result = await $.ui.blit({ requestId: PANE, key, cells })
181
182 // Refused: the pane closed or its drawing moved on. The next drawing starts it again.
183 if (result.deny !== undefined) {
184 stopAnimation()
185 }
186 } catch {
187 stopAnimation()
188 }
189}
190
191function animate($: EngineInterface) {
192 if (animation !== undefined || (!isMascotShown && shimmer === undefined)) {
193 return
194 }
195
196 animation = $.clock.every(TICK_MS, () => {
197 tick += 1
198
199 if (!isMascotShown && shimmer === undefined) {
200 stopAnimation()
201 return
202 }
203
204 if (isMascotShown && tick % TICKS_PER_MASCOT_FRAME === 0) {
205 const shown = mascotShowing
206 mascotFrame = (mascotFrame + 1) % mascotFrames().length
207
208 if (mascotNow() !== shown) {
209 void repaint($, 'mascot', mascotShowing)
210 }
211 }
212
213 if (shimmer !== undefined) {
214 const { width, left, right } = shimmer
215 void repaint($, 'banner', banner(width, left, right, glowAt(tick, width)))
216 }
217 })
218}
219
220function showStatus($: EngineInterface, list: readonly Decision[]) {
221 const pending = list.filter(one => one.verdict === 'pending').length
222
223 $.ui.status(
224 pending === 0
225 ? undefined
226 : `${pending} decision${pending === 1 ? '' : 's'} to review (/decisions)`,
227 )
228}
229
230function openPane($: EngineInterface, isFocusing: boolean) {
231 return $.ui.open({ id: PANE, title: 'Decisions', ...(isFocusing ? { focus: true } : {}) })
232}
233
234async function select($: EngineInterface, id: number) {
235 await update($, selectedId, () => id)
236 await update($, commentingId, current => (current === id ? current : null))
237}
238
239async function moveSelection($: EngineInterface, by: number) {
240 const shown = shownIn(await read($, decisions), await read($, currentTab))
241 const current = selectedIn(shown, await read($, selectedId))
242
243 if (current === undefined) {
244 return
245 }
246
247 const index = shown.indexOf(current) + by
248 const target = shown[Math.min(shown.length - 1, Math.max(0, index))]
249
250 if (target !== undefined) {
251 await select($, target.id)
252 }
253}
254
255async function startComment($: EngineInterface, id: number) {
256 await update($, commentingId, () => id)
257
258 // Best effort: the box is open either way, and moving the ring there only saves a Tab.
259 try {
260 await $.ui.focus({ requestId: PANE, key: `comment:${id}` })
261 } catch (error) {
262 $.ui.log(`decision-log: could not focus the comment box: ${String(error)}`, { to: 'debug' })
263 }
264}
265
266// Every verdict change goes through here: a first verdict, a switch, a reopen, an undo.
267async function setVerdict($: EngineInterface, id: number, verdict: Verdict) {
268 const isClaudeWorking = await read($, isWorking)
269 let before: Decision | undefined
270 let after: Decision | undefined
271
272 const list = await update($, decisions, all =>
273 all.map(one => {
274 if (one.id !== id || one.verdict === verdict) {
275 return one
276 }
277
278 const comment = verdict === 'pending' ? '' : (drafts.get(id) ?? one.comment).trim()
279 before = one
280 after = {
281 ...one,
282 verdict,
283 comment,
284 isAwaitingRevisit: !isClaudeWorking && (verdict === 'rejected' || (verdict === 'approved' && comment !== '')),
285 }
286
287 return after
288 }),
289 )
290
291 if (before === undefined || after === undefined) {
292 return
293 }
294
295 await update($, commentingId, () => null)
296 await update($, lastChange, () => ({ id, from: before?.verdict ?? 'pending', to: verdict }))
297 showStatus($, list)
298
299 if (verdict === 'pending') {
300 // Reopened: its old comment comes back to edit, and it waits on the To review tab.
301 drafts.set(id, before.comment)
302 await update($, currentTab, () => 'pending')
303 await update($, selectedId, () => id)
304 } else {
305 drafts.delete(id)
306 }
307
308 if (before.verdict === 'pending') {
309 const waiting = list.filter(one => one.verdict === 'pending')
310 const nextUp = waiting.find(one => one.id > id) ?? waiting[0]
311
312 if (nextUp !== undefined) {
313 await update($, selectedId, () => nextUp.id)
314 }
315 }
316
317 const text = feedbackFor(before, after)
318
319 if (text !== undefined) {
320 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
321 }
322}
323
324async function undoLastChange($: EngineInterface) {
325 const change = await read($, lastChange)
326 const decision = (await read($, decisions)).find(one => one.id === change?.id)
327
328 if (change === null || decision === undefined || decision.verdict !== change.to) {
329 return
330 }
331
332 await setVerdict($, change.id, change.from)
333 await update($, lastChange, () => null)
334 await update($, selectedId, () => change.id)
335}
336
337async function revisit($: EngineInterface) {
338 const waiting = (await read($, decisions)).filter(one => one.isAwaitingRevisit)
339 const ids = (verdict: Verdict) =>
340 waiting
341 .filter(one => one.verdict === verdict)
342 .map(one => `D${one.id}`)
343 .join(', ')
344 const done = [
345 ids('rejected') === '' ? '' : `rejected ${ids('rejected')}`,
346 ids('approved') === '' ? '' : `commented on ${ids('approved')}`,
347 ].filter(part => part !== '')
348
349 if (done.length === 0) {
350 return
351 }
352
353 await update($, decisions, all => all.map(one => ({ ...one, isAwaitingRevisit: false })))
354 await $.prompt.submit({
355 text: `In the decision log I ${done.join(' and ')}. Please act on my feedback.`,
356 asUser: true,
357 })
358}
359
360async function clearReviewed($: EngineInterface) {
361 const list = await update($, decisions, all =>
362 all.filter(one => one.verdict === 'pending' || one.isAwaitingRevisit),
363 )
364 showStatus($, list)
365}
366
367export const register: Register = on => {
368 on('session.start', async ($, e, next) => {
369 await $.tool.register({
370 name: 'log_decision',
371 description: TOOL_DESCRIPTION,
372 inputSchema: INPUT_SCHEMA,
373 isDeferred: false,
374 })
375 await $.command.register({
376 name: 'decisions',
377 description: 'Open the decision log pane; "clear" drops reviewed decisions',
378 argumentHint: '[clear]',
379 immediate: true,
380 })
381 showStatus($, await read($, decisions))
382
383 return next(e)
384 })
385
386 on('prompt.compose', async ($, e, next) => {
387 const composed = await next(e)
388
389 if (!e.tools.includes(TOOL)) {
390 return composed
391 }
392
393 return {
394 sections: [
395 ...composed.sections,
396 { id: 'decision-log:instructions', text: INSTRUCTIONS, scope: 'session' as const },
397 ],
398 }
399 })
400
401 // Logging a decision only writes to this pane, so it never needs a permission prompt.
402 on('tool.check', { tool: TOOL_PATTERN }, () => ({ decision: 'allow' as const }))
403
404 on('tool.call', { tool: TOOL_PATTERN }, async ($, e) => {
405 const input = e as unknown as Record<string, unknown>
406 const choice = asText(input.choice)
407
408 if (choice === '') {
409 return { deny: 'log_decision needs a choice: what you decided, in one line.' }
410 }
411
412 let id = 0
413 const reversibility = asReversibility(input.reversibility)
414 const list = await update($, decisions, all => {
415 id = all.reduce((max, one) => Math.max(max, one.id), 0) + 1
416
417 return [
418 ...all,
419 {
420 id,
421 choice,
422 why: asText(input.why),
423 alternatives: asText(input.alternatives),
424 impact: asText(input.impact),
425 reversibility,
426 isFromSubagent: e.agentId !== undefined,
427 verdict: 'pending' as const,
428 comment: '',
429 isAwaitingRevisit: false,
430 },
431 ]
432 })
433
434 showStatus($, list)
435
436 // Bring a new decision into view unless the person is looking at one still waiting.
437 const shownId = await read($, selectedId)
438 const shownDecision = list.find(one => one.id === shownId)
439
440 if (shownDecision === undefined || shownDecision.verdict !== 'pending') {
441 await update($, selectedId, () => id)
442 }
443
444 if (reversibility === 'hard') {
445 $.ui.toast(`D${id} is hard to undo: ${choice}`, { timeoutMs: 8000 })
446 }
447
448 if (!isPaneDismissed) {
449 void openPane($, false)
450 }
451
452 return {
453 result: `Logged as D${id}. The user reviews it in their decision log; carry on without waiting.`,
454 }
455 })
456
457 on('turn.start', async ($, e, next) => {
458 await update($, isWorking, () => true)
459 // Claude reads every verdict already appended at the start of this turn.
460 await update($, decisions, all =>
461 all.some(one => one.isAwaitingRevisit)
462 ? all.map(one => ({ ...one, isAwaitingRevisit: false }))
463 : all,
464 )
465
466 return next(e)
467 })
468
469 on('turn.complete', async ($, e, next) => {
470 if (e.agentId === undefined) {
471 await update($, isWorking, () => false)
472 }
473
474 return next(e)
475 })
476
477 on('command.run', { command: 'decisions' }, async ($, e) => {
478 if (e.args.trim() === 'clear') {
479 await clearReviewed($)
480
481 return { text: 'Cleared reviewed decisions.' }
482 }
483
484 isPaneDismissed = false
485 await openPane($, true)
486
487 return { text: 'Decision log opened.' }
488 })
489
490 on('ui.close', async ($, e, next) => {
491 if (e.id === PANE && e.origin.kind === 'person') {
492 isPaneDismissed = true
493 }
494
495 return next(e)
496 })
497
498 // Moving the focus ring onto a table row selects it, so the arrows flick through the details.
499 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
500 const id = e.element?.startsWith('row:') === true ? Number(e.element.slice(4)) : NaN
501
502 if (!Number.isNaN(id)) {
503 await select($, id)
504 }
505
506 return next(e)
507 })
508
509
510 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
511 const elements = $.ui.resolve(e)
512 const { Box, Button, Text } = elements
513 const Input = 'Input' in elements ? elements.Input : undefined
514 const Raster = 'Raster' in elements ? elements.Raster : undefined
515 const list = await read($, decisions)
516 const tab = await read($, currentTab)
517 const stored = await read($, selectedId)
518 const commenting = await read($, commentingId)
519 const change = await read($, lastChange)
520 const isClaudeWorking = await read($, isWorking)
521
522 if (Raster !== undefined && isMascotBusy !== isClaudeWorking) {
523 isMascotBusy = isClaudeWorking
524 mascotFrame = 0
525 }
526
527 const pending = list.filter(one => one.verdict === 'pending').length
528 const approved = list.filter(one => one.verdict === 'approved').length
529 const rejected = list.filter(one => one.verdict === 'rejected').length
530 const awaitingRevisit = list.filter(one => one.isAwaitingRevisit)
531 const counts: Record<DecisionTab, number> = { all: list.length, pending, approved, rejected }
532 const shown = shownIn(list, tab)
533 const selected = selectedIn(shown, stored)
534 const isCommenting = Input !== undefined && selected !== undefined && commenting === selected.id
535 // No hotkeys while the comment box is open, so typing never presses a button.
536 const hotkey = (key: string) => (isCommenting ? {} : { hotkey: key })
537 const width = Math.max(24, e.props.bodyColumns)
538
539 const heading = (title: string) => (
540 <Box marginTop={1}>
541 <Text bold color="claude">
542 {title.toUpperCase()}
543 </Text>
544 <Text dimColor>{` ${'─'.repeat(Math.max(0, width - title.length - 1))}`}</Text>
545 </Box>
546 )
547
548 const gauge = (reversibility: Reversibility) => {
549 const undo = REVERSIBILITY[reversibility]
550
551 return (
552 <Text>
553 <Text color={undo.color}>{'●'.repeat(undo.level)}</Text>
554 <Text dimColor>{'○'.repeat(3 - undo.level)}</Text>
555 </Text>
556 )
557 }
558
559 const pill = (text: string, color: string) => (
560 <Text backgroundColor={color} color="inverseText" bold>
561 {` ${text} `}
562 </Text>
563 )
564
565 const header = () => {
566 const right = isClaudeWorking ? '● Claude is working ' : `${list.length} logged `
567
568 if (Raster !== undefined) {
569 // While Claude works a light sweeps along the bar, from where the timer has it now.
570 const glow = isClaudeWorking ? glowAt(tick, width) : undefined
571 shimmer = isClaudeWorking ? { width, left: TITLE, right } : undefined
572
573 return <Raster key="banner" columns={width} rows={1} cells={banner(width, TITLE, right, glow)} />
574 }
575
576 return (
577 <Box gap={1}>
578 <Text bold>
579 <Text color="claude">◆</Text> Decision log
580 </Text>
581 <Text dimColor>· {right.trim()}</Text>
582 </Box>
583 )
584 }
585
586 const progressBar = () => {
587 const approvedCells = Math.round((approved / list.length) * width)
588 const rejectedCells = Math.min(width - approvedCells, Math.round((rejected / list.length) * width))
589
590 return (
591 <Text>
592 <Text color="success">{'▔'.repeat(approvedCells)}</Text>
593 <Text color="error">{'▔'.repeat(rejectedCells)}</Text>
594 <Text dimColor>{'▔'.repeat(width - approvedCells - rejectedCells)}</Text>
595 </Text>
596 )
597 }
598
599 const tabButton = ({ value, label }: { value: DecisionTab; label: string }) => {
600 const isActive = value === tab
601
602 return (
603 <Button key={`tab:${value}`} plain onPress={() => update($, currentTab, () => value)}>
604 {isActive ? (
605 pill(`${label} ${counts[value]}`, 'claude')
606 ) : (
607 <Text color="inactive">{` ${label} ${counts[value]} `}</Text>
608 )}
609 </Button>
610 )
611 }
612
613 // The table shows a window of rows around the selection so the detail stays on screen.
614 const room = Math.max(4, Math.min(15, e.props.scroll.bodyRows - 26))
615 const index = selected === undefined ? 0 : shown.indexOf(selected)
616 const start = Math.min(Math.max(0, index - Math.floor(room / 2)), Math.max(0, shown.length - room))
617 const rows = shown.slice(start, start + room)
618 const below = shown.length - start - rows.length
619
620 const row = (one: Decision) => {
621 const isSelected = one.id === selected?.id
622 const status = STATUS[one.verdict]
623 const undo = REVERSIBILITY[one.reversibility]
624
625 return (
626 <Button key={`row:${one.id}`} plain onPress={() => select($, one.id)}>
627 <Text color="claude">{isSelected ? '▌' : ' '}</Text>
628 <Text color={status.color}>{` ${status.icon} `}</Text>
629 <Text bold={isSelected} color={isSelected ? 'claude' : 'inactive'}>
630 {`D${one.id}`.padEnd(4)}
631 </Text>
632 <Text color={undo.color}>{'●'.repeat(undo.level)}</Text>
633 <Text dimColor>{'○'.repeat(3 - undo.level)}</Text>
634 <Text bold={isSelected} dimColor={!isSelected}>
635 {` ${clip(one.choice, width - 14)}`}
636 </Text>
637 </Button>
638 )
639 }
640
641 const field = (icon: string, color: string, label: string, value: string) =>
642 value !== '' && (
643 <Box flexDirection="column" marginTop={1}>
644 <Text color={color} bold>
645 {`${icon} ${label}`}
646 </Text>
647 <Box paddingLeft={2}>
648 <Text>{value}</Text>
649 </Box>
650 </Box>
651 )
652
653 const panel = (one: Decision) => {
654 const undo = REVERSIBILITY[one.reversibility]
655 const status = STATUS[one.verdict]
656 const draft = (drafts.get(one.id) ?? '').trim()
657 const isPending = one.verdict === 'pending'
658
659 return (
660 <Box
661 key="detail"
662 flexDirection="column"
663 borderStyle="round"
664 borderColor={undo.color}
665 hover={{ borderColor: 'claude' }}
666 paddingX={1}
667 marginTop={1}
668 >
669 <Box gap={1}>
670 {pill(`D${one.id}`, 'claude')}
671 {pill(`${status.icon} ${status.label.toUpperCase()}`, status.color)}
672 {gauge(one.reversibility)}
673 <Text color={undo.color}>{undo.label}</Text>
674 {one.isFromSubagent && <Text dimColor>· subagent</Text>}
675 </Box>
676 <Box marginTop={1}>
677 <Text bold>{one.choice}</Text>
678 </Box>
679 {field('»', 'suggestion', 'Why', one.why)}
680 {field('⇄', 'permission', 'Instead of', one.alternatives)}
681 {field('▲', 'warning', 'Ramifications', one.impact)}
682 {!isPending && one.comment !== '' && field('↳', 'text', 'Your comment', one.comment)}
683 {one.isAwaitingRevisit && (
684 <Box marginTop={1}>
685 <Text color="warning">
686 {one.verdict === 'rejected' ? '↻ waiting for Claude to revisit' : '↻ waiting for Claude to read your comment'}
687 </Text>
688 </Box>
689 )}
690 {isPending && isCommenting && Input !== undefined && (
691 <Box marginTop={1}>
692 <Input
693 key={`comment:${one.id}`}
694 label="✎"
695 placeholder="type a comment"
696 value={drafts.get(one.id) ?? ''}
697 submitLabel="keep"
698 onInput={value => {
699 drafts.set(one.id, value)
700 }}
701 onSubmit={value => {
702 drafts.set(one.id, value)
703 void update($, commentingId, () => null)
704 }}
705 />
706 </Box>
707 )}
708 {isPending && !isCommenting && draft !== '' && field('✎', 'permission', 'Your comment', draft)}
709 {isPending && (
710 <Box marginTop={1} gap={2}>
711 <Button key="approve" plain {...hotkey('a')} onPress={() => setVerdict($, one.id, 'approved')}>
712 {pill('✓ Approve', 'success')}
713 </Button>
714 <Button key="reject" plain {...hotkey('r')} onPress={() => setVerdict($, one.id, 'rejected')}>
715 {pill('✗ Reject', 'error')}
716 </Button>
717 {Input !== undefined && !isCommenting && (
718 <Button key="comment" plain {...hotkey('c')} onPress={() => startComment($, one.id)}>
719 {pill(draft === '' ? '✎ Comment' : '✎ Edit comment', 'permission')}
720 </Button>
721 )}
722 </Box>
723 )}
724 {!isPending && (
725 <Box marginTop={1} gap={2}>
726 {one.verdict === 'approved' ? (
727 <Button key="reject" plain {...hotkey('r')} onPress={() => setVerdict($, one.id, 'rejected')}>
728 {pill('✗ Reject instead', 'error')}
729 </Button>
730 ) : (
731 <Button key="approve" plain {...hotkey('a')} onPress={() => setVerdict($, one.id, 'approved')}>
732 {pill('✓ Approve instead', 'success')}
733 </Button>
734 )}
735 <Button key="reopen" plain {...hotkey('o')} onPress={() => setVerdict($, one.id, 'pending')}>
736 {pill('↺ Reopen', 'permission')}
737 </Button>
738 </Box>
739 )}
740 </Box>
741 )
742 }
743
744 const hero = () => (
745 <Box flexDirection="column" alignItems="center">
746 {Raster !== undefined && (
747 <Raster key="mascot" columns={MASCOT_SIZE.columns} rows={MASCOT_SIZE.rows} cells={mascotNow()} />
748 )}
749 <Text color={isClaudeWorking ? 'claude' : 'inactive'} bold={isClaudeWorking}>
750 {isClaudeWorking ? 'working' : 'idle'}
751 </Text>
752 </Box>
753 )
754
755 const overview = () => {
756 const byUndo = (['easy', 'moderate', 'hard'] as const).map(level => ({
757 level,
758 count: list.filter(one => one.reversibility === level).length,
759 }))
760 const most = Math.max(1, ...byUndo.map(one => one.count))
761 const barRoom = Math.max(4, width - 24)
762
763 return (
764 <Box flexDirection="column">
765 {heading('Overview')}
766 <Box gap={3} marginTop={1}>
767 {hero()}
768 <Box flexDirection="column" justifyContent="center">
769 <Text bold>{pending === 0 ? '✓ All caught up' : `${pending} waiting for review`}</Text>
770 <Text dimColor>{`${list.length} decisions this session`}</Text>
771 <Box marginTop={1} flexDirection="column">
772 <Text>
773 <Text color="success">■</Text> {approved} approved
774 </Text>
775 <Text>
776 <Text color="error">■</Text> {rejected} rejected
777 </Text>
778 <Text>
779 <Text color="warning">■</Text> {pending} to review
780 </Text>
781 </Box>
782 </Box>
783 </Box>
784 {heading('How hard to undo')}
785 {byUndo.map(({ level, count }) => (
786 <Box key={`undo:${level}`} gap={1}>
787 {gauge(level)}
788 <Text dimColor>{level.padEnd(9)}</Text>
789 <Text color={REVERSIBILITY[level].color}>
790 {'█'.repeat(Math.round((count / most) * barRoom)) || '·'}
791 </Text>
792 <Text dimColor>{String(count)}</Text>
793 </Box>
794 ))}
795 {heading('Recent')}
796 {list
797 .slice(-5)
798 .reverse()
799 .map(one => (
800 <Button key={`recent:${one.id}`} plain onPress={() => select($, one.id).then(() => update($, currentTab, () => 'all'))}>
801 <Text color={STATUS[one.verdict].color}>{`${STATUS[one.verdict].icon} `}</Text>
802 <Text color="inactive">{`D${one.id}`.padEnd(4)}</Text>
803 <Text dimColor>{` ${clip(one.choice, width - 8)}`}</Text>
804 </Button>
805 ))}
806 </Box>
807 )
808 }
809
810 const undoable = list.find(one => one.id === change?.id && one.verdict === change.to)
811 const undoKey = undoable === undefined ? '' : ' · u undo'
812 const keyHelp = isCommenting
813 ? 'Enter keeps the comment and turns the hotkeys back on'
814 : !e.props.isFocused
815 ? 'ctrl+x tab to review from the keyboard'
816 : selected !== undefined && selected.verdict !== 'pending'
817 ? `n/p next/previous · o reopen · ${selected.verdict === 'approved' ? 'r reject' : 'a approve'} instead${undoKey} · Esc back to the prompt`
818 : `n/p next/previous · a approve · r reject · c comment${undoKey} · Esc back to the prompt`
819
820 const undoStrip = (one: Decision) => {
821 const verb = { pending: 'Reopened', approved: 'Approved', rejected: 'Rejected' }[one.verdict]
822
823 return (
824 <Box gap={1} marginTop={1}>
825 <Text color={STATUS[one.verdict].color}>{STATUS[one.verdict].icon}</Text>
826 <Text bold>{`${verb} D${one.id}`}</Text>
827 <Text dimColor>{clip(one.choice, Math.max(8, width - 30))}</Text>
828 <Button key="undo" plain {...hotkey('u')} onPress={() => undoLastChange($)}>
829 {pill('↺ Undo', 'permission')}
830 </Button>
831 </Box>
832 )
833 }
834
835 const tree = (
836 <Box flexDirection="column">
837 {header()}
838 {list.length === 0 && (
839 <Box flexDirection="column" marginTop={Math.max(1, Math.floor((e.props.scroll.bodyRows - 6) / 2))}>
840 {hero()}
841 </Box>
842 )}
843 {list.length > 0 && (
844 <Box flexDirection="column" marginTop={1}>
845 <Box gap={1}>{TABS.map(tabButton)}</Box>
846 {progressBar()}
847 {undoable !== undefined && undoStrip(undoable)}
848 </Box>
849 )}
850 {awaitingRevisit.length > 0 && !isClaudeWorking && (
851 <Box
852 flexDirection="column"
853 borderStyle="round"
854 borderColor="warning"
855 paddingX={1}
856 marginTop={1}
857 >
858 <Box gap={1}>
859 <Text color="warning">↻</Text>
860 <Text>
861 Claude has your feedback on {awaitingRevisit.map(one => `D${one.id}`).join(', ')} and
862 will act on it with your next message.
863 </Text>
864 </Box>
865 <Button key="revisit" plain onPress={() => revisit($)}>
866 {pill('↻ Ask Claude to revisit now', 'warning')}
867 </Button>
868 </Box>
869 )}
870 {list.length > 0 && shown.length === 0 && tab !== 'pending' && (
871 <Box marginTop={1}>
872 <Text dimColor>Nothing here yet.</Text>
873 </Box>
874 )}
875 {shown.length > 0 && (
876 <Box flexDirection="column" marginTop={1}>
877 <Text dimColor>{' ID UNDO DECISION'}</Text>
878 {start > 0 && <Text dimColor>{` ↑ ${start} more`}</Text>}
879 {rows.map(row)}
880 {below > 0 && <Text dimColor>{` ↓ ${below} more`}</Text>}
881 <Box gap={3} marginTop={1}>
882 <Button key="prev" plain dimColor {...hotkey('p')} onPress={() => moveSelection($, -1)}>
883 ‹ prev
884 </Button>
885 <Button key="next" plain dimColor {...hotkey('n')} onPress={() => moveSelection($, 1)}>
886 next ›
887 </Button>
888 {approved + rejected > 0 && (
889 <Button key="clear-reviewed" plain dimColor onPress={() => clearReviewed($)}>
890 clear reviewed
891 </Button>
892 )}
893 </Box>
894 </Box>
895 )}
896 {selected !== undefined && panel(selected)}
897 {list.length > 0 && shown.length === 0 && overview()}
898 {list.length > 0 && (
899 <Box marginTop={1}>
900 <Text dimColor>{keyHelp}</Text>
901 </Box>
902 )}
903 </Box>
904 )
905
906 if (Raster !== undefined) {
907 isMascotShown = list.length === 0 || shown.length === 0
908 animate($)
909 }
910
911 return tree
912 })
913}
914
915hooks/graphics.ts 238 lines1// Pixel graphics for the terminal's Raster element: each cell is a character with a 24-bit
2// foreground and background, so a half block (▀) shows two square pixels stacked.
3
4export const DEFAULT_COLOR = 0x01000000
5
6export const PALETTE = {
7 claude: 0xd97757,
8 violet: 0x8a6fd8,
9 white: 0xffffff,
10}
11
12const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
13
14const toBase64 = (bytes: Uint8Array): string => {
15 let out = ''
16
17 for (let i = 0; i < bytes.length; i += 3) {
18 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
19 out += BASE64.charAt((n >> 18) & 63) + BASE64.charAt((n >> 12) & 63)
20 out += i + 1 < bytes.length ? BASE64.charAt((n >> 6) & 63) : '='
21 out += i + 2 < bytes.length ? BASE64.charAt(n & 63) : '='
22 }
23
24 return out
25}
26
27const mix = (from: number, to: number, t: number): number => {
28 const channel = (shift: number) => {
29 const a = (from >> shift) & 0xff
30 const b = (to >> shift) & 0xff
31 return Math.round(a + (b - a) * t) << shift
32 }
33
34 return channel(16) | channel(8) | channel(0)
35}
36
37class Cells {
38 private readonly words: number[]
39
40 constructor(
41 readonly columns: number,
42 readonly rows: number,
43 ) {
44 this.words = []
45
46 for (let i = 0; i < columns * rows; i += 1) {
47 this.words.push(0x20, DEFAULT_COLOR, DEFAULT_COLOR)
48 }
49 }
50
51 set(x: number, y: number, char: string, foreground: number, background: number) {
52 if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) {
53 return
54 }
55
56 const at = (y * this.columns + x) * 3
57 this.words[at] = char.codePointAt(0) ?? 0x20
58 this.words[at + 1] = foreground
59 this.words[at + 2] = background
60 }
61
62 encode(): string {
63 const view = new DataView(new ArrayBuffer(this.words.length * 4))
64 this.words.forEach((word, index) => view.setUint32(index * 4, word, true))
65
66 return toBase64(new Uint8Array(view.buffer))
67 }
68}
69
70// One row: text over a left-to-right gradient, the right-hand text flush with the end.
71// `glow`, a column, lights the gradient around it: moved each frame, it sweeps along.
72export function banner(columns: number, left: string, right: string, glow?: number): string {
73 const cells = new Cells(columns, 1)
74 const rightStart = columns - right.length
75
76 for (let x = 0; x < columns; x += 1) {
77 const gradient = mix(PALETTE.claude, PALETTE.violet, columns === 1 ? 0 : x / (columns - 1))
78 const light = glow === undefined ? 0 : Math.max(0, 1 - Math.abs(x - glow) / 6) * 0.45
79 const background = mix(gradient, PALETTE.white, light)
80 const char = left[x] ?? (x >= rightStart ? right[x - rightStart] : undefined) ?? ' '
81 cells.set(x, 0, char, PALETTE.white, background)
82 }
83
84 return cells.encode()
85}
86
87export type Glyph = { column: number; row: number; char: string; color: number }
88
89// Quarter blocks by which of a cell's four pixels are lit: upper left 1, upper right 2,
90// lower left 4, lower right 8.
91const QUADRANTS = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'
92
93// A one-colour picture in quarter blocks: each cell shows 2 by 2 pixels, each half a column wide
94// and half a row tall, the way Claude Code draws its own logo. Lit pixels are letters, unlit dots.
95export function quarterSprite(
96 art: readonly string[],
97 color: number,
98 glyphs: readonly Glyph[] = [],
99): { columns: number; rows: number; cells: string } {
100 const columns = Math.ceil(Math.max(...art.map(line => line.length)) / 2)
101 const rows = Math.ceil(art.length / 2)
102 const cells = new Cells(columns, rows)
103 const isLit = (x: number, y: number) => (art[y]?.charAt(x) ?? '.') !== '.' && art[y]?.charAt(x) !== ''
104
105 for (let row = 0; row < rows; row += 1) {
106 for (let column = 0; column < columns; column += 1) {
107 const [x, y] = [column * 2, row * 2]
108 const mask =
109 (isLit(x, y) ? 1 : 0) | (isLit(x + 1, y) ? 2 : 0) | (isLit(x, y + 1) ? 4 : 0) | (isLit(x + 1, y + 1) ? 8 : 0)
110
111 if (mask !== 0) {
112 cells.set(column, row, QUADRANTS.charAt(mask), color, DEFAULT_COLOR)
113 }
114 }
115 }
116
117 glyphs.forEach(({ column, row, char, color: glyphColor }) => cells.set(column, row, char, glyphColor, DEFAULT_COLOR))
118
119 return { columns, rows, cells: cells.encode() }
120}
121
122// The mascot: Claude Code's critter, a fan homage drawn pixel for pixel from the block-character
123// logo on its welcome screen (2 by 2 pixels per character, so 18 by 6: 9 columns by 3 rows). It glances about
124// and mulls a decision over until a tick or a cross appears in its thought bubble.
125const CRITTER = [
126 '...OOOOOOOOOOOO...',
127 '...OOOOOOOOOOOO...',
128 '.OOOOOOOOOOOOOOOO.',
129 '...OOOOOOOOOOOO...',
130 '....O.O....O.O....',
131 '..................',
132]
133
134// Its eyes are holes in the head; looking left or right moves them a pixel.
135const EYES = { left: [4, 11], ahead: [5, 12], right: [6, 13], shut: [] } as const
136
137// In pixels: the critter sits in the middle, under a row for its thought bubble, with as much
138// room on its left as the bubble takes on its right, so centring the picture centres the critter.
139const CANVAS = { width: 42, height: 8, headroom: 2, inset: 12 }
140
141// A thought bubble builds up from the head in stages: a dot, a small ring, then the bubble,
142// where the decision appears. Cells, clear of the critter.
143const BUBBLE_COLOR = 0x9aa0a6
144const BUBBLE_STAGES: readonly Glyph[][] = [
145 [],
146 [{ column: 16, row: 1, char: '·', color: BUBBLE_COLOR }],
147 [{ column: 17, row: 0, char: 'o', color: BUBBLE_COLOR }],
148 [
149 { column: 18, row: 0, char: '(', color: BUBBLE_COLOR },
150 { column: 20, row: 0, char: ')', color: BUBBLE_COLOR },
151 ],
152]
153const VERDICTS = {
154 approved: { column: 19, row: 0, char: '✓', color: 0x4eba65 },
155 rejected: { column: 19, row: 0, char: '✗', color: 0xe5534b },
156} as const
157
158type Pose = {
159 eyes: keyof typeof EYES
160 bubble?: 0 | 1 | 2 | 3
161 verdict?: keyof typeof VERDICTS
162}
163
164// While Claude works: look about, let a thought slowly form, blink on it, then decide. Twice:
165// once a tick, once a cross. Each pose shows for one frame of the pane's animation.
166const decision = (verdict: keyof typeof VERDICTS): Pose[] => [
167 { eyes: 'ahead' },
168 { eyes: 'ahead' },
169 { eyes: 'left' },
170 { eyes: 'right' },
171 { eyes: 'left' },
172 { eyes: 'ahead' },
173 { eyes: 'ahead', bubble: 1 },
174 { eyes: 'ahead', bubble: 1 },
175 { eyes: 'ahead', bubble: 2 },
176 { eyes: 'ahead', bubble: 2 },
177 { eyes: 'ahead', bubble: 3 },
178 { eyes: 'shut', bubble: 3 },
179 { eyes: 'ahead', bubble: 3 },
180 { eyes: 'ahead', bubble: 3, verdict },
181 { eyes: 'ahead', bubble: 3, verdict },
182 { eyes: 'ahead', bubble: 3, verdict },
183 { eyes: 'ahead', bubble: 3, verdict },
184]
185
186const WORKING_POSES: readonly Pose[] = [...decision('approved'), ...decision('rejected')]
187
188// While Claude is idle: sit still and blink now and then, sometimes twice.
189const IDLE_POSES: readonly Pose[] = [
190 ...Array.from({ length: 26 }, (): Pose => ({ eyes: 'ahead' })),
191 { eyes: 'shut' },
192 ...Array.from({ length: 14 }, (): Pose => ({ eyes: 'ahead' })),
193 { eyes: 'shut' },
194 { eyes: 'ahead' },
195 { eyes: 'shut' },
196]
197
198const pose = ({ eyes }: Pose): string[] => {
199 const critter = CRITTER.map(line => [...line])
200
201 EYES[eyes].forEach(x => {
202 const line = critter[1]
203
204 if (line !== undefined) {
205 line[x] = '.'
206 }
207 })
208
209 const top = CANVAS.headroom
210 const canvas = Array.from({ length: CANVAS.height }, () => [...'.'.repeat(CANVAS.width)])
211 critter.forEach((line, y) =>
212 line.forEach((letter, x) => {
213 const row = canvas[top + y]
214
215 if (letter !== '.' && row !== undefined) {
216 row[CANVAS.inset + x] = letter
217 }
218 }),
219 )
220
221 return canvas.map(line => line.join(''))
222}
223
224const thought = ({ bubble = 0, verdict }: Pose): Glyph[] => [
225 ...BUBBLE_STAGES.slice(1, bubble + 1).flat(),
226 ...(verdict === undefined ? [] : [VERDICTS[verdict]]),
227]
228
229const frames = (poses: readonly Pose[]): readonly string[] =>
230 poses.map(one => quarterSprite(pose(one), PALETTE.claude, thought(one)).cells)
231
232export const MASCOT_WORKING_FRAMES = frames(WORKING_POSES)
233
234export const MASCOT_IDLE_FRAMES = frames(IDLE_POSES)
235
236export const MASCOT_SIZE = { columns: CANVAS.width / 2, rows: CANVAS.height / 2 }
237
238types/index.d.ts 38 lines1export type Reversibility = 'easy' | 'moderate' | 'hard'
2
3export type Verdict = 'pending' | 'approved' | 'rejected'
4
5export type DecisionTab = 'all' | Verdict
6
7// The most recent verdict change, which the pane offers to undo.
8export type VerdictChange = { id: number; from: Verdict; to: Verdict }
9
10export type Decision = {
11 id: number
12 choice: string
13 why: string
14 alternatives: string
15 impact: string
16 reversibility: Reversibility
17 isFromSubagent: boolean
18 verdict: Verdict
19 comment: string
20 // Rejected, or approved with a comment, while Claude was idle: it has the feedback but no
21 // turn to act on it yet.
22 isAwaitingRevisit: boolean
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'decision-log': {
28 decisions: Decision[]
29 isWorking: boolean
30 selectedId: number | null
31 tab: DecisionTab
32 // The decision whose comment box is open; its hotkeys are off meanwhile.
33 commentingId: number | null
34 lastChange: VerdictChange | null
35 }
36 }
37}
38