SLOPSHOPPER

decision-log

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

newpaneguardcommandtoaststatus
v0.2.1MITupdated 2026-10-12HectorBailey/claude-mods/decision-log
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · decision-log
│ ┃ Decisions ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Decision log │ ┃ ⏺ 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 │ ┃ │ ┃ › /decisions │ ┃ ⎿ decision-log: Decision log opened. │ ┃ ▐▛███▜▌ │ ┃ ▝▜█████▛▘ │ ┃ ▘▘ ▝▝ │ ┃ idle │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Decisions
◆ Decision log 0 logged ▐▛███▜▌ ▝▜█████▛▘ ▘▘ ▝▝ idle
README

decision-log

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.

What it does

  • Gives Claude a 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.
  • Each decision lands in a Decisions pane: filter tabs with counts (All, To review, Approved, Rejected; it opens on To review), a one-line-per-decision table, and a detail panel for the selected row with what was chosen, why, what else was considered and the ramifications. Rows and the panel border carry a ●○○ 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.
  • Approve or reject each one, with an optional comment. Claude does not wait for you; it keeps working and you review at your own pace.
  • A rejection, or an approval with a comment, is added to the conversation straight away. Mid-turn, Claude reads it at its next step and revisits the decision. If Claude is idle, the pane offers Ask Claude to revisit now, or it picks the feedback up with your next message.
  • A plain approval sends nothing, so it costs no tokens.
  • Hard-to-undo decisions also raise a toast, and the status line shows how many are waiting.

Using it

  • The pane opens by itself on the first decision when the terminal is wide enough (fullscreen, 144+ columns). Otherwise run /decisions, which also gives the pane the keyboard.
  • ctrl+x tab (or a click) gives the pane the keyboard. Then:
KeyDoes
n / pnext / previous decision
arrowswalk the tabs, rows and buttons; landing on a row shows it
aapprove the selected decision
rreject it
copen a comment box; Enter keeps the comment
uundo your most recent verdict (an accidental approve or reject)
oreopen the selected reviewed decision, back to To review
Escback to the prompt

The hotkeys switch off while the comment box is open, so typing never presses a button.

  • After each verdict the selection moves to the next decision still waiting.
  • To change your mind later, click a decision in the All, Approved or Rejected tab (or the Recent list) and use Reopen, Approve instead or Reject instead. If Claude was already told about the old verdict (a rejection, or an approval with a comment), it is told about the change; undoing a plain approval tells it nothing.
  • /decisions clear (or clear reviewed) drops reviewed decisions from the list.
  • Closing the pane stops it reopening on new decisions until you run /decisions again.

Decisions last for the session only.

Install

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.

Developing

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

Credits

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.

Source 3 files
hooks/register.tsx 915 lines
1import { 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
915
hooks/graphics.ts 238 lines
1// 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
238
types/index.d.ts 38 lines
1export 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