SLOPSHOPPER

question-pane

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

newpanecommandtoastpromptmodel
v0.1.0no licenseupdated 2026-10-15Skorch/question-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · question-pane
│ ┃ Questions ✕ › fix the failing auth test and add an audit log call │ ┃ tab:needs tab:answered [ Rebuild from the │ ┃ ⏺ Read(src/auth.ts) │ ┃ 0 ready to send Preview [ Send 0 ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Nothing needs you. New questions appear here ⎿ Added 2 lines, removed 1 line │ ┃ when an agent asks them. ⏺ 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 │ │ › /questions │ ⎿ question-pane: Questions pane opened: 0 need you. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Questions
tab:needs tab:answered [ Rebuild from the conversation ] 0 ready to send Preview [ Send 0 ] Nothing needs you. New questions appear here when an agent asks them.
README

question-pane

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.

Install

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

What it tracks

After each reply of the main agent, it reads the items the reply puts to you, in these formats:

  • A grilling question: ❓ Q1 - title: body, then ➡️ recommended answer.
  • A question line under ❓ Questions:: - Q1: question Default: default answer. Feeds D1.
  • A decision card: ### 🫵 D1: question, with Recommendation:, Stakes:, Driven by:, Blocks: and its options.
  • A review item under 🔍 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.

Using the pane

/questions opens it. Each question is a card, open while it needs you:

  • Answer: Use default, pick an option, Ratify or Reverse a review, or write your own answer in the multi-line editor (click it first; Enter is a new line, Ctrl+Enter or Add adds it). A card folds to one line once its answer is ready.
  • Send: puts every ready answer in your prompt box, one line per question with its code and title. Press Enter to send it. Preview shows the message first.
  • Use all defaults: readies every open question that has a default.
  • Ask about this: asks a fork of the conversation about one question. The answer stays on the card and never enters the main chat.
  • Looks answered: when something you type answers an open question, Haiku points at your words and the card asks you to Commit (store it as answered, nothing sent) or Cancel.
  • Answered: every answered or dismissed question, with its answer, options, Ask thread and history, and Reopen.
  • Rebuild from the conversation (or /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".

Costs

Every model call uses your session's own account (your subscription, if you are signed in with one):

  • one Haiku call per reply that asks something;
  • one Haiku call per prompt you send while questions are open;
  • one fork of the conversation, on the session's own model, per Ask;
  • up to 20 Haiku calls, and one more per reply you answered, per Rebuild.

Limits

  • On the desktop app, a click on the pane when it does not have the keyboard only gives it the keyboard. The mod runs the clicked button's action itself for actions that are safe to run twice (Use default, an option, Ratify or Approve, Reverse, Commit, Cancel, Dismiss, Rebuild); a first click on Send, Add or a fold toggle is still lost.
  • Whether a paste reaches the multi-line editor is not yet known.
  • The editor needs a surface that draws a Client (the terminal and the desktop app); elsewhere the answer field is one line.
  • A fork always starts from the conversation as it is now, so Ask is told which question and when it was asked, and sees everything said since.

Develop

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.

Source 10 files
hooks/register.tsx 746 lines
1import { 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}
746
hooks/answers.ts 251 lines
1// 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}
251
hooks/ask.ts 28 lines
1// 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}
28
hooks/focus.ts 36 lines
1// 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}
36
hooks/haiku.ts 173 lines
1// 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}
173
hooks/looks.ts 51 lines
1// 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}
51
hooks/parse.ts 229 lines
1// 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}
229
hooks/rebuild.ts 98 lines
1// "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}
98
hooks/editor.tsx 115 lines
1// 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
115
types/index.d.ts 77 lines
1export 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