Tracks every question, decision and review item an agent puts to you, in a pane, until you answer or dismiss it

A Claude Code mod that keeps every question an agent puts to you in a side pane, until you answer or dismiss it. You answer in the pane, one question at a time or several at once; you ask about a question without that counting as your answer; and nothing gets lost when the conversation scrolls on.
At the prompt of a Claude Code session in a terminal:
/plugin install question-pane --marketplace Skorch/question-pane
Answer y to add the marketplace, then pick the user scope. From a shell:
claude plugin marketplace add Skorch/question-pane
claude plugin install question-pane@question-pane
After each reply of the main agent, it reads the items the reply puts to you, in these formats:
❓ Q1 - title: body, then ➡️ recommended answer.❓ Questions:: - Q1: question Default: default answer. Feeds D1.### 🫵 D1: question, with Recommendation:, Stakes:, Driven by:, Blocks: and its options.🔍 Review:: D2, pending: ... or D2, taken: ....Haiku turns each reply into structured items (title, body, default, options, why it was asked, stakes, what it feeds). Every field must appear in the reply's own words, or it is refused; a pattern match fills in anything Haiku missed. A reply with none of these markers costs no call.
/questions opens it. Each question is a card, open while it needs you:
/questions rebuild): reads the last 20 replies that ask something, brings back questions the pane missed, refreshes the ones it holds, and marks the ones your later messages answered as "Looks answered".Every model call uses your session's own account (your subscription, if you are signed in with one):
Client (the terminal and the desktop app); elsewhere the answer field is one line.claude --plugin-dir ./question-pane
claude plugin validate .
claude plugin test .
Claude Code writes the API's types into .claude-plugin/types/ when it loads the mod from a folder it watches; tsconfig.json extends them, so tsc -p . type-checks the mod once they are there.
hooks/register.tsx 746 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
3
4import type { Item, PaneView } from '../types'
5import {
6 added,
7 afterFill,
8 asked,
9 cancelled,
10 canDefault,
11 committed,
12 defaultAnswer,
13 dismissed,
14 isLive,
15 lineFor,
16 looked,
17 message,
18 queue,
19 readied,
20 reopened,
21 restored,
22 sent,
23 submitted,
24 toChange,
25 undone,
26} from './answers'
27import { askPrompt } from './ask'
28import { keepFocusOn } from './focus'
29import { parseReply } from './haiku'
30import { checkLooks, LOOKS_RULES, looksPrompt } from './looks'
31import { hasMarker } from './parse'
32import type { RebuiltReply } from './rebuild'
33import { rebuilt, repliesIn } from './rebuild'
34
35const PANE = 'questions'
36const TITLE = 'Questions'
37// Sessions kept in $.store (4 MiB in all); the oldest go first.
38const KEPT_SESSIONS = 30
39const DEFAULT_VIEW: PaneView = {
40 tab: 'needs',
41 collapsed: [],
42 expanded: [],
43 preview: false,
44 asking: [],
45 foldedThreads: [],
46 rebuild: '',
47}
48// Replies a rebuild reads, newest first: one Haiku call each, and one more for
49// each that the person wrote after.
50const REBUILD_REPLIES = 20
51const items = atom({ plugin: 'question-pane', key: 'list' } as const, [])
52const view = atom({ plugin: 'question-pane', key: 'pane' } as const, DEFAULT_VIEW)
53const drafts = atom({ plugin: 'question-pane', key: 'drafts' } as const, {})
54
55// Prompts that are not the person's own words.
56const NOT_THE_PERSON: ReadonlySet<PromptOrigin['kind']> = new Set(['plugin', 'task-notification', 'scheduled-trigger'])
57
58const KIND = { question: 'Question', decision: 'Decision', review: 'Review' } as const
59
60function clock(at: number): string {
61 const d = new Date(at)
62
63 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
64}
65
66// The pane's view, with any field an older version did not save at its default.
67async function readView($: EngineInterface): Promise<PaneView> {
68 return { ...DEFAULT_VIEW, ...(await read($, view)) }
69}
70
71function toggled(list: readonly string[], id: string): string[] {
72 return list.includes(id) ? list.filter(x => x !== id) : [...list, id]
73}
74
75async function save($: EngineInterface): Promise<void> {
76 const key = `session:${await $.session.id()}`
77 await $.store.set(key, await read($, items))
78 const sessions = (await $.store.keys()).filter(k => k.startsWith('session:'))
79 for (const old of sessions.slice(0, Math.max(0, sessions.length - KEPT_SESSIONS))) {
80 await $.store.delete(old)
81 }
82}
83
84async function change($: EngineInterface, fn: (list: Item[], at: number) => Item[]): Promise<void> {
85 const at = await $.clock.now()
86 await update($, items, list => fn(list, at))
87 await save($)
88}
89
90async function haiku($: EngineInterface, system: string, prompt: string): Promise<string | undefined> {
91 const r = await $.model.complete({ model: 'haiku', system, prompt, maxTokens: 8000, timeoutMs: 60_000 })
92
93 return r.isAnswered ? r.text : undefined
94}
95
96async function openPane($: EngineInterface): Promise<void> {
97 if (!(await $.ui.panes()).some(p => p.id === PANE)) await $.ui.open({ id: PANE, title: TITLE })
98}
99
100async function answer($: EngineInterface, id: string, text: string, what: string): Promise<void> {
101 await change($, (list, at) => readied(list, new Map([[id, text]]), what, at))
102 await update($, drafts, old => Object.fromEntries(Object.entries(old).filter(([key]) => key !== id)))
103}
104
105// "Ask about this": a fork of the conversation answers, told which question it
106// is about; the exchange stays on the item, never in the conversation.
107async function askAbout($: EngineInterface, id: string, question: string): Promise<void> {
108 const item = (await read($, items)).find(i => i.id === id)
109 if (item === undefined) return
110 const now = await $.clock.now()
111 await update($, items, list => asked(list, id, { who: 'you', text: question, at: now }))
112 await update($, view, v => ({ ...v, asking: [...v.asking, id] }))
113 const r = await $.model.fork({ prompt: askPrompt(item, question, clock(item.at)) })
114 const text = r.isAnswered
115 ? r.text.trim()
116 : `No answer: ${r.reason === 'nothing-to-fork' ? 'there is no conversation to fork yet' : r.reason}.`
117 const at = await $.clock.now()
118 await update($, items, list => asked(list, id, { who: 'fork', text, at }))
119 await update($, view, v => ({ ...v, asking: v.asking.filter(x => x !== id) }))
120 await save($)
121}
122
123// A review asks to be approved, or, when the agent already acted, ratified.
124const approveWord = (i: Item) => (i.title.startsWith('taken') ? 'Ratify' : 'Approve')
125
126// The actions that are safe to run twice, by their Button's key: each checks
127// the item is still in the state the action is for, so a second run does
128// nothing. The pane's buttons run them, and so does a click that only gave
129// the pane the keyboard (D33).
130async function runSafeAction($: EngineInterface, element: string): Promise<boolean> {
131 if (element === 'rebuild') {
132 // Not awaited: the rebuild runs on, showing its progress in the pane.
133 void rebuild($)
134 return true
135 }
136 const [action, turnId, code, index] = element.split(':')
137 const id = `${turnId}:${code}`
138 const item = (await read($, items)).find(i => i.id === id)
139 if (item === undefined) return false
140 const isOpen = item.status === 'open' || item.status === 'looks'
141 switch (action) {
142 case 'default':
143 if (!canDefault(item)) return false
144 await answer($, id, defaultAnswer(item), 'Default taken in the pane')
145 return true
146 case 'option': {
147 const option = item.options[Number(index)]
148 if (!isOpen || option === undefined) return false
149 await answer($, id, option.label, 'Option picked in the pane')
150 return true
151 }
152 case 'yes': {
153 if (!isOpen) return false
154 const yes = approveWord(item)
155 await answer($, id, yes.toLowerCase(), `${yes} in the pane`)
156 return true
157 }
158 case 'reverse':
159 if (!isOpen) return false
160 await answer($, id, 'reverse', 'Reverse in the pane')
161 return true
162 case 'commit':
163 if (item.status !== 'looks') return false
164 await change($, (l, at) => committed(l, id, at))
165 return true
166 case 'cancel':
167 if (item.status !== 'looks') return false
168 await change($, (l, at) => cancelled(l, id, at))
169 return true
170 case 'dismiss':
171 if (!isLive(item)) return false
172 await change($, (l, at) => dismissed(l, id, at))
173 return true
174 default:
175 return false
176 }
177}
178
179// Whether the pane held the keyboard when last drawn; and the last click that
180// only gave it the keyboard and ran its action, whose real press, should one
181// follow, is dropped. Module variables: a reload starts them over.
182let isPaneFocused = true
183let ranOnFocus: { element: string; at: number } | undefined
184
185// Parses the agent's recent replies again and merges their items into the
186// list; then asks Haiku which new items the person's later messages answered,
187// so a question answered long ago comes back as "Looks answered", not open.
188async function rebuild($: EngineInterface): Promise<void> {
189 if ((await readView($)).rebuild !== '') return
190 const progress = (text: string) => update($, view, v => ({ ...v, rebuild: text }))
191 await progress('Reading the conversation…')
192 try {
193 const replies = repliesIn(await $.session.messages(), hasMarker, REBUILD_REPLIES)
194 $.ui.toast(
195 replies.length === 0
196 ? 'Rebuild: no reply in the conversation asks a question.'
197 : `Rebuild: reading ${replies.length} ${replies.length === 1 ? 'reply' : 'replies'} with Haiku.`,
198 )
199 const parsed: RebuiltReply[] = []
200 for (const [index, reply] of replies.entries()) {
201 await progress(`Reading reply ${index + 1} of ${replies.length} with Haiku…`)
202 const { topic, items: found } = await parseReply(reply.text, (system, prompt) => haiku($, system, prompt))
203 parsed.push({ index, topic, items: found, after: reply.after })
204 }
205 const now = await $.clock.now()
206 let counts = { added: 0, refreshed: 0 }
207 await update($, items, list => {
208 const result = rebuilt(list, parsed, now)
209 counts = { added: result.added, refreshed: result.refreshed }
210 return result.list
211 })
212 let looks = 0
213 const answeredLater = parsed.filter(r => r.after.trim() !== '')
214 for (const [n, reply] of answeredLater.entries()) {
215 const open = (await read($, items)).filter(i => i.turnId === `rebuilt-${reply.index}` && i.status === 'open')
216 if (open.length === 0) continue
217 await progress(`Checking what you wrote after reply ${reply.index + 1} (${n + 1} of ${answeredLater.length})…`)
218 const answerText = await haiku($, LOOKS_RULES, looksPrompt(open, reply.after))
219 const found = answerText === undefined ? new Map<string, string>() : checkLooks(answerText, reply.after, open)
220 looks += found.size
221 if (found.size > 0) await change($, (list, at) => looked(list, found, at))
222 }
223 await save($)
224 $.ui.toast(
225 `Rebuilt from ${replies.length} replies: ${counts.added} new, ${counts.refreshed} refreshed, ${looks} looking answered by what you wrote.`,
226 )
227 } catch (error) {
228 $.ui.toast(`Rebuild stopped: ${error instanceof Error ? error.message : String(error)}`)
229 } finally {
230 await progress('')
231 }
232}
233
234// Puts the ready answers in the prompt box for the person to send. Where the
235// session draws no box, submits them as the person's words and marks them sent.
236async function send($: EngineInterface): Promise<void> {
237 const list = await read($, items)
238 const text = message(list)
239 if (text === '') return
240 const box = await $.prompt.read()
241 const hasDraft = box.text.trim() !== ''
242 const filled = await $.prompt.fill({ text: hasDraft ? `\n\n${text}` : text, mode: hasDraft ? 'append' : 'replace' })
243 const next = afterFill(filled)
244 if (next === 'filled') {
245 await update($, view, v => ({ ...v, preview: false }))
246 $.ui.toast('Your answers are in the prompt box: press Enter to send them.')
247 } else if (next === 'submit') {
248 await $.prompt.submit({ text, asUser: true })
249 await change($, (l, at) => sent(l, new Set(queue(list).map(i => i.id)), at))
250 $.ui.toast('Your answers were sent.')
251 } else {
252 $.ui.toast(
253 next === 'dialog'
254 ? 'Close the open dialog, then press Send again.'
255 : 'The prompt box did not take your answers; they are still ready.',
256 )
257 }
258}
259
260export const register: Register = on => {
261 on('session.start', async ($, e, next) => {
262 await $.command.register({
263 name: 'questions',
264 description: 'Show the questions you have not answered yet',
265 argumentHint: '[rebuild]',
266 })
267 // A hot reload keeps $.state; a resumed session, or a list saved under an
268 // older shape, is taken from $.store.
269 if ((await read($, items)).length === 0) {
270 const saved = restored(await $.store.get(`session:${await $.session.id()}`))
271 if (saved !== undefined && saved.length > 0) await update($, items, () => saved)
272 }
273 if ((await read($, items)).some(isLive)) await openPane($)
274
275 return next(e)
276 })
277
278 on('session.end', async ($, e, next) => {
279 // /clear: the conversation ends and the process goes on under a new id;
280 // the old list stays saved under the old one.
281 if (e.reason === 'clear') {
282 await update($, items, () => [])
283 await update($, view, () => DEFAULT_VIEW)
284 await update($, drafts, () => ({}))
285 }
286
287 return next(e)
288 })
289
290 on('turn.complete', async ($, e, next) => {
291 const result = await next(e)
292 if (e.agentId !== undefined || e.reason !== 'answer' || !hasMarker(e.answer)) return result
293 const parsed = await parseReply(e.answer, (system, prompt) => haiku($, system, prompt))
294 if (parsed.items.length === 0) return result
295 await change($, (list, at) => added(list, parsed.items, e.turnId, parsed.topic, at))
296 await openPane($)
297
298 return result
299 })
300
301 // The person's prompt enters first; then the list learns from it: a ready
302 // answer sent unchanged is sent, a line led by a code looks like an answer,
303 // and Haiku is asked which other open questions it answers.
304 on('prompt.submit', async ($, e, next) => {
305 const result = await next(e)
306 if (NOT_THE_PERSON.has(e.origin.kind) || e.text.trim() === '') return result
307 await change($, (list, at) => submitted(list, e.text, at))
308 const open = (await read($, items)).filter(i => i.status === 'open')
309 if (open.length === 0) return result
310 const answerText = await haiku($, LOOKS_RULES, looksPrompt(open, e.text))
311 if (answerText === undefined) return result
312 const found = checkLooks(answerText, e.text, open)
313 if (found.size > 0) await change($, (list, at) => looked(list, found, at))
314
315 return result
316 }).catch(($, e, next) => next(e))
317
318 // The editor posts each change, kept as the item's draft, and Ctrl+Enter.
319 on('ui.message', async ($, e, next) => {
320 if (!e.element.startsWith('editor:')) return next(e)
321 const id = e.element.slice('editor:'.length)
322 const data = e.data
323 if (typeof data !== 'object' || data === null || !('kind' in data) || !('text' in data)) return {}
324 const text = typeof data.text === 'string' ? data.text : ''
325 if (data.kind === 'submit') {
326 if (text.trim() !== '') await answer($, id, text, 'Answered in the pane')
327 } else {
328 await update($, drafts, old => ({ ...old, [id]: text }))
329 }
330
331 return {}
332 })
333
334 // A click on a pane that does not hold the keyboard only gives it the
335 // keyboard: the desktop raises this focus move, naming the clicked element,
336 // and no press (seen live, 2026-10-15). So when the pane was not focused and
337 // the person's click lands on an action safe to run twice, run it here (D33).
338 on('ui.focus', async ($, e, next) => {
339 const wasFocused = isPaneFocused
340 const result = await next(e)
341 if (e.requestId !== PANE || e.origin.kind !== 'person' || e.element === undefined || wasFocused) return result
342 const element = e.element
343 const keep = keepFocusOn(element, (await readView($)).tab)
344 if (keep !== undefined) await $.ui.focus({ requestId: PANE, key: keep }).catch(() => undefined)
345 if (await runSafeAction($, element)) ranOnFocus = { element, at: await $.clock.now() }
346 return result
347 })
348
349 // Before a press runs, the focus moves to something that outlasts it, so the
350 // pane keeps the keyboard and the next click presses (hooks/focus.ts). A
351 // press whose action already ran on the click that focused the pane is
352 // dropped, so it does not run twice.
353 on('ui.press', async ($, e, next) => {
354 const ran = ranOnFocus
355 ranOnFocus = undefined
356 if (ran !== undefined && ran.element === e.element && (await $.clock.now()) - ran.at < 2000) {
357 return { element: e.element }
358 }
359 const keep = keepFocusOn(e.element, (await readView($)).tab)
360 if (keep !== undefined) await $.ui.focus({ requestId: e.requestId, key: keep }).catch(() => undefined)
361
362 return next(e)
363 })
364
365 on('command.run', { command: 'questions' }, async ($, e) => {
366 await $.ui.open({ id: PANE, title: TITLE })
367 if (e.args.trim() === 'rebuild') {
368 await rebuild($)
369 return { text: 'Questions pane rebuilt from the conversation.' }
370 }
371 const live = (await read($, items)).filter(isLive).length
372
373 return { text: `Questions pane opened: ${live} need you.` }
374 })
375
376 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
377 const table = $.ui.resolve(e)
378 const { Box, Text, Button, Code } = table
379 // The terminal and the desktop draw a Client, the multi-line editor, and
380 // an Input; elsewhere an answer is an option or a default.
381 const Client = 'Client' in table ? table.Client : undefined
382 const Input = 'Input' in table ? table.Input : undefined
383 const all = await read($, items)
384 const v = await readView($)
385 isPaneFocused = e.props.isFocused
386 const needs = v.tab === 'needs'
387 const shown = all.filter(i => (needs ? isLive(i) : !isLive(i)))
388 const ready = queue(all)
389 const setView = (fn: (old: PaneView) => PaneView) => update($, view, fn)
390 // A card that needs you is open unless folded; a ready or answered card is
391 // folded unless opened.
392 const foldsByDefault = (i: Item) => !needs || i.status === 'ready'
393 const isOpen = (i: Item) => (foldsByDefault(i) ? v.expanded.includes(i.id) : !v.collapsed.includes(i.id))
394 const toggle = (i: Item) =>
395 setView(old =>
396 foldsByDefault(i)
397 ? { ...old, expanded: toggled(old.expanded, i.id) }
398 : { ...old, collapsed: toggled(old.collapsed, i.id) },
399 )
400
401 const pill = (i: Item): [string, string] => {
402 if (i.status === 'answered') return [i.how === 'committed' ? 'Committed' : 'Sent', 'success']
403 if (i.status === 'ready') return ['Ready', 'suggestion']
404 if (i.status === 'looks') return ['Looks answered', 'warning']
405 if (i.status === 'dismissed') return ['Dismissed', 'inactive']
406 return ['Open', 'subtle']
407 }
408 const labelled = (label: string, value: string, color?: string) =>
409 value === '' ? null : (
410 <Box flexDirection="row" gap={1}>
411 <Text dimColor={color === undefined} color={color} bold={color !== undefined}>
412 {label}
413 </Text>
414 <Text>{value}</Text>
415 </Box>
416 )
417
418 // Row 1 of every card: one Button of Texts, so pressing anywhere on it folds.
419 const titleRow = (i: Item) => {
420 const [word, color] = pill(i)
421 return (
422 <Button plain key={`card:${i.id}`} onPress={() => toggle(i)}>
423 <Text inverse mono>{` ${i.code} `}</Text> <Text color={color} bold>
424 {word}
425 </Text>{' '}
426 <Text bold>{i.title.replace(/`/g, '')}</Text> <Text dimColor>{`${KIND[i.kind]} ${isOpen(i) ? '▾' : '▸'}`}</Text>
427 </Button>
428 )
429 }
430
431 const contextLines = (i: Item) => (
432 <Box flexDirection="column">
433 {labelled('Why:', i.context)}
434 {labelled('Stakes:', i.stakes)}
435 {labelled('Driven by:', i.drivenBy)}
436 {labelled('Blocks:', i.blocks)}
437 {i.feeds.length > 0 && (
438 <Box flexDirection="row" gap={1}>
439 <Text dimColor>Feeds:</Text>
440 {i.feeds.map(code => {
441 const target = all.filter(x => x.code === code).sort((a, b) => b.at - a.at)[0]
442 return (
443 <Button
444 plain
445 key={`feeds:${i.id}:${code}`}
446 // Scrolling is a convenience: a refusal leaves the pane where it is.
447 onPress={() =>
448 target === undefined
449 ? undefined
450 : $.ui.scroll({ in: PANE, to: { key: `box:${target.id}` }, block: 'start' }).catch(() => undefined)
451 }
452 >
453 <Text inverse mono>{` ${code} `}</Text>
454 </Button>
455 )
456 })}
457 </Box>
458 )}
459 {i.status !== 'ready' &&
460 i.status !== 'looks' &&
461 labelled(i.kind === 'decision' ? 'Recommended:' : 'Default:', i.recommendation)}
462 </Box>
463 )
464
465 const editor = (i: Item, label: string) =>
466 Client !== undefined ? (
467 <Box flexDirection="column">
468 <Text dimColor>{label}</Text>
469 <Client key={`editor:${i.id}`} module="./editor.tsx" props={{ placeholder: 'Click here, then write your answer' }} />
470 </Box>
471 ) : Input !== undefined ? (
472 <Input
473 key={`answer:${i.id}`}
474 label={label}
475 placeholder="one line; Enter adds it"
476 submitLabel="Add"
477 onSubmit={text => answer($, i.id, text, 'Answered in the pane')}
478 />
479 ) : null
480
481 const addDraft = (i: Item) => async () => {
482 const draft = (await read($, drafts))[i.id]?.trim() ?? ''
483 if (draft === '') {
484 $.ui.toast('Write the answer first.')
485 return
486 }
487 await answer($, i.id, draft, 'Answered in the pane')
488 }
489 const addButton = (i: Item) => (Client === undefined ? null : <Button key={`add:${i.id}`} label="Add" onPress={addDraft(i)} />)
490 const dismissButton = (i: Item) => (
491 <Button plain dimColor key={`dismiss:${i.id}`} label="Dismiss" onPress={() => runSafeAction($, `dismiss:${i.id}`)} />
492 )
493
494 const answerBlock = (i: Item) => {
495 if (i.status === 'ready') {
496 return (
497 <Box flexDirection="column">
498 {labelled('Your answer:', i.answer, 'suggestion')}
499 <Box flexDirection="row" gap={1}>
500 <Button key={`change:${i.id}`} label="Change" onPress={() => change($, (l, at) => toChange(l, i.id, at))} />
501 <Button plain dimColor key={`undo:${i.id}`} label="Undo" onPress={() => change($, (l, at) => undone(l, i.id, at))} />
502 </Box>
503 </Box>
504 )
505 }
506 if (i.status === 'looks') {
507 return (
508 <Box flexDirection="column">
509 <Box flexDirection="row" gap={1}>
510 <Text color="warning" bold>
511 Your message:
512 </Text>
513 <Text italic>{`“${i.found}”`}</Text>
514 </Box>
515 <Box flexDirection="row" gap={1}>
516 <Button variant="primary" key={`commit:${i.id}`} label="Commit" onPress={() => runSafeAction($, `commit:${i.id}`)} />
517 <Button key={`cancel:${i.id}`} label="Cancel" onPress={() => runSafeAction($, `cancel:${i.id}`)} />
518 </Box>
519 </Box>
520 )
521 }
522 if (i.kind === 'decision') {
523 return (
524 <Box flexDirection="column" gap={1}>
525 {i.options.map((o, n) => (
526 <Button
527 key={`option:${i.id}:${n}`}
528 variant={o.verdict === 'Recommended' ? 'primary' : undefined}
529 onPress={() => runSafeAction($, `option:${i.id}:${n}`)}
530 >
531 <Text bold>{o.label}</Text> <Text dimColor>{`${o.verdict}: ${o.reason}`}</Text>
532 </Button>
533 ))}
534 {editor(i, 'Or write your own:')}
535 <Box flexDirection="row" gap={1}>
536 {addButton(i)}
537 {dismissButton(i)}
538 </Box>
539 </Box>
540 )
541 }
542 if (i.kind === 'review') {
543 const yes = approveWord(i)
544 return (
545 <Box flexDirection="column" gap={1}>
546 <Box flexDirection="row" gap={1}>
547 <Button variant="primary" key={`yes:${i.id}`} label={yes} onPress={() => runSafeAction($, `yes:${i.id}`)} />
548 <Button key={`reverse:${i.id}`} label="Reverse" onPress={() => runSafeAction($, `reverse:${i.id}`)} />
549 </Box>
550 {editor(i, 'Or reply:')}
551 <Box flexDirection="row" gap={1}>
552 {addButton(i)}
553 {dismissButton(i)}
554 </Box>
555 </Box>
556 )
557 }
558 return (
559 <Box flexDirection="column" gap={1}>
560 {editor(i, 'Your answer:')}
561 <Box flexDirection="row" gap={1}>
562 {i.recommendation !== '' && (
563 <Button
564 variant="primary"
565 key={`default:${i.id}`}
566 label="Use default"
567 onPress={() => runSafeAction($, `default:${i.id}`)}
568 />
569 )}
570 {addButton(i)}
571 {dismissButton(i)}
572 </Box>
573 </Box>
574 )
575 }
576
577 const thread = (i: Item) =>
578 i.thread.length === 0 && !v.asking.includes(i.id) ? null : (
579 <Box flexDirection="column" marginLeft={1}>
580 {i.thread.map(t => (
581 <Box flexDirection="row" gap={1}>
582 <Text bold color={t.who === 'fork' ? 'claude' : undefined}>
583 {t.who === 'fork' ? 'Fork:' : 'You:'}
584 </Text>
585 <Text>{t.text}</Text>
586 </Box>
587 ))}
588 {v.asking.includes(i.id) && (
589 <Text dimColor italic>
590 Fork: thinking…
591 </Text>
592 )}
593 </Box>
594 )
595
596 // The Ask thread folds to its count; the field is drawn afresh after each
597 // question (its key carries the thread's length), so it starts empty.
598 const askBox = (i: Item) => {
599 const isFolded = v.foldedThreads.includes(i.id)
600 const asks = i.thread.filter(t => t.who === 'you').length
601 return (
602 <Box flexDirection="column">
603 <Button plain key={`thread:${i.id}`} onPress={() => setView(old => ({ ...old, foldedThreads: toggled(old.foldedThreads, i.id) }))}>
604 <Text dimColor bold>
605 {asks === 0 ? 'Ask about this' : `Ask about this · ${asks} asked ${isFolded ? '▸' : '▾'}`}
606 </Text>
607 </Button>
608 {!isFolded && thread(i)}
609 {Input !== undefined && (
610 <Input
611 key={`ask:${i.id}:${i.thread.length}`}
612 placeholder="what does this mean? why that default?"
613 submitLabel="Ask"
614 onSubmit={question => (v.asking.includes(i.id) ? undefined : askAbout($, i.id, question))}
615 />
616 )}
617 <Text dimColor>Answered by a fork of this conversation. Nothing here reaches the main chat.</Text>
618 </Box>
619 )
620 }
621
622 const history = (i: Item) => (
623 <Box flexDirection="column">
624 <Text dimColor bold>
625 History
626 </Text>
627 {i.history.map(entry => (
628 <Box flexDirection="row" gap={2}>
629 <Text dimColor>{clock(entry.at)}</Text>
630 <Text>{entry.what}</Text>
631 </Box>
632 ))}
633 </Box>
634 )
635
636 const answered = (i: Item) => (
637 <Box flexDirection="column" gap={1}>
638 {i.status === 'answered' ? labelled('Answer:', i.answer, 'success') : <Text dimColor>Dismissed without an answer</Text>}
639 {i.options.map(o => (
640 <Text dimColor={o.label !== i.answer}>{`${o.label === i.answer ? '✓' : ' '} ${o.label} · ${o.verdict}`}</Text>
641 ))}
642 {i.thread.length > 0 && (
643 <Box flexDirection="column">
644 <Text dimColor bold>
645 Asked about it
646 </Text>
647 {thread(i)}
648 </Box>
649 )}
650 {history(i)}
651 <Box flexDirection="row">
652 <Button key={`reopen:${i.id}`} label="Reopen" onPress={() => change($, (l, at) => reopened(l, i.id, at))} />
653 </Box>
654 </Box>
655 )
656
657 const card = (i: Item) => (
658 <Box key={`box:${i.id}`} flexDirection="column" borderStyle="round" paddingX={1}>
659 {titleRow(i)}
660 {!isOpen(i) && (i.status === 'ready' || !needs) && (
661 <Box marginLeft={2}>
662 <Text dimColor wrap="truncate-end">
663 {i.status === 'dismissed' ? 'Dismissed without an answer' : `Your answer: ${i.answer}`}
664 </Text>
665 </Box>
666 )}
667 {isOpen(i) && (
668 <Box flexDirection="column" marginLeft={2} gap={1}>
669 {contextLines(i)}
670 {needs ? answerBlock(i) : answered(i)}
671 {needs && i.status !== 'ready' && askBox(i)}
672 </Box>
673 )}
674 </Box>
675 )
676
677 // One list in the order the questions were asked: oldest turn first, a
678 // turn's items in the reply's order (the sort is stable).
679 const ordered = [...shown].sort((a, b) => a.at - b.at)
680 const defaults = needs ? shown.filter(canDefault) : []
681
682 const tabButton = (tab: PaneView['tab'], label: string) => (
683 <Button plain key={`tab:${tab}`} onPress={() => setView(old => ({ ...old, tab }))}>
684 <Text bold={v.tab === tab} underline={v.tab === tab} dimColor={v.tab !== tab}>
685 {label}
686 </Text>
687 </Button>
688 )
689
690 return (
691 <Box flexDirection="column" gap={1}>
692 <Box flexDirection="row" gap={2} flexWrap="wrap">
693 {tabButton('needs', `Needs you · ${all.filter(isLive).length}`)}
694 {tabButton('answered', `Answered · ${all.filter(i => !isLive(i)).length}`)}
695 <Button
696 key="rebuild"
697 dimColor={v.rebuild !== ''}
698 label={v.rebuild === '' ? 'Rebuild from the conversation' : 'Rebuilding…'}
699 onPress={() => runSafeAction($, 'rebuild')}
700 />
701 </Box>
702 {v.rebuild !== '' && (
703 <Text color="suggestion" bold>{`⟳ ${v.rebuild}`}</Text>
704 )}
705 {needs && (
706 <Box flexDirection="column">
707 <Box flexDirection="row" gap={1} flexWrap="wrap">
708 <Text dimColor>{`${ready.length} ready to send`}</Text>
709 {defaults.length > 0 && (
710 <Button
711 key="defaults"
712 label={`Use all defaults · ${defaults.length}`}
713 onPress={() =>
714 change($, (l, at) => readied(l, new Map(defaults.map(d => [d.id, defaultAnswer(d)])), 'Default taken in the pane', at))
715 }
716 />
717 )}
718 <Button
719 plain
720 dimColor
721 key="preview"
722 label={v.preview ? 'Hide preview' : 'Preview'}
723 onPress={() => setView(old => ({ ...old, preview: !old.preview }))}
724 />
725 <Button
726 variant="primary"
727 dimColor={ready.length === 0}
728 key="send"
729 label={`Send ${ready.length}`}
730 onPress={() => (ready.length === 0 ? $.ui.toast('Nothing is ready. Answer a question first.') : send($))}
731 />
732 </Box>
733 {v.preview && ready.length > 0 && <Code source={ready.map(lineFor).join('\n')} />}
734 </Box>
735 )}
736 {shown.length === 0 && (
737 <Text dimColor>
738 {needs ? 'Nothing needs you. New questions appear here when an agent asks them.' : 'Nothing answered yet.'}
739 </Text>
740 )}
741 {ordered.map(card)}
742 </Box>
743 )
744 })
745}
746hooks/answers.ts 251 lines1// The list's changes, as plain functions over it: answering in the pane, the
2// message Send puts in the prompt box, what a submitted prompt says about the
3// list, and every change written into the item's history.
4
5import type { HistoryEntry, Item, ItemOption, ItemStatus, Parsed, ThreadEntry } from '../types'
6import { answeredCodes } from './parse'
7
8export const isLive = (i: Item) => i.status === 'open' || i.status === 'ready' || i.status === 'looks'
9
10// Only an item with a default or recommendation can take it.
11export const canDefault = (i: Item) => i.recommendation !== '' && (i.status === 'open' || i.status === 'looks')
12
13export function defaultAnswer(i: Item): string {
14 return `${i.kind === 'decision' ? 'your recommendation' : 'your default'}, ${i.recommendation}`
15}
16
17// The message's text for one item: its code and title, so the agent cannot
18// confuse two items that share a code, then the answer, which may run to
19// several lines.
20export function lineFor(i: Item): string {
21 return `${i.code} (${i.title.replace(/`/g, '')}): ${i.answer}`
22}
23
24function changed(i: Item, change: Partial<Item>, what: string, at: number): Item {
25 return { ...i, ...change, history: [...i.history, { at, what }] }
26}
27
28function each(list: readonly Item[], ids: ReadonlySet<string>, fn: (i: Item) => Item): Item[] {
29 return list.map(i => (ids.has(i.id) ? fn(i) : i))
30}
31
32export function added(
33 list: readonly Item[],
34 parsed: readonly Parsed[],
35 turnId: string,
36 topic: string,
37 at: number,
38): Item[] {
39 const known = new Set(list.map(i => i.id))
40 const fresh: Item[] = parsed
41 .map(p => ({
42 ...p,
43 id: `${turnId}:${p.code}`,
44 turnId,
45 topic,
46 at,
47 status: 'open' as const,
48 answer: '',
49 how: '' as const,
50 found: '',
51 thread: [],
52 history: [{ at, what: 'Asked' }],
53 }))
54 .filter(i => !known.has(i.id))
55
56 return [...list, ...fresh]
57}
58
59// Answered in the pane: ready to send.
60export function readied(list: readonly Item[], answers: ReadonlyMap<string, string>, what: string, at: number): Item[] {
61 return list.map(i => {
62 const answer = answers.get(i.id)?.trim()
63
64 return answer === undefined || answer === '' ? i : changed(i, { status: 'ready', answer, found: '' }, what, at)
65 })
66}
67
68export function undone(list: readonly Item[], id: string, at: number): Item[] {
69 return each(list, new Set([id]), i => changed(i, { status: 'open', answer: '' }, 'Answer undone', at))
70}
71
72export function toChange(list: readonly Item[], id: string, at: number): Item[] {
73 return each(list, new Set([id]), i => changed(i, { status: 'open' }, 'Reopened to change the answer', at))
74}
75
76export function committed(list: readonly Item[], id: string, at: number): Item[] {
77 return each(list, new Set([id]), i =>
78 changed(i, { status: 'answered', how: 'committed', answer: i.found }, 'Committed', at),
79 )
80}
81
82export function cancelled(list: readonly Item[], id: string, at: number): Item[] {
83 return each(list, new Set([id]), i => changed(i, { status: 'open', found: '' }, 'Not an answer: back to open', at))
84}
85
86export function dismissed(list: readonly Item[], id: string, at: number): Item[] {
87 return each(list, new Set([id]), i => changed(i, { status: 'dismissed' }, 'Dismissed', at))
88}
89
90export function reopened(list: readonly Item[], id: string, at: number): Item[] {
91 return each(list, new Set([id]), i => changed(i, { status: 'open', answer: '', how: '', found: '' }, 'Reopened', at))
92}
93
94export function sent(list: readonly Item[], ids: ReadonlySet<string>, at: number): Item[] {
95 return each(list, ids, i => changed(i, { status: 'answered', how: 'sent' }, 'Sent', at))
96}
97
98// Things the person wrote that look like answers: commit or cancel each.
99export function looked(list: readonly Item[], found: ReadonlyMap<string, string>, at: number): Item[] {
100 return list.map(i => {
101 const quote = found.get(i.id)
102
103 return quote === undefined || (i.status !== 'open' && i.status !== 'ready')
104 ? i
105 : changed(i, { status: 'looks', found: quote }, 'Your message looks like an answer', at)
106 })
107}
108
109export function asked(list: readonly Item[], id: string, entry: ThreadEntry): Item[] {
110 return each(list, new Set([id]), i => ({
111 ...i,
112 thread: [...i.thread, entry],
113 history: entry.who === 'you' ? [...i.history, { at: entry.at, what: 'Asked about it in the pane' }] : i.history,
114 }))
115}
116
117// The ready items in the order they were asked: oldest turn first, a turn's
118// items in the reply's order.
119export function queue(list: readonly Item[]): Item[] {
120 return list.filter(i => i.status === 'ready').sort((a, b) => a.at - b.at)
121}
122
123export function message(list: readonly Item[]): string {
124 return queue(list).map(lineFor).join('\n')
125}
126
127// The newest item under a code with one of the statuses.
128export function newest(list: readonly Item[], code: string, statuses: readonly ItemStatus[]): Item | undefined {
129 return list
130 .filter(i => i.code === code && statuses.includes(i.status))
131 .reduce<Item | undefined>((best, i) => (best === undefined || i.at >= best.at ? i : best), undefined)
132}
133
134// What a prompt the person submitted says, without a model: a ready item
135// whose text it holds unchanged is sent; a line that starts with a code
136// looks like an answer to the newest open or ready item under it.
137export function submitted(list: readonly Item[], text: string, at: number): Item[] {
138 const wasSent = new Set(list.filter(i => i.status === 'ready' && text.includes(lineFor(i))).map(i => i.id))
139 const after = sent(list, wasSent, at)
140 const found = new Map<string, string>()
141 for (const line of text.split('\n')) {
142 const [code] = answeredCodes(line)
143 if (code === undefined) continue
144 const hit = newest(after, code, ['open', 'ready'])
145 if (hit !== undefined && !found.has(hit.id)) found.set(hit.id, line.trim())
146 }
147
148 return looked(after, found, at)
149}
150
151const STATUSES: readonly ItemStatus[] = ['open', 'ready', 'looks', 'answered', 'dismissed']
152
153function isOption(o: unknown): o is ItemOption {
154 return (
155 typeof o === 'object' &&
156 o !== null &&
157 'label' in o &&
158 typeof o.label === 'string' &&
159 'verdict' in o &&
160 typeof o.verdict === 'string' &&
161 'reason' in o &&
162 typeof o.reason === 'string'
163 )
164}
165
166function isHistoryEntry(h: unknown): h is HistoryEntry {
167 return typeof h === 'object' && h !== null && 'at' in h && typeof h.at === 'number' && 'what' in h && typeof h.what === 'string'
168}
169
170function isThreadEntry(t: unknown): t is ThreadEntry {
171 return (
172 typeof t === 'object' &&
173 t !== null &&
174 'who' in t &&
175 (t.who === 'you' || t.who === 'fork') &&
176 'text' in t &&
177 typeof t.text === 'string' &&
178 'at' in t &&
179 typeof t.at === 'number'
180 )
181}
182
183const str = (v: unknown) => (typeof v === 'string' ? v : '')
184
185// A list this mod saved, read back: undefined when it is not one. A field an
186// older version did not save is filled with its empty value; an older
187// version's statuses are read as today's (queued is ready, maybe is looks).
188export function restored(value: unknown): Item[] | undefined {
189 if (!Array.isArray(value)) return undefined
190 const rows: unknown[] = value
191 const out: Item[] = []
192 for (const raw of rows) {
193 if (typeof raw !== 'object' || raw === null) return undefined
194 const i: Record<string, unknown> = { ...raw }
195 const legacy = i.status === 'queued' ? 'ready' : i.status === 'maybe' ? 'looks' : i.status
196 const status = STATUSES.find(s => s === legacy)
197 if (
198 typeof i.id !== 'string' ||
199 typeof i.code !== 'string' ||
200 typeof i.title !== 'string' ||
201 typeof i.turnId !== 'string' ||
202 typeof i.at !== 'number' ||
203 status === undefined ||
204 (i.kind !== 'question' && i.kind !== 'decision' && i.kind !== 'review')
205 ) {
206 return undefined
207 }
208 const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
209 out.push({
210 id: i.id,
211 code: i.code,
212 kind: i.kind,
213 title: i.title,
214 body: str(i.body),
215 recommendation: str(i.recommendation),
216 options: list(i.options).filter(isOption),
217 context: str(i.context),
218 stakes: str(i.stakes),
219 drivenBy: str(i.drivenBy),
220 blocks: str(i.blocks),
221 feeds: list(i.feeds).filter((f): f is string => typeof f === 'string'),
222 source: str(i.source),
223 parsedBy: i.parsedBy === 'haiku' ? 'haiku' : 'regex',
224 turnId: i.turnId,
225 topic: str(i.topic),
226 at: i.at,
227 status,
228 answer: str(i.answer),
229 how: i.how === 'sent' || i.how === 'committed' ? i.how : '',
230 found: str(i.found),
231 thread: list(i.thread).filter(isThreadEntry),
232 history: list(i.history).filter(isHistoryEntry),
233 })
234 }
235
236 return out
237}
238
239export type AfterFill = 'filled' | 'submit' | 'dialog' | 'kept'
240
241// What Send does after the prompt box answered: done when the box took the
242// text; submit as the person's words when the engine said the session has
243// no box; otherwise keep the answers ready. Only the engine's own refusal
244// names a cause: a hook's carries none, and an absent cause is unknown.
245export function afterFill(filled: { isFilled: boolean; refusal?: 'no_composer' | 'dialog' }): AfterFill {
246 if (filled.isFilled) return 'filled'
247 if (filled.refusal === 'no_composer') return 'submit'
248
249 return filled.refusal === 'dialog' ? 'dialog' : 'kept'
250}
251hooks/ask.ts 28 lines1// What "Ask about this" sends to a fork of the conversation. A fork always
2// starts from the conversation as it is now (ModelForkRequest takes only the
3// prompt), so the prompt names which question, which round, and when it was
4// asked; the fork sees what was said since then too.
5
6import type { Item } from '../types'
7
8export function askPrompt(i: Item, question: string, asked: string): string {
9 const lines = [
10 'The person is asking you about one of the questions you put to them. Explain; do not answer the question for them, and do not start any work.',
11 'Answer in plain words, in at most a few short paragraphs. This answer stays in their question pane: it is not part of the conversation and is not their answer.',
12 '',
13 `The question: ${i.code} (${i.kind}), asked at ${asked}${i.topic === '' ? '' : `, in the round about: ${i.topic}`}`,
14 `"${i.title}"`,
15 ]
16 if (i.context !== '') lines.push(`Why it was asked: ${i.context}`)
17 if (i.recommendation !== '') lines.push(`Your ${i.kind === 'decision' ? 'recommendation' : 'default'}: ${i.recommendation}`)
18 for (const o of i.options) lines.push(`Option ${o.label}: ${o.verdict}: ${o.reason}`)
19 const earlier = i.thread.filter(t => t.text !== '')
20 if (earlier.length > 0) {
21 lines.push('', 'What they asked about it before, and your answers:')
22 for (const t of earlier) lines.push(`${t.who === 'you' ? 'They asked' : 'You answered'}: ${t.text}`)
23 }
24 lines.push('', `Their question now: ${question}`)
25
26 return lines.join('\n')
27}
28hooks/focus.ts 36 lines1// Where the pane's keyboard focus goes before a press runs. On the desktop,
2// a press that removes the pressed button (Use default folds the card; Commit
3// and Dismiss move it off the tab) takes the pane's focus with it, and the
4// next click is then spent giving the pane focus back, so it presses nothing
5// (seen live, 2026-10-15: a `focused=false` drawing followed each such press,
6// and the next click raised `ui.focus` but no `ui.press`). Moving the focus
7// first to something that stays keeps the pane focused.
8
9// The key to move the focus to before pressing `element`, or undefined to
10// leave it: the card's title row when the card stays on the tab, the tab's own
11// button when the card leaves it.
12export function keepFocusOn(element: string, tab: 'needs' | 'answered'): string | undefined {
13 const [action, turnId, code] = element.split(':')
14 if (action === undefined || turnId === undefined || code === undefined) return undefined
15 const card = `card:${turnId}:${code}`
16 switch (action) {
17 // The card stays on Needs you, folded or reopened.
18 case 'default':
19 case 'option':
20 case 'add':
21 case 'yes':
22 case 'reverse':
23 case 'change':
24 case 'undo':
25 case 'cancel':
26 return card
27 // The card leaves the tab it is on.
28 case 'commit':
29 case 'dismiss':
30 case 'reopen':
31 return `tab:${tab}`
32 default:
33 return undefined
34 }
35}
36hooks/haiku.ts 173 lines1// Haiku turns a reply into items; this file asks it and checks what it says.
2// A completion has no structured-output mode (ModelCompleteRequest takes
3// model, prompt, system, maxTokens, effort, timeoutMs), so the checks here
4// are what hold Haiku to the reply: an item's text must be in the reply, in
5// that item's own block where the regex found one, and a field that is not
6// falls back to the regex's.
7
8import type { ItemKind, ItemOption, Parsed } from '../types'
9import { norm, parseRegex, stripFences, topicOf } from './parse'
10
11export const RULES = `You turn one message an AI agent wrote to a person into JSON: the items it puts to that person, and what the message is about.
12
13An item is one of:
14- "question": a numbered question, Q1, Q2, ...: either a line "❓ **Q1** - **title**: body" followed by "➡️ recommended answer", or a line "- Q1: question Default: default answer" under "❓ **Questions:**".
15- "decision": a heading "🫵 D1: question" with "**Recommendation:**", "**Stakes:**", "**Driven by:**", maybe "**Blocks:**", and a list of options, each "(a) option" with "**Recommended:**", "**Not recommended:**" or "**Rejected:**" and a reason.
16- "review": under "🔍 **Review:**", an entry "D2, pending: ..." or "D2, taken: ...".
17
18Ignore anything inside code fences: those are examples, not items.
19
20Copy text exactly as the message writes it, without its markdown marks. Never reword, never summarize, never add an item the message does not number.
21
22Answer with JSON alone, no prose and no code fence:
23{"topic":"...","items":[{"code":"Q1","kind":"question","title":"...","body":"...","recommendation":"...","context":"...","stakes":"...","drivenBy":"...","blocks":"...","feeds":["D1"],"options":[{"label":"(a) ...","verdict":"Recommended","reason":"..."}]}]}
24
25"topic" is the one sentence of the message that best says what this round of questions is about.
26"title" is the item's question or call in one line. "body" is the rest of the item's own text.
27"recommendation" is its recommended or default answer.
28"context" is the sentences elsewhere in the message that explain why this item is asked: copied, at most three sentences.
29"stakes", "drivenBy" and "blocks" are a decision's labelled lines.
30"feeds" lists the codes the item says it feeds ("Feeds D1").
31Use "" for a text the message does not have, [] for a list. "options" is [] unless it is a decision.`
32
33const KINDS: readonly ItemKind[] = ['question', 'decision', 'review']
34
35function isRecord(value: unknown): value is Record<string, unknown> {
36 return typeof value === 'object' && value !== null && !Array.isArray(value)
37}
38
39function text(value: unknown): string {
40 return typeof value === 'string' ? value.trim() : ''
41}
42
43// What Haiku answered, as JSON: the outermost object in its text.
44export function readJson(answer: string): unknown {
45 const start = answer.indexOf('{')
46 const end = answer.lastIndexOf('}')
47 if (start === -1 || end <= start) return undefined
48 try {
49 return JSON.parse(answer.slice(start, end + 1))
50 } catch {
51 return undefined
52 }
53}
54
55function holds(where: string, part: string): boolean {
56 return part === '' || where.includes(norm(part))
57}
58
59export type HaikuAnswer = { topic: string; items: Parsed[] }
60
61// Haiku's answer, each item and field held to the reply; undefined when the
62// answer is not the JSON asked for, so the caller can ask once more. An item
63// the regex also found is held to that item's own block, so a title or
64// recommendation moved from a neighbouring item is refused; its context may
65// come from anywhere in the reply. A context, stakes, drivenBy or blocks the
66// reply does not hold is dropped, the item kept.
67export function checkHaiku(
68 answer: string,
69 reply: string,
70 blocks: ReadonlyMap<string, string> = new Map(),
71): HaikuAnswer | undefined {
72 const json = readJson(answer)
73 if (!isRecord(json) || !Array.isArray(json.items)) return undefined
74 const whole = norm(stripFences(reply))
75 const topic = holds(whole, text(json.topic)) ? text(json.topic) : ''
76 const items: Parsed[] = []
77 for (const raw of json.items) {
78 if (!isRecord(raw)) continue
79 const code = text(raw.code).toUpperCase()
80 const kind = KINDS.find(k => k === raw.kind)
81 const block = blocks.get(code)
82 const own = block === undefined ? whole : norm(block)
83 const title = text(raw.title)
84 const body = text(raw.body)
85 const recommendation = text(raw.recommendation)
86 const options: ItemOption[] = (Array.isArray(raw.options) ? raw.options : [])
87 .filter(isRecord)
88 .map(o => ({ label: text(o.label), verdict: text(o.verdict), reason: text(o.reason) }))
89 const isBacked =
90 /^[QD]\d+$/.test(code) &&
91 kind !== undefined &&
92 title !== '' &&
93 new RegExp(`\\b${code}\\b`, 'i').test(whole) &&
94 holds(own, title) &&
95 holds(own, body) &&
96 holds(own, recommendation) &&
97 options.every(o => holds(own, o.label))
98 if (!isBacked || kind === undefined) continue
99 const kept = (value: unknown, where: string) => (holds(where, text(value)) ? text(value) : '')
100 const feeds = (Array.isArray(raw.feeds) ? raw.feeds : [])
101 .map(text)
102 .map(f => f.toUpperCase())
103 .filter(f => /^[QD]\d+$/.test(f) && new RegExp(`\\b${f}\\b`, 'i').test(own))
104 items.push({
105 code,
106 kind,
107 title,
108 body,
109 recommendation,
110 options,
111 context: kept(raw.context, whole),
112 stakes: kept(raw.stakes, own),
113 drivenBy: kept(raw.drivenBy, own),
114 blocks: kept(raw.blocks, own),
115 feeds,
116 source: '',
117 parsedBy: 'haiku',
118 })
119 }
120
121 return { topic, items }
122}
123
124// A field Haiku left empty takes the regex's.
125function filled(h: Parsed, r: Parsed): Parsed {
126 return {
127 ...h,
128 body: h.body || r.body,
129 recommendation: h.recommendation || r.recommendation,
130 options: h.options.length > 0 ? h.options : r.options,
131 stakes: h.stakes || r.stakes,
132 drivenBy: h.drivenBy || r.drivenBy,
133 blocks: h.blocks || r.blocks,
134 feeds: h.feeds.length > 0 ? h.feeds : r.feeds,
135 source: r.source,
136 }
137}
138
139// One list: each code once, in the reply's order. Haiku's item where the
140// reply backs it, the regex item otherwise, and every code the regex found
141// that Haiku missed. The source text is always the regex's, where it has one.
142export function merge(fromRegex: readonly Parsed[], fromHaiku: readonly Parsed[]): Parsed[] {
143 const byCode = new Map(fromHaiku.map(p => [p.code, p]))
144 const out: Parsed[] = fromRegex.map(r => {
145 const h = byCode.get(r.code)
146 byCode.delete(r.code)
147
148 return h === undefined ? r : filled(h, r)
149 })
150 for (const h of byCode.values()) out.push({ ...h, source: h.title })
151
152 return out
153}
154
155export type Ask = (system: string, prompt: string) => Promise<string | undefined>
156
157// The reply's items and topic: the regex pass, then Haiku asked up to twice
158// for an answer that is the JSON asked for; the regex items alone, and the
159// reply's first line as the topic, when it never is.
160export async function parseReply(reply: string, ask: Ask): Promise<HaikuAnswer> {
161 const fromRegex = parseRegex(reply)
162 for (let attempt = 0; attempt < 2; attempt += 1) {
163 const answer = await ask(RULES, reply)
164 if (answer === undefined) break
165 const fromHaiku = checkHaiku(answer, reply, new Map(fromRegex.map(r => [r.code, r.source])))
166 if (fromHaiku !== undefined) {
167 return { topic: fromHaiku.topic || topicOf(reply), items: merge(fromRegex, fromHaiku.items) }
168 }
169 }
170
171 return { topic: topicOf(reply), items: fromRegex }
172}
173hooks/looks.ts 51 lines1// Whether something the person wrote answers questions still open: Haiku
2// reads the message beside the open items and names each one it answers,
3// with a quote. A quote the message does not hold is refused, so Haiku can
4// only point at the person's own words, never put words in their mouth.
5
6import type { Item } from '../types'
7import { readJson } from './haiku'
8import { norm } from './parse'
9
10export const LOOKS_RULES = `A person is answering questions an AI agent asked them. You get the questions still open and the message the person just wrote.
11
12Name each open question the message answers, plainly or in passing. For each, copy the exact words of the message that answer it.
13
14Only name a question the message really answers. A message that only asks about a question, or mentions its topic, does not answer it.
15
16Answer with JSON alone, no prose and no code fence:
17{"answered":[{"code":"Q3","quote":"the exact words from the message"}]}
18
19Answer {"answered":[]} when the message answers none.`
20
21export function looksPrompt(open: readonly Item[], text: string): string {
22 const questions = open
23 .map(i => `${i.code}: ${i.title}${i.recommendation === '' ? '' : ` (default: ${i.recommendation})`}`)
24 .join('\n')
25
26 return `Open questions:\n${questions}\n\nThe person's message:\n${text}`
27}
28
29// The items Haiku says the message answers, each with its quote; an unknown
30// code, a closed item or a quote not in the message is dropped. A code shared
31// by two open items names the newest.
32export function checkLooks(answer: string, text: string, open: readonly Item[]): Map<string, string> {
33 const out = new Map<string, string>()
34 const json = readJson(answer)
35 if (typeof json !== 'object' || json === null || !('answered' in json) || !Array.isArray(json.answered)) return out
36 const said = norm(text)
37 const rows: unknown[] = json.answered
38 for (const row of rows) {
39 if (typeof row !== 'object' || row === null || !('code' in row) || !('quote' in row)) continue
40 const code = typeof row.code === 'string' ? row.code.trim().toUpperCase() : ''
41 const quote = typeof row.quote === 'string' ? row.quote.trim() : ''
42 if (quote === '' || !said.includes(norm(quote))) continue
43 const hit = open
44 .filter(i => i.code === code)
45 .reduce<Item | undefined>((best, i) => (best === undefined || i.at >= best.at ? i : best), undefined)
46 if (hit !== undefined && !out.has(hit.id)) out.set(hit.id, quote)
47 }
48
49 return out
50}
51hooks/parse.ts 229 lines1// Finds the items an agent puts to the person in one reply, by the four
2// formats they are written in:
3// grilling: ❓ **Q1** - **<title>**: <body> then ➡️ <recommendation>
4// output style Q: ❓ **Questions:** then - Q1: <question> Default: <default>. Feeds D1.
5// output style D: ### 🫵 D1: <question> then **Recommendation:** <...>
6// output style Review: 🔍 **Review:** D2, pending: <call> (or taken:)
7// Text inside code fences is an example, never an item.
8
9import type { ItemKind, ItemOption, Parsed } from '../types'
10
11const FENCE = /^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1[ \t]*$/gm
12
13export function stripFences(text: string): string {
14 return text.replace(FENCE, '')
15}
16
17export function hasMarker(text: string): boolean {
18 const plain = stripFences(text)
19
20 return /❓|🫵|🔍/u.test(plain) || /^\s*[-*]\s*Q\d+:/m.test(plain)
21}
22
23// Lowercased, markdown marks and spacing dropped: what "found in the reply"
24// compares, so a title Haiku copied without its bold still matches.
25export function norm(text: string): string {
26 return text
27 .replace(/[*_`>#]/g, '')
28 .replace(/\s+/g, ' ')
29 .trim()
30 .toLowerCase()
31}
32
33type Found = Parsed & { at: number }
34
35// A line where an item's block ends.
36const ITEM_END = /^(?:#{1,6}\s|❓|🔍|🫵|➡️\s*\*\*Next|---\s*$)/mu
37// A decision's block keeps its own `#### Options` subheading.
38const DECISION_END = /^(?:#{1,3}\s|❓|🔍|➡️\s*\*\*Next|---\s*$)/mu
39
40function blockAfter(text: string, from: number, end: RegExp): string {
41 const rest = text.slice(from)
42 const hit = end.exec(rest)
43
44 return hit === null ? rest : rest.slice(0, hit.index)
45}
46
47function item(
48 at: number,
49 code: string,
50 kind: ItemKind,
51 fields: Partial<Omit<Parsed, 'code' | 'kind' | 'parsedBy'>>,
52): Found {
53 return {
54 at,
55 code,
56 kind,
57 title: (fields.title ?? '').trim(),
58 body: (fields.body ?? '').trim(),
59 recommendation: (fields.recommendation ?? '').trim(),
60 options: fields.options ?? [],
61 context: (fields.context ?? '').trim(),
62 stakes: (fields.stakes ?? '').trim(),
63 drivenBy: (fields.drivenBy ?? '').trim(),
64 blocks: (fields.blocks ?? '').trim(),
65 feeds: fields.feeds ?? [],
66 source: (fields.source ?? '').trim(),
67 parsedBy: 'regex',
68 }
69}
70
71// The codes a "Feeds D1, D2" clause names.
72function feedsIn(text: string): string[] {
73 const clause = /Feeds\s+((?:D\d+)(?:\s*(?:,|and)\s*D\d+)*)/.exec(text)?.[1] ?? ''
74
75 return [...clause.matchAll(/D\d+/g)].map(m => m[0])
76}
77
78// A decision card's labelled line: `**Stakes:** <text>`.
79function labelled(block: string, label: string): string {
80 return new RegExp(`\\*\\*${label}:\\*\\*[ \\t]*([^\\n]+)`).exec(block)?.[1]?.trim() ?? ''
81}
82
83const GRILL = /^❓\s*\*\*Q(\d+)\*\*\s*[-–:]\s*\*\*(.+?)\*\*\s*:?[ \t]*(.*)$/gmu
84
85function grilling(text: string): Found[] {
86 const found: Found[] = []
87 for (const m of text.matchAll(GRILL)) {
88 const start = m.index ?? 0
89 const lineEnd = start + m[0].length
90 const tail = blockAfter(text, lineEnd + 1, ITEM_END)
91 const arrow = tail.search(/^➡️/mu)
92 const body = [m[3] ?? '', arrow === -1 ? tail : tail.slice(0, arrow)].join('\n')
93 const recommendation = arrow === -1 ? '' : tail.slice(arrow).replace(/^➡️\s*/u, '')
94 found.push(
95 item(start, `Q${m[1]}`, 'question', {
96 title: m[2],
97 body,
98 recommendation,
99 source: text.slice(start, lineEnd + 1 + tail.length),
100 }),
101 )
102 }
103
104 return found
105}
106
107const QUESTION_LINE = /^[ \t]*[-*][ \t]*Q(\d+):[ \t]*(.+)$/gm
108
109function questionLines(text: string): Found[] {
110 if (!/\*\*Questions:\*\*/.test(text)) return []
111 const found: Found[] = []
112 for (const m of text.matchAll(QUESTION_LINE)) {
113 const line = (m[2] ?? '').trim()
114 const split = line.search(/\s+Default:\s*/)
115 const title = split === -1 ? line : line.slice(0, split)
116 const recommendation =
117 split === -1
118 ? ''
119 : line
120 .slice(split)
121 .replace(/^\s+Default:\s*/, '')
122 .replace(/\s*Feeds\s+D\d+(?:\s*(?:,|and)\s*D\d+)*\.?\s*$/, '')
123 found.push(
124 item(m.index ?? 0, `Q${m[1]}`, 'question', {
125 title,
126 recommendation,
127 feeds: feedsIn(line),
128 source: m[0],
129 }),
130 )
131 }
132
133 return found
134}
135
136const DECISION = /^#{1,6}[ \t]*🫵[ \t]*D(\d+):[ \t]*(.+)$/gmu
137const OPTION = /^[ \t]*[-*][ \t]*(\([a-z]\)[^\n]*)\n[ \t]*\*\*(Recommended|Not recommended|Rejected):\*\*[ \t]*([^\n]*)/gm
138
139function decisions(text: string): Found[] {
140 const found: Found[] = []
141 for (const m of text.matchAll(DECISION)) {
142 const start = m.index ?? 0
143 const lineEnd = start + m[0].length
144 const tail = blockAfter(text, lineEnd + 1, DECISION_END)
145 const recommendation = labelled(tail, 'Recommendation')
146 const options: ItemOption[] = [...tail.matchAll(OPTION)].map(o => ({
147 label: (o[1] ?? '').trim(),
148 verdict: o[2] ?? '',
149 reason: (o[3] ?? '').trim(),
150 }))
151 found.push(
152 item(start, `D${m[1]}`, 'decision', {
153 title: m[2],
154 recommendation,
155 stakes: labelled(tail, 'Stakes'),
156 drivenBy: labelled(tail, 'Driven by'),
157 blocks: labelled(tail, 'Blocks'),
158 options,
159 source: text.slice(start, lineEnd + 1 + tail.length),
160 }),
161 )
162 }
163
164 return found
165}
166
167const REVIEW = /🔍\s*\*\*Review:\*\*/gu
168const REVIEW_ITEM = /\bD(\d+),[ \t]*(pending|taken):[ \t]*([^\n]+)/g
169
170function reviews(text: string): Found[] {
171 const found: Found[] = []
172 for (const m of text.matchAll(REVIEW)) {
173 const start = m.index ?? 0
174 const from = start + m[0].length
175 const segment = blockAfter(text, from, /^(?:#{1,6}\s|❓|🫵|➡️\s*\*\*Next|---\s*$)/mu)
176 for (const r of segment.matchAll(REVIEW_ITEM)) {
177 const line = (r[3] ?? '').trim()
178 found.push(
179 item(from + (r.index ?? 0), `D${r[1]}`, 'review', {
180 title: `${r[2]}: ${line}`,
181 source: r[0],
182 }),
183 )
184 }
185 }
186
187 return found
188}
189
190// Every item the four formats find, in the order the reply has them; a code
191// found twice keeps its first.
192export function parseRegex(reply: string): Parsed[] {
193 const text = stripFences(reply)
194 const all = [...grilling(text), ...questionLines(text), ...decisions(text), ...reviews(text)]
195 all.sort((a, b) => a.at - b.at)
196 const seen = new Set<string>()
197 const out: Parsed[] = []
198 for (const { at: _at, ...found } of all) {
199 if (seen.has(found.code)) continue
200 seen.add(found.code)
201 out.push(found)
202 }
203
204 return out
205}
206
207// The codes a line of the person's prompt starts with: `Q7`, `D1`, `**Q3**`,
208// `- D2:`. A bare number names nothing.
209export function answeredCodes(prompt: string): string[] {
210 const codes = new Set<string>()
211 for (const m of prompt.matchAll(/^[ \t]*(?:[-*][ \t]*)?\**([QD]\d+)\b/gim)) {
212 codes.add((m[1] ?? '').toUpperCase())
213 }
214
215 return [...codes]
216}
217
218// What a round is about, for its header: the reply's first line that is not
219// an item, its markdown marks dropped, cut to one line.
220export function topicOf(reply: string): string {
221 const lines = stripFences(reply)
222 .split('\n')
223 .map(l => l.replace(/[*_`#>]/g, '').replace(/^[^\p{L}\p{N}]+/u, '').replace(/^Orientation:\s*/i, '').trim())
224 .filter(l => l !== '' && !/^(?:Q|D)\d+\b/.test(l) && !/^Questions:?$/.test(l))
225 const first = lines[0] ?? ''
226
227 return first.length > 120 ? `${first.slice(0, 117)}...` : first
228}
229hooks/rebuild.ts 98 lines1// "Rebuild from the conversation": the items of the agent's recent replies,
2// parsed again, merged into the list. A reply's items that the list already
3// holds (same code, same title) are refreshed in place and keep their status,
4// answer, thread and history; the rest are added. A reply in the transcript
5// carries no turn id or time, so an added item's id comes from the reply's
6// place among the replies read, and its time puts it before every item the
7// list already holds, in the order the replies came: most rebuilt items were
8// asked before the mod was tracking.
9
10import type { Item, Parsed } from '../types'
11import { norm } from './parse'
12
13export type RebuiltReply = {
14 // Its place among the replies read, 0 the oldest.
15 index: number
16 topic: string
17 items: readonly Parsed[]
18 // What the person wrote after it, before the agent's next reply with items.
19 after: string
20}
21
22// The replies to parse: each assistant message with text, with the person's
23// messages that followed it, the newest `limit` of them.
24export function repliesIn(
25 messages: readonly { role: 'user' | 'assistant'; text: string }[],
26 isItemReply: (text: string) => boolean,
27 limit: number,
28): { text: string; after: string }[] {
29 const out: { text: string; after: string }[] = []
30 for (const m of messages) {
31 if (m.role === 'assistant' && isItemReply(m.text)) out.push({ text: m.text, after: '' })
32 else if (m.role === 'user' && out.length > 0) {
33 const last = out[out.length - 1]
34 if (last !== undefined) last.after = last.after === '' ? m.text : `${last.after}\n\n${m.text}`
35 }
36 }
37
38 return out.slice(-limit)
39}
40
41const sameItem = (a: Pick<Parsed, 'code' | 'title'>, b: Pick<Parsed, 'code' | 'title'>) =>
42 a.code === b.code && norm(a.title) === norm(b.title)
43
44export function rebuilt(
45 list: readonly Item[],
46 replies: readonly RebuiltReply[],
47 now: number,
48): { list: Item[]; added: number; refreshed: number } {
49 let out = [...list]
50 let added = 0
51 let refreshed = 0
52 const count = replies.length
53 const base = Math.min(now, ...list.map(i => i.at))
54 for (const reply of replies) {
55 for (const p of reply.items) {
56 const known = out.find(i => sameItem(i, p))
57 if (known !== undefined) {
58 out = out.map(i =>
59 i.id === known.id
60 ? {
61 ...i,
62 body: p.body || i.body,
63 recommendation: p.recommendation || i.recommendation,
64 options: p.options.length > 0 ? p.options : i.options,
65 context: p.context || i.context,
66 stakes: p.stakes || i.stakes,
67 drivenBy: p.drivenBy || i.drivenBy,
68 blocks: p.blocks || i.blocks,
69 feeds: p.feeds.length > 0 ? p.feeds : i.feeds,
70 source: p.source || i.source,
71 topic: i.topic || reply.topic,
72 }
73 : i,
74 )
75 refreshed += 1
76 continue
77 }
78 const turnId = `rebuilt-${reply.index}`
79 out.push({
80 ...p,
81 id: `${turnId}:${p.code}`,
82 turnId,
83 topic: reply.topic,
84 at: base - (count - reply.index) * 1000,
85 status: 'open',
86 answer: '',
87 how: '',
88 found: '',
89 thread: [],
90 history: [{ at: now, what: 'Found when rebuilding from the conversation' }],
91 })
92 added += 1
93 }
94 }
95
96 return { list: out, added, refreshed }
97}
98hooks/editor.tsx 115 lines1// A multi-line answer editor: a Client region the pane draws inside a card,
2// since the pane's own Input is one line (Enter submits it). It takes keys
3// once a click gives it the focus: Enter is a new line, Ctrl+Enter (or
4// Cmd+Enter) adds the answer. Each change is posted to the hooks module, which
5// keeps it as the item's draft so the card's Add button can add it too.
6// A key whose name is longer than one character and is not a special key is
7// inserted as text, which is how a paste would arrive if it arrives as one
8// key; whether a paste reaches a Client at all is not yet known.
9
10import type { ClientKeyEvent, ClientModule } from 'claude-code'
11
12export type EditorProps = { placeholder: string }
13export type EditorPost = { kind: 'change' | 'submit'; text: string }
14
15type State = { text: string; cursor: number }
16
17
18// The cursor's line and column, and the offset of a line and column.
19function where(text: string, cursor: number): { line: number; column: number } {
20 const before = text.slice(0, cursor).split('\n')
21
22 return { line: before.length - 1, column: (before[before.length - 1] ?? '').length }
23}
24
25function offset(text: string, line: number, column: number): number {
26 const lines = text.split('\n')
27 const at = Math.max(0, Math.min(line, lines.length - 1))
28 let sum = 0
29 for (let n = 0; n < at; n += 1) sum += (lines[n] ?? '').length + 1
30
31 return sum + Math.min(column, (lines[at] ?? '').length)
32}
33
34// The editor's next state for one key, and whether the key adds the answer.
35export function edit(state: State, key: ClientKeyEvent): { state: State; submit: boolean } {
36 const { text, cursor } = state
37 const put = (s: string) => ({ state: { text: text.slice(0, cursor) + s + text.slice(cursor), cursor: cursor + s.length }, submit: false })
38 const move = (to: number) => ({ state: { text, cursor: Math.max(0, Math.min(text.length, to)) }, submit: false })
39 const { line, column } = where(text, cursor)
40 switch (key.key) {
41 case 'return':
42 return key.ctrl === true || key.meta === true ? { state, submit: true } : put('\n')
43 case 'backspace':
44 return cursor === 0 ? { state, submit: false } : { state: { text: text.slice(0, cursor - 1) + text.slice(cursor), cursor: cursor - 1 }, submit: false }
45 case 'delete':
46 return { state: { text: text.slice(0, cursor) + text.slice(cursor + 1), cursor }, submit: false }
47 case 'left':
48 return move(cursor - 1)
49 case 'right':
50 return move(cursor + 1)
51 case 'home':
52 return move(offset(text, line, 0))
53 case 'end':
54 return move(offset(text, line, Number.MAX_SAFE_INTEGER))
55 case 'up':
56 return move(line === 0 ? 0 : offset(text, line - 1, column))
57 case 'down':
58 return move(offset(text, line + 1, column))
59 case 'space':
60 return put(' ')
61 default:
62 // One character typed; any other name is a key this editor does not use.
63 if ([...key.key].length !== 1 || key.ctrl === true || key.meta === true) return { state, submit: false }
64
65 return put(key.key)
66 }
67}
68
69const Editor: ClientModule<EditorProps, State> = (props, surface) => {
70 const { Box, Text } = surface.elements
71 const state: State = surface.state ?? { text: '', cursor: 0 }
72 surface.onKey(key => {
73 const next = edit(surface.state ?? state, key)
74 if (next.submit) {
75 const text = next.state.text.trim()
76 if (text === '') return
77 const post: EditorPost = { kind: 'submit', text }
78 surface.post(post)
79 surface.setState({ text: '', cursor: 0 })
80 return
81 }
82 surface.setState(next.state)
83 const post: EditorPost = { kind: 'change', text: next.state.text }
84 surface.post(post)
85 })
86 const lines = state.text.split('\n')
87 const { line, column } = where(state.text, state.cursor)
88
89 return (
90 <Box flexDirection="column" borderStyle="round" paddingX={1}>
91 {state.text === '' ? (
92 <Text>
93 <Text inverse> </Text>
94 <Text dimColor>{props.placeholder}</Text>
95 </Text>
96 ) : (
97 lines.map((l, n) =>
98 n === line ? (
99 <Text>
100 {l.slice(0, column)}
101 <Text inverse>{l.slice(column, column + 1) || ' '}</Text>
102 {l.slice(column + 1)}
103 </Text>
104 ) : (
105 <Text>{l === '' ? ' ' : l}</Text>
106 ),
107 )
108 )}
109 <Text dimColor>Enter: new line · Ctrl+Enter: add</Text>
110 </Box>
111 )
112}
113
114export default Editor
115types/index.d.ts 77 lines1export type ItemKind = 'question' | 'decision' | 'review'
2
3// open: waiting on the person. ready: answered in the pane, not sent yet.
4// looks: something the person wrote seems to answer it; they commit or cancel.
5// answered: sent or committed. dismissed: put aside, restorable.
6export type ItemStatus = 'open' | 'ready' | 'looks' | 'answered' | 'dismissed'
7
8export type ItemOption = { label: string; verdict: string; reason: string }
9
10// One question, decision or review item, as parsed from a reply. Every text
11// field is copied from the reply; '' when the reply has none.
12export type Parsed = {
13 code: string
14 kind: ItemKind
15 title: string
16 body: string
17 // The default or recommendation.
18 recommendation: string
19 options: ItemOption[]
20 // The reply's sentences that explain why the item is asked.
21 context: string
22 stakes: string
23 drivenBy: string
24 blocks: string
25 // Codes of the items this one feeds, such as ["D1"].
26 feeds: string[]
27 // The reply's own text for the item.
28 source: string
29 parsedBy: 'haiku' | 'regex'
30}
31
32export type HistoryEntry = { at: number; what: string }
33
34export type ThreadEntry = { who: 'you' | 'fork'; text: string; at: number }
35
36// A tracked item: the turn it came from plus its code is its id.
37export type Item = Parsed & {
38 id: string
39 turnId: string
40 // What the round is about: a line from the reply that asked it.
41 topic: string
42 at: number
43 status: ItemStatus
44 // The answer: ready to send, sent, or committed.
45 answer: string
46 // How an answered item was answered.
47 how: 'sent' | 'committed' | ''
48 // What the person wrote that looks like an answer.
49 found: string
50 thread: ThreadEntry[]
51 history: HistoryEntry[]
52}
53
54export type PaneView = {
55 tab: 'needs' | 'answered'
56 // Cards the person folded (an open card is unfolded by default).
57 collapsed: string[]
58 // Cards the person unfolded (a ready or answered card is folded by default).
59 expanded: string[]
60 preview: boolean
61 // Items whose Ask is waiting on the fork.
62 asking: string[]
63 // Items whose Ask thread the person folded.
64 foldedThreads: string[]
65 // What a running rebuild from the conversation is doing; '' when none runs.
66 rebuild: string
67}
68
69declare module 'claude-code' {
70 interface PluginState {
71 // `list` and `pane` replace slice 2's `items` and `view`, whose items had
72 // fewer fields: a reload reads the new keys, and takes a saved list from
73 // $.store, converted.
74 'question-pane': { list: Item[]; pane: PaneView; drafts: Record<string, string> }
75 }
76}
77