SLOPSHOPPER

cactus pane

A live cactus inbox pane for the session's project, answerable in place

newpaneguardcommandtoastprompt
★ 5v0.3.0MITupdated 2026-10-08eighteyes/cactus/mods/cactus-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cactus-pane
│ ┃ cactus ✕ › fix the failing auth test and add an audit log call │ ┃ ⠁⠁ ⢀ ⠂ │ ┃ ⡈ ⠠⠈ ⏺ Read(src/auth.ts) │ ┃ ⢰⣹⣻⡆ ⠔ ⠠ ⢀ ⢀ ⠄ ⢀ ⢀ ⎿ Read 6 lines │ ┃ ⣔⡖⡄⠁ ⠂⣠⡤⡀ ⏺ Update(src/auth.ts) │ ┃ ⠐⠠ ⢸⣸⣺⡇⢰⣻⣷ ⠠ ⢄⠠⡀⠂ ⠂ ⢀ ⠈ ⎿ Added 2 lines, removed 1 line │ ┃ ⡀⠄⡌⣟⡗⡇ ⣟⡗⡇ ⏺ Bash(bun test) │ ┃ ⠈⢀⠄⠄⣘⡮⡋⡆⢸⣸⣺⡇⢸⣺⣾ ⠣⠂ ⠁⠠ ⠄⠁⢀ ⠠⡠ ⠠⠁⠆⠁⠠⠈ ⎿ 3 pass, 1 fail │ ┃ ⡠⠠⠑⢀⣟⡗⡇⠢⢀ ⠁⣠⡤⡀⣟⡗⡇ │ ┃ ⠄⢀⠠⠘⡠⡧⡊⡇⢸⣸⣺⣷⣾⣺⣾ ⣤ ⢂⢓⠃⠤⣁⠘⢃⢀⠁⣀⣀⣄⣀⡀⠨⠈⣀ ⠗⣔⢆⣔⡖⡄ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ⣟⡗⡇⣞⡟⡆⡆⣟⡗⡇⣟⡗⡇ │ ┃ ⡔⠡⠋⡠⣣⡧⡊⡷⢺⣸⣺⡏⠉⠉⠉⢁⡓⠬⢣⠂⡠⢩⠁ ✻ Worked for 42s · done 4:20 PM │ ┃ ⡡⣴⣿⣿⣿⣿⣿⣿⣷⡄⠆⠋⠄⣥⠰⣟⡗⡇⠴⣟⡗⡇⣟⡗⡇⠑⣟⡗⡇⣟⡗⡇ │ ┃ ⢅⢃⣹⣮⣭⠉⠉⠉⢹⣸⣺⡇⣢⠪⡬⣨⢼⢦⡀⢧⡐⠚⣝⣟⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣥⣜⢿⣵⢢⣟⡗⡇⡦⣟ › /cactus-pane │ ┃ ⡗⡇⣟⡗⡇⢻⣟⡗⡇⣟⡗⡇ │ ┃ ⠑⢕⢕⢕⢕⢕⢕⢕⢸⣸⣺⡇⠋⠉⠏⠉⡉⠉⠉⠉⠍⠉⠋⠛⠍⠉⠫⠹⢭⠭⡭⣍⠉⢏⠉⢉⠙⠉⠉⣟⡗⡇⢕⣟ │ ┃ ⡗⡇⣟⡗⡇⢕⣟⡗⡇⣟⡗⡇ │ ┃ ⠑⢕⢕⢕⢕⢕⢸⣸⣺⡇⠠ ⠂ ⠄⠅⠠⡐ ⠈⠠⠤⠡⠄ ⠂⠁⠘ │ ┃ ⠂⠐⠙⣟⡗⡇⢕⣟⡗⡇⣟⡗⡇⢕⣟⡗⡇⣟⡗⡇ │ ┃ │ ┃ Cactus inbox empty. │ ┃ Decisions incoming... │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cactus
⠁⠁ ⢀ ⠂ ⡈ ⠠⠈ ⢰⣹⣻⡆ ⠔ ⠠ ⢀ ⢀ ⠄ ⢀ ⢀ ⣔⡖⡄⠁ ⠂⣠⡤⡀ ⠐⠠ ⢸⣸⣺⡇⢰⣻⣷ ⠠ ⢄⠠⡀⠂ ⠂ ⢀ ⠈ ⡀⠄⡌⣟⡗⡇ ⣟⡗⡇ ⠈⢀⠄⠄⣘⡮⡋⡆⢸⣸⣺⡇⢸⣺⣾ ⠣⠂ ⠁⠠ ⠄⠁⢀ ⠠⡠ ⠠⠁⠆⠁⠠⠈ ⡠⠠⠑⢀⣟⡗⡇⠢⢀ ⠁⣠⡤⡀⣟⡗⡇ ⠄⢀⠠⠘⡠⡧⡊⡇⢸⣸⣺⣷⣾⣺⣾ ⣤ ⢂⢓⠃⠤⣁⠘⢃⢀⠁⣀⣀⣄⣀⡀⠨⠈⣀ ⠗⣔⢆⣔⡖⡄ ⣟⡗⡇⣞⡟⡆⡆⣟⡗⡇⣟⡗⡇ ⡔⠡⠋⡠⣣⡧⡊⡷⢺⣸⣺⡏⠉⠉⠉⢁⡓⠬⢣⠂⡠⢩⠁ ⡡⣴⣿⣿⣿⣿⣿⣿⣷⡄⠆⠋⠄⣥⠰⣟⡗⡇⠴⣟⡗⡇⣟⡗⡇⠑⣟⡗⡇⣟⡗⡇ ⢅⢃⣹⣮⣭⠉⠉⠉⢹⣸⣺⡇⣢⠪⡬⣨⢼⢦⡀⢧⡐⠚⣝⣟⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣥⣜⢿⣵⢢⣟⡗⡇⡦⣟⡗⡇⣟⡗⡇⢻⣟⡗⡇⣟⡗⡇ ⠑⢕⢕⢕⢕⢕⢕⢕⢸⣸⣺⡇⠋⠉⠏⠉⡉⠉⠉⠉⠍⠉⠋⠛⠍⠉⠫⠹⢭⠭⡭⣍⠉⢏⠉⢉⠙⠉⠉⣟⡗⡇⢕⣟⡗⡇⣟⡗⡇⢕⣟⡗⡇⣟⡗⡇ ⠑⢕⢕⢕⢕⢕⢸⣸⣺⡇⠠ ⠂ ⠄⠅⠠⡐ ⠈⠠⠤⠡⠄ ⠂⠁⠘ ⠂⠐⠙⣟⡗⡇⢕⣟⡗⡇⣟⡗⡇⢕⣟⡗⡇⣟⡗⡇ Cactus inbox empty. Decisions incoming...
README

cactus pane

A live cactus inbox inside Claude Code. Your agent's questions show up as a pane; answer them in place.

Needs

  • The cactus CLI: uv tool install git+https://github.com/eighteyes/cactus
  • Claude Code with mods (function hooks) support

Use

  • Opens at session start. /cactus-pane opens it again.
  • A rail of open rows, a card for the focused one. A digit answers, i types (enter in the text line sends it), g sends a multi pick. Enter on a question only opens it; it never answers, so a stray enter cannot send a recommendation.
  • When one of the session's rows moves, the pane starts a turn carrying the answer in full and clears the row. The agent posts cactus ask --no-wait and keeps working.

What it runs and sends

Everything stays on your machine. The pane talks to nothing over the network.

Programs it runs (process.run), each with a fixed program name:

  • cactus: the local CLI, in the session's project directory (session.cwd). feed --json --here every few seconds to draw the pane; answer, undo, elaborate, clear, get, plan, review when you press a key. exec runs the command a run or review row carries, only when you press r on that row.
  • git diff HEAD -- FILE and head -n 40 FILE: the o preview of a file attached to a row.
  • open FILE: the f key, opens an attached file in your default app (macOS).

Prompts it submits (prompt.submit): when one of this session's own rows is answered, it submits one prompt to this session holding each moved row's key, its question's first line and your answer, so the agent can act on it. Nothing else goes in.

Hooks that change what passes through:

  • tool.call on Bash: adds --no-wait to a cactus ask --no-wait or cactus run --no-wait command the agent writes, so the agent never blocks waiting for you; the pane wakes it instead. No other command is touched.
  • prompt.compose: adds one section to the system prompt describing the cactus workflow in mod mode.
  • command.run / command.register: the /cactus-pane command that reopens the pane.

License

MIT

Source 2 files
hooks/register.tsx 1085 lines
1// cactus-pane: a live cactus inbox pane for the session's project.
2// - polls `cactus feed --json --here` and keeps the open/live/elaborate rows
3// - draws a one-line rail per row and a TUI-style card for the selected one
4// - the card's buttons carry the TUI's row keys (1-9, y/n, s, i, c, x, d, e, b, r, m)
5// - acts through the `cactus` CLI, so cli.py stays the only validator
6// - /cactus-pane opens the pane focused; it also opens unasked at session start
7// - wakes this session when its own rows move, and turns its cactus waits off
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register } from 'claude-code'
11
12import type { Answer, Row } from '../types'
13
14const PANE = 'cactus-pane'
15const POLL_MS = 3000
16const PREVIEW_LINES = 40
17const HISTORY = 3
18// Headers drawn at once, centred on the selection, so its card stays in view.
19const LIST_WINDOW = 7
20const rows = atom({ plugin: 'cactus-pane', key: 'rows' } as const, [])
21const picks = atom({ plugin: 'cactus-pane', key: 'picks' } as const, {})
22const error = atom({ plugin: 'cactus-pane', key: 'error' } as const, null)
23const selected = atom({ plugin: 'cactus-pane', key: 'selected' } as const, null)
24const previews = atom({ plugin: 'cactus-pane', key: 'previews' } as const, {})
25const undo = atom({ plugin: 'cactus-pane', key: 'undo' } as const, [])
26const modes = atom({ plugin: 'cactus-pane', key: 'modes' } as const, {})
27const flips = atom({ plugin: 'cactus-pane', key: 'flips' } as const, {})
28const drafts = atom({ plugin: 'cactus-pane', key: 'drafts' } as const, {})
29const UNDO_DEPTH = 20
30const RUN_TIMEOUT_MS = 600_000
31
32const ACT_COLOUR: Record<string, string> = {
33  ask: 'cyan',
34  steer: 'blue',
35  run: 'red',
36  review: 'magenta',
37  plan: 'green',
38  data: 'yellow',
39  notify: 'gray',
40}
41const CONFIDENCE: Record<string, string> = { low: '○', med: '◐', high: '●' }
42const PERSISTENT = new Set(['review', 'plan', 'data'])
43const QUIET = new Set(['open', 'live'])
44
45// The feed's change token; '' forces the next poll to rewrite the rows.
46let lastCursor = ''
47
48function firstLine(s: string | null | undefined): string {
49  return (s ?? '').split('\n')[0] ?? ''
50}
51
52// Rows arrive hard-wrapped for an 80-column terminal; the pane is narrower,
53// so soft breaks are joined and the pane wraps to its own width. A blank
54// line, a list or tradeoff marker, an indent, or a line after a colon keeps
55// its break.
56const KEEP_BREAK = /^(\s|[-+*•>]\s|\d+[.)]\s|→)/
57
58function reflow(s: string | null | undefined): string {
59  const out: string[] = []
60  for (const line of (s ?? '').split('\n')) {
61    const prev = out[out.length - 1]
62    const joins =
63      prev !== undefined && prev.trim() !== '' && line.trim() !== '' &&
64      !KEEP_BREAK.test(line) && !prev.trimEnd().endsWith(':')
65    if (joins) out[out.length - 1] = `${prev.trimEnd()} ${line.trim()}`
66    else out.push(line)
67  }
68  return out.join('\n').replace(/\n{3,}/g, '\n\n').trim()
69}
70
71function verdict(a: Answer): string {
72  if (a.skipped) return 'skipped'
73  const label = a.selected.join(', ')
74  if (label !== '' && a.text) return `${label} — ${a.text}`
75  return label || a.text || ''
76}
77
78async function poll($: EngineInterface): Promise<void> {
79  const ran = await $.process.run(['cactus', 'feed', '--json', '--here'], {
80    cwd: await $.session.cwd(),
81  })
82  if (ran.exitCode === 3) {
83    if (lastCursor !== 'empty') {
84      lastCursor = 'empty'
85      await update($, rows, () => [])
86      await update($, error, () => null)
87      await wake($, []).catch(() => undefined)
88    }
89    return
90  }
91  if (ran.exitCode !== 0) {
92    await update($, error, () => firstLine(ran.stderr) || `feed exit ${ran.exitCode}`)
93    return
94  }
95  const doc = JSON.parse(ran.stdout) as { cursor: unknown; questions: Row[] }
96  const cursor = JSON.stringify(doc.cursor)
97  if (cursor === lastCursor) return
98  lastCursor = cursor
99  await update($, rows, () => doc.questions)
100  await update($, error, () => null)
101  await wake($, doc.questions).catch(() => undefined)
102}
103
104// This session's own rows as last seen: key -> status and verdict count.
105// Null until the first poll seeds it, so rows already settled never wake.
106let watched: { sid: string; rows: Map<string, { status: string; n: number }> } | null = null
107
108// What a wake says about one row, and what is left for the agent to do. A
109// proper answer arrives whole and is cleared here, so it needs no get/clear.
110type Moved = { row: Row; text: string; clears: boolean; heard: boolean }
111
112function describe(row: Row): Moved {
113  const last = row.answers[row.answers.length - 1]
114  const asked = `${row.key} ${firstLine(row.text)}`
115  if (row.status === 'cleared' && row.closed_by_pass === true && last !== undefined) {
116    return { row, text: `${asked}\n  verdict: ${verdict(last)}\n  (the pass closed it)`, clears: false, heard: false }
117  }
118  if (row.status === 'cleared') {
119    return { row, text: `${asked}\n  declined: the human cleared it`, clears: false, heard: false }
120  }
121  if (row.status === 'elaborate') {
122    const hint = row.elaborate ?? 'no hint: rewrite plainly, add numbers and costs'
123    return {
124      row,
125      text: `${asked}\n  sent back for a rewrite: ${hint}\n  rewrite it with \`cactus edit ${row.key}\``,
126      clears: false,
127      heard: false,
128    }
129  }
130  if (last === undefined) return { row, text: `${asked}\n  ${row.status}`, clears: false, heard: false }
131  const ran = row.result ? `\n  ran: exit ${String(row.result.exit)}${row.result.log ? `, log ${row.result.log}` : ''}` : ''
132  if (PERSISTENT.has(row.act)) {
133    return {
134      row,
135      text: `${asked}\n  verdict: ${verdict(last)}${ran}\n  respond with \`cactus ${row.act === 'plan' ? 'plan' : row.act === 'review' ? 'review' : 'edit'} ${row.key}\` once acted on`,
136      clears: false,
137      heard: true,
138    }
139  }
140  if (last.skipped) {
141    return { row, text: `${asked}\n  skipped: act on your stated default\n  (cleared)`, clears: true, heard: false }
142  }
143  return { row, text: `${asked}\n  answer: ${verdict(last)}${ran}\n  (cleared)`, clears: true, heard: false }
144}
145
146// The client end of delivery: when one of this session's rows moves, start a
147// turn (queued behind a running one). A prompt the mod submits replaces the
148// backgrounded wait while the mod is loaded.
149async function wake($: EngineInterface, list: Row[]): Promise<void> {
150  const sid = await $.session.id()
151  const now = new Map(
152    list.filter(r => r.agent === sid).map(r => [r.key, { status: r.status, n: r.answers.length }]),
153  )
154  if (watched === null || watched.sid !== sid) {
155    watched = { sid, rows: now }
156    return
157  }
158  const moved: Row[] = []
159  const gone: string[] = []
160  for (const [key, before] of watched.rows) {
161    const after = now.get(key)
162    if (after === undefined) gone.push(key)
163    // A move back to open/live with no new verdict is this agent's own edit
164    // answering an elaborate request: never wake on it (q334's echo rule).
165    else if (after.n > before.n || (after.status !== before.status && !QUIET.has(after.status))) {
166      const row = list.find(r => r.key === key)
167      if (row !== undefined) moved.push(row)
168    }
169  }
170  watched = { sid, rows: now }
171  if (gone.length > 0) {
172    const got = await $.process.run(['cactus', 'get', ...gone, '--json'], {
173      cwd: await $.session.cwd(),
174    })
175    const parsed: unknown = got.exitCode === 0 ? JSON.parse(got.stdout) : null
176    if (Array.isArray(parsed)) {
177      // A row this agent cleared itself is not the human declining it.
178      for (const row of parsed as Row[]) {
179        if (row.status === 'cleared' && selfCleared.delete(row.key)) continue
180        moved.push(row)
181      }
182    }
183  }
184  if (moved.length === 0) return
185  const told = moved.map(describe)
186  const cwd = await $.session.cwd()
187  const clears = told.filter(t => t.clears).map(t => t.row.key)
188  if (clears.length > 0) await $.process.run(['cactus', 'clear', ...clears, '--agent', sid], { cwd })
189  // A read under the owner's --agent is what tells the human the verdict was
190  // heard; the wake carries it, so the mod reads it on the agent's behalf.
191  const heard = told.filter(t => t.heard).map(t => t.row.key)
192  if (heard.length > 0) await $.process.run(['cactus', 'get', ...heard, '--agent', sid, '--json'], { cwd })
193  // Bare, as the person's own words: no plugin frame, no header, just the rows.
194  await $.prompt.submit({ text: told.map(t => t.text).join('\n\n'), asUser: true })
195}
196
197// The cactus workflow in mod mode, injected into the system prompt for as long
198// as the mod is loaded; unloading it restores the session-start hook's waits.
199function guide(sid: string): string {
200  return [
201    '# cactus (mod mode)',
202    'cactus-pane is loaded: this session runs cactus in mod mode. It overrides the',
203    "session-start hook's wait instructions for as long as the mod is loaded.",
204    '',
205    `Identity: pass \`--agent ${sid}\` on every ask, edit, plan, review and clear.`,
206    '',
207    '1 ask    post every decision the human makes with `cactus ask`, not chat or',
208    '         AskUserQuestion: one -c per direction, --recommend LABEL --confidence L',
209    '         when you have a pick, -f for every file it is about.',
210    '2 post   post with --no-wait (the mod adds it when missing) and keep working.',
211    '         Never background-wait: no `cactus get --wait`, no waiting ask.',
212    '3 wake   the mod starts a turn, worded as the human, listing each row that moved',
213    '         is answered, gets a verdict, is sent back for a rewrite, or is cleared.',
214    '4 act    the wake carries each answer in full. An answered or skipped row is',
215    '         already cleared: act on the answer, or on your stated default.',
216    `         An elaborate row: rewrite it with \`cactus edit KEY --agent ${sid}\`.`,
217    '         A review/plan verdict (already marked heard): act, then respond with',
218    '         `cactus plan|review|edit`. `cactus get` only for more detail.',
219    '5 clear  the rows the mod did not clear (steer, notify, review, plan, data),',
220    '         by key, once acted on.',
221    '',
222    'The Stop hook stays quiet while you have an open row; end a turn with one open.',
223  ].join('\n')
224}
225
226// Blocking `cactus ask`/`cactus run` gain --no-wait: the mod is the wait.
227// Keys the agent clears through Bash, so their clear never reads as a decline.
228const selfCleared = new Set<string>()
229
230function noteClears(command: string): void {
231  for (const m of command.matchAll(/\bcactus\s+clear\b([^\n;&|]*)/g)) {
232    for (const word of (m[1] ?? '').split(/\s+/)) {
233      if (/^([\w.-]+:)?q\d+$/.test(word)) selfCleared.add(word.replace(/^.*:/, ''))
234    }
235  }
236}
237
238function noWait(command: string): string {
239  return command.replace(/\bcactus\s+(ask|run)\b(?![^\n;&|]*--no-wait)/g, 'cactus $1 --no-wait')
240}
241
242async function cactus(
243  $: EngineInterface,
244  key: string,
245  args: string[],
246  timeoutMs?: number,
247): Promise<string | null> {
248  const ran = await $.process.run(['cactus', ...args], {
249    cwd: await $.session.cwd(),
250    ...(timeoutMs === undefined ? {} : { timeoutMs }),
251  })
252  if (ran.exitCode !== 0) {
253    $.ui.toast(`cactus ${key}: ${firstLine(ran.stderr) || `exit ${ran.exitCode}`}`)
254    return null
255  }
256  await update($, picks, all => {
257    const { [key]: _gone, ...rest } = all
258    return rest
259  })
260  const before = (await read($, rows)).map(r => r.key)
261  lastCursor = ''
262  await poll($)
263  await refocus($, key, before)
264  return ran.stdout
265}
266
267// A row that left the inbox takes the focus ring with it, and a pane with no
268// ring hears no arrows. Land it on the header of the row now in that place.
269async function refocus($: EngineInterface, key: string, before: string[]): Promise<void> {
270  const now = (await read($, rows)).map(r => r.key)
271  if (now.includes(key)) return
272  const at = before.indexOf(key)
273  const after = before.slice(at + 1).find(k => now.includes(k))
274  const prior = before.slice(0, Math.max(0, at)).reverse().find(k => now.includes(k))
275  const target = after ?? prior
276  if (target === undefined) return
277  await select($, target)
278  await $.ui.focus({ requestId: PANE, key: `${target}:sel` }).catch(() => undefined)
279  await pinTop($, target)
280}
281
282// An answer or verdict the pane recorded goes on the undo stack: `u` pops it.
283async function answerRow($: EngineInterface, key: string, args: string[]): Promise<void> {
284  if ((await cactus($, key, ['answer', key, ...args])) !== null) {
285    await update($, undo, stack => [...stack.filter(k => k !== key), key].slice(-UNDO_DEPTH))
286  }
287}
288
289async function undoLast($: EngineInterface): Promise<void> {
290  const stack = await read($, undo)
291  const key = stack[stack.length - 1]
292  if (key === undefined) return
293  await update($, undo, s => s.slice(0, -1))
294  if ((await cactus($, key, ['undo', key])) !== null) {
295    $.ui.toast(`cactus ${key}: answer withdrawn`)
296    await select($, key)
297  }
298}
299
300// The TUI's `R`: run the row's command; on a run row that is the approval.
301async function runRow($: EngineInterface, key: string, isRunAct: boolean): Promise<void> {
302  $.ui.toast(`cactus ${key}: running`)
303  const out = await cactus($, key, ['exec', key], RUN_TIMEOUT_MS)
304  if (out === null) return
305  if (isRunAct) {
306    await update($, undo, stack => [...stack.filter(k => k !== key), key].slice(-UNDO_DEPTH))
307  }
308  $.ui.toast(`cactus ${key}: ran — ${firstLine(out.trim().split('\n').slice(-1)[0])}`)
309}
310
311// The flip is the pane's alone: `cactus answer -s a -s b` already records
312// several picks on a choice row, so the row itself never changes.
313async function setFlip($: EngineInterface, key: string, flipOn: boolean): Promise<void> {
314  await update($, flips, all => {
315    const { [key]: _gone, ...rest } = all
316    return flipOn ? { ...rest, [key]: true as const } : rest
317  })
318  await update($, picks, all => {
319    const { [key]: _gone, ...rest } = all
320    return rest
321  })
322}
323
324async function setDraft($: EngineInterface, key: string, text: string): Promise<void> {
325  await update($, drafts, all => {
326    const { [key]: _gone, ...rest } = all
327    return text === '' ? rest : { ...rest, [key]: text }
328  })
329}
330
331// One input line; enter sends. Multi-line is hidden until asked for: a line
332// ending in `\` is kept (without the `\`) and shown above the input, and the
333// next plain enter sends every kept line plus that one.
334async function submitLine($: EngineInterface, key: string, line: string): Promise<void> {
335  const draft = (await read($, drafts))[key] ?? ''
336  if (line.endsWith('\\')) {
337    const kept = line.slice(0, -1)
338    await setDraft($, key, draft === '' ? kept : `${draft}\n${kept}`)
339    return
340  }
341  const full = draft === '' ? line : line === '' ? draft : `${draft}\n${line}`
342  if (await submitText($, key, full)) await setDraft($, key, '')
343}
344
345// Enter on the open question's header sends, never picks: a multiple-choice
346// row sends its ticks, a single-choice row its pick (or recommendation), a
347// text row moves to the input. A run row is never run by enter.
348// Enter on the open question never answers it: an enter meant to open the
349// next question sent its recommendation (q622). A digit answers, `g` sends a
350// multi pick, the text line sends what was typed. Enter only points the way.
351async function enterOnOpen($: EngineInterface, row: Row): Promise<void> {
352  if (row.choices.length === 0 && row.act !== 'run') {
353    await $.ui.focus({ requestId: PANE, key: `${row.key}:text` }).catch(() => undefined)
354    return
355  }
356  $.ui.toast(`cactus ${row.key}: enter does not answer; press a digit${row.kind === 'multi' ? ', then g to send' : ''}`)
357}
358
359// The send path behind the input line.
360async function submitText($: EngineInterface, key: string, value: string): Promise<boolean> {
361  const row = (await read($, rows)).find(r => r.key === key)
362  if (row === undefined) return false
363  const typed = value.trim() === '' ? [] : [value]
364  if ((await read($, modes))[key] === 'elaborate') {
365    await setMode($, key, null)
366    await cactus($, key, ['elaborate', key, ...typed])
367    return true
368  }
369  const isMulti = row.kind === 'multi' || (await read($, flips))[key] === true
370  const mine = (await read($, picks))[key] ?? (isMulti ? (row.recommend ?? []) : [])
371  const sel = isMulti ? mine.flatMap(l => ['-s', l]) : []
372  if (sel.length === 0 && typed.length === 0) {
373    $.ui.toast(`cactus ${key}: nothing picked or typed`)
374    return false
375  }
376  await answerRow($, key, [...sel, ...typed])
377  return true
378}
379
380async function setMode($: EngineInterface, key: string, mode: 'elaborate' | null): Promise<void> {
381  await update($, modes, all => {
382    const { [key]: _gone, ...rest } = all
383    return mode === null ? rest : { ...rest, [key]: mode }
384  })
385}
386
387async function toggle(
388  $: EngineInterface,
389  key: string,
390  label: string,
391  start: readonly string[],
392): Promise<void> {
393  await update($, picks, all => {
394    const mine = all[key] ?? [...start]
395    return {
396      ...all,
397      [key]: mine.includes(label) ? mine.filter(l => l !== label) : [...mine, label],
398    }
399  })
400}
401
402async function select($: EngineInterface, key: string): Promise<void> {
403  if ((await read($, selected)) !== key) await update($, selected, () => key)
404}
405
406// Keep the open question's header in view.
407// The empty inbox: a braille sunset drawn at the pane's width. Sky dots
408// thicken toward the horizon around a half-set sun; an armed saguaro stands on
409// the left and a stand of single stalks on the right. Every cactus is lit on
410// the side facing the sun and shadowed on the other (solid outline, solid rib,
411// sparse shadow column), and casts a long ground shadow away from it.
412const ART_ROWS = 9
413const ART_MIN_COLUMNS = 20
414const HORIZON = 28
415const SKY_COLOURS = ['#4b2a6b', '#6e2f6e', '#9a3468', '#c4405e', '#e05a4f', '#f07f45', '#f9a640']
416const SUN_COLOUR = '#ffd166'
417const CACTUS_LIT = '#3f9a5c'
418const CACTUS_SHADE = '#24603a'
419const GROUND_COLOUR = '#8a5a3c'
420const SHADOW_COLOUR = '#4a2c2a'
421const BRAILLE_BITS: [number, number, number][] = [
422  [0, 0, 0x01], [0, 1, 0x02], [0, 2, 0x04], [1, 0, 0x08],
423  [1, 1, 0x10], [1, 2, 0x20], [0, 3, 0x40], [1, 3, 0x80],
424]
425const STIPPLE: Record<number, number[]> = { 1: [0], 2: [0, 2], 3: [0, 1, 2], 4: [0, 1, 2, 3] }
426
427// A cactus part: dot rectangles [x0, x1, y0, y1] inclusive, how many of every
428// 4 rows each interior column draws (absent: all 4), and its base span on the
429// ground, which casts the shadow.
430type Part = { rects: [number, number, number, number][]; cols: Record<number, number>; base: [number, number] }
431// Stipple phase counts from each part's left edge, so every stalk shades alike.
432
433// Fixed hash in [0, 1): the same art at the same width, every draw.
434function noise(x: number, y: number): number {
435  let mix = (Math.imul(x, 374761393) + Math.imul(y, 668265263)) >>> 0
436  mix = Math.imul(mix ^ (mix >>> 13), 1274126177) >>> 0
437  return ((mix ^ (mix >>> 16)) >>> 0) / 2 ** 32
438}
439
440// The armed saguaro on the left, lit from the right (the sun is east of it).
441function saguaro(): Part[] {
442  const base: [number, number] = [17, 22]
443  return [
444    { rects: [[17, 22, 5, 99], [18, 21, 4, 4]], cols: { 18: 1, 19: 4, 20: 2, 21: 4 }, base },
445    { rects: [[10, 14, 13, 23], [11, 13, 12, 12]], cols: { 11: 1, 12: 2, 13: 1 }, base },
446    { rects: [[10, 17, 21, 24]], cols: { 11: 1, 12: 1, 13: 1, 14: 1, 15: 1, 16: 1 }, base },
447    { rects: [[25, 29, 9, 19], [26, 28, 8, 8]], cols: { 26: 2, 27: 4, 28: 3 }, base },
448    { rects: [[22, 29, 17, 20]], cols: { 23: 3, 24: 3, 25: 3, 26: 3, 27: 4, 28: 3 }, base },
449  ]
450}
451
452// A stand of single stalks bunched on the right, past the sun: each lit on
453// the side facing it (west), shadowed on the east, heights from the hash.
454// Tallest first, so a shorter stalk in front keeps its own outline.
455function grove(dotWidth: number, sunX: number): Part[] {
456  const out: Part[] = []
457  // Even columns only: every stalk then splits into braille cells the same way.
458  let x = Math.max(sunX + 14, Math.round(dotWidth * 0.7)) & ~1
459  while (x + 4 <= dotWidth - 2) {
460    const top = 6 + Math.floor(noise(x, 99) * 15)
461    const lit = x + 2 < sunX
462    const cols = lit ? { [x + 1]: 1, [x + 2]: 4, [x + 3]: 3 } : { [x + 1]: 3, [x + 2]: 4, [x + 3]: 1 }
463    out.push({ rects: [[x, x + 4, top, 99], [x + 1, x + 3, top - 1, top - 1]], cols, base: [x, x + 4] })
464    x += 6 + 2 * Math.floor(noise(x, 7) * 2)
465  }
466  return out.sort((p, q) => (q.rects[0]?.[2] ?? 0) - (p.rects[0]?.[2] ?? 0))
467}
468
469function sunsetArt(columns: number): [string, string | undefined][][] {
470  const dotWidth = columns * 2
471  const dotHeight = ART_ROWS * 4
472  const sunX = Math.round(dotWidth * 0.52)
473  const sunR = 10
474  const parts = [...saguaro(), ...grove(dotWidth, sunX)]
475  const partAt = (x: number, y: number) =>
476    y < dotHeight ? parts.find(p => p.rects.some(([x0, x1, y0, y1]) => x >= x0 && x <= x1 && y >= y0 && y <= y1)) : undefined
477  const sides: [number, number][] = [[1, 0], [-1, 0], [0, 1], [0, -1]]
478  const cactusDot = (x: number, y: number): boolean | undefined => {
479    const p = partAt(x, y)
480    if (p === undefined) return undefined
481    // An edge against open sky or another cactus: stalks in a stand stay apart.
482    if (sides.some(([dx, dy]) => y + dy < dotHeight && partAt(x + dx, y + dy) !== p && !sameCactus(p, partAt(x + dx, y + dy)))) return true
483    return (STIPPLE[p.cols[x] ?? 4] ?? []).includes((y + x - p.base[0]) % 4)
484  }
485  // The saguaro's parts are one plant: no outline between trunk and arm.
486  const plant = new Set(parts.slice(0, 5))
487  const sameCactus = (p: Part, q: Part | undefined) => q !== undefined && plant.has(p) && plant.has(q)
488  // Long evening shadows on the ground, cast away from the sun, thinning out.
489  const shadow = (x: number, y: number) =>
490    y >= HORIZON && parts.some(({ base: [x0, x1] }) => {
491      const away = x0 > sunX ? x - x1 : x0 - x
492      const reach = 18 - (y - HORIZON)
493      return away > 0 && away <= reach
494    })
495  const sun = (x: number, y: number) => y < HORIZON && (x - sunX) ** 2 + (y - HORIZON) ** 2 <= sunR * sunR
496  const sky = (x: number, y: number) => {
497    const t = y / HORIZON
498    const glow = Math.max(0, 1 - Math.hypot(x - sunX, (y - HORIZON) * 1.8) / 22)
499    return noise(x, y) < Math.min(0.9, 0.02 + 0.6 * t ** 2.4 + 0.35 * glow)
500  }
501  const ground = (x: number, y: number) => {
502    const t = (y - HORIZON) / (dotHeight - HORIZON)
503    const reflect = Math.abs(x - sunX) < 8 - 2 * (y - HORIZON) && y % 2 === 0
504    return reflect || y === HORIZON || noise(x + 91, y) < 0.18 - 0.12 * t
505  }
506  const lines: [string, string | undefined][][] = []
507  for (let cy = 0; cy < ART_ROWS; cy++) {
508    const line: [string, string | undefined][] = []
509    for (let cx = 0; cx < columns; cx++) {
510      let cactusBits = 0, inCactus = false, sunBits = 0, skyBits = 0, groundBits = 0, shadeBits = 0
511      for (const [dx, dy, bit] of BRAILLE_BITS) {
512        const x = cx * 2 + dx, y = cy * 4 + dy
513        const c = cactusDot(x, y)
514        if (c !== undefined) {
515          inCactus = true
516          if (c) cactusBits |= bit
517        } else if (sun(x, y)) sunBits |= bit
518        else if (y < HORIZON && sky(x, y)) skyBits |= bit
519        else if (y >= HORIZON && shadow(x, y)) {
520          if ((x + y) % 2 === 0) shadeBits |= bit
521        } else if (y >= HORIZON && ground(x, y)) groundBits |= bit
522      }
523      let cell: [string, string | undefined] = [' ', undefined]
524      if (inCactus) {
525        const lit = cactusBits.toString(2).split('1').length - 1 >= 5
526        cell = [String.fromCharCode(0x2800 | cactusBits), lit ? CACTUS_LIT : CACTUS_SHADE]
527      } else if (sunBits) cell = [String.fromCharCode(0x2800 | sunBits), SUN_COLOUR]
528      else if (skyBits) cell = [String.fromCharCode(0x2800 | skyBits), SKY_COLOURS[Math.min(SKY_COLOURS.length - 1, cy)]]
529      else if (shadeBits) cell = [String.fromCharCode(0x2800 | shadeBits), SHADOW_COLOUR]
530      else if (groundBits) {
531        const nearSun = Math.abs(cx * 2 - sunX) < 8
532        cell = [String.fromCharCode(0x2800 | groundBits), nearSun ? SUN_COLOUR : GROUND_COLOUR]
533      }
534      const last = line[line.length - 1]
535      if (last !== undefined && last[1] === cell[1]) last[0] += cell[0]
536      else line.push([cell[0], cell[1]])
537    }
538    const tail = line[line.length - 1]
539    if (tail !== undefined && tail[1] === undefined) tail[0] = tail[0].trimEnd()
540    lines.push(line.filter(([text]) => text !== ''))
541  }
542  return lines
543}
544
545// One drawing per width: the art only changes when the pane is resized.
546const artCache = new Map<number, [string, string | undefined][][]>()
547function sunsetAt(columns: number): [string, string | undefined][][] {
548  const hit = artCache.get(columns)
549  if (hit !== undefined) return hit
550  const art = sunsetArt(columns)
551  artCache.set(columns, art)
552  return art
553}
554
555// cactus's own idle label: whole days from 48h up, else whole hours.
556function idleLabel(hours: number): string {
557  const whole = Math.floor(hours)
558  return whole >= 48 ? `${Math.floor(whole / 24)}d` : `${whole}h`
559}
560
561async function pinTop($: EngineInterface, key: string): Promise<void> {
562  await $.ui.scroll({ to: { key: `${key}:sel` }, block: 'nearest' }).catch(() => undefined)
563}
564
565// The TUI's j/k: move the selection one row and bring its card into view.
566async function step($: EngineInterface, by: number): Promise<void> {
567  const list = await read($, rows)
568  if (list.length === 0) return
569  const current = await read($, selected)
570  const at = Math.max(0, list.findIndex(r => r.key === current))
571  const to = list[Math.min(list.length - 1, Math.max(0, at + Math.sign(by)))]
572  if (to === undefined) return
573  await select($, to.key)
574  await $.ui.focus({ requestId: PANE, key: `${to.key}:sel` }).catch(() => undefined)
575  await pinTop($, to.key)
576}
577
578// Diff against HEAD when the file changed, else its head: the TUI's `o`.
579async function togglePreview($: EngineInterface, path: string): Promise<void> {
580  const shown = await read($, previews)
581  if (path in shown) {
582    await update($, previews, all => {
583      const { [path]: _gone, ...rest } = all
584      return rest
585    })
586    return
587  }
588  const dir = path.slice(0, path.lastIndexOf('/')) || '/'
589  const diff = await $.process.run(['git', '-C', dir, 'diff', 'HEAD', '--', path], {
590    timeoutMs: 2000,
591  }).catch(() => null)
592  const body =
593    diff !== null && diff.exitCode === 0 && diff.stdout.trim() !== ''
594      ? diff.stdout
595      : (await $.process.run(['head', '-n', String(PREVIEW_LINES), path])).stdout
596  const text = body.split('\n').slice(0, PREVIEW_LINES).join('\n')
597  await update($, previews, all => ({ ...all, [path]: text }))
598}
599
600export const register: Register = on => {
601  on('session.start', async ($, e, next) => {
602    await $.command.register({
603      name: 'cactus-pane',
604      description: "Show this project's cactus inbox in a pane",
605    })
606    $.clock.every(POLL_MS, () => poll($))
607    void poll($)
608    if (e.isInteractive) void $.ui.open({ id: PANE, title: 'cactus' })
609
610    return next(e)
611  })
612
613  on('command.run', { command: 'cactus-pane' }, async $ => {
614    lastCursor = ''
615    await poll($)
616    await $.ui.open({ id: PANE, title: 'cactus', focus: true })
617    const first = (await read($, selected)) ?? (await read($, rows))[0]?.key
618    if (first !== undefined) await pinTop($, first)
619
620    return { text: 'cactus pane opened.' }
621  })
622
623  on('prompt.compose', async ($, e, next) => {
624    const composed = await next(e)
625    const text = guide(await $.session.id())
626    return {
627      ...composed,
628      sections: [...composed.sections, { id: 'cactus-pane:guide', text, scope: 'session' }],
629    }
630  })
631
632  on('tool.call', { tool: 'Bash' }, ($, e, next) => {
633    noteClears(e.command)
634    return next({ ...e, command: noWait(e.command) })
635  })
636
637  // Walking the ring onto any of a row's elements selects that row.
638  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
639    const els = $.ui.resolve(e)
640    const { Box, Text, Button } = els
641    // The pane's inner width, redrawn on resize; the sunset is drawn to fit it.
642    const width = Math.min(200, e.props.bodyColumns)
643    const Input = 'Input' in els ? els.Input : undefined
644    const list = await read($, rows)
645    const picked = await read($, picks)
646    const failed = await read($, error)
647    const shown = await read($, previews)
648    const wanted = await read($, selected)
649    const stack = await read($, undo)
650    const modeOf = await read($, modes)
651    const flipped = await read($, flips)
652    const drafted = await read($, drafts)
653    const current = list.find(r => r.key === wanted) ?? list[0]
654
655    const card = (row: Row) => {
656      // m turns a single-choice row into pick-several for this one answer.
657      const canFlip = row.kind === 'choice' && row.act !== 'data' && row.act !== 'run' && row.choices.length > 1
658      const isFlipped = canFlip && flipped[row.key] === true
659      const isMulti = row.kind === 'multi' || isFlipped
660      const isConfirm = row.kind === 'confirm'
661      const isData = row.act === 'data'
662      const isPlan = row.act === 'plan'
663      const isRunAct = row.act === 'run'
664      const isElaborate = row.status === 'elaborate'
665      const isHinting = modeOf[row.key] === 'elaborate'
666      const canRun = isRunAct || Boolean(row.review?.run_cmd)
667      const hasInput =
668        Input !== undefined && (row.allow_free || isMulti || PERSISTENT.has(row.act) || isHinting) && !isData
669      const rec = row.recommend ?? []
670      // A multi row starts with its recommended set ticked, as the TUI does.
671      const mine = picked[row.key] ?? (isMulti ? rec : [])
672      const inputLabel = isHinting ? 'hint' : isMulti ? 'note' : PERSISTENT.has(row.act) ? 'verdict' : 'answer'
673      const owner = row.agent ? ['--agent', row.agent] : []
674      const pick = (label: string, surface: typeof e.surface) => {
675        if (isData) {
676          const body = row.choices.find(c => c.label === label)?.description ?? ''
677          void $.ui.copy({ text: body, surface })
678          void answerRow($, row.key, ['-s', label])
679        } else if (isMulti) {
680          // The ring stays on the header, where enter sends.
681          void toggle($, row.key, label, rec).then(() =>
682            $.ui.focus({ requestId: PANE, key: `${row.key}:sel` }).catch(() => undefined),
683          )
684        } else if (isRunAct && label === 'approve') {
685          void runRow($, row.key, true)
686        } else {
687          void answerRow($, row.key, ['-s', label])
688        }
689      }
690      const choiceKey = (i: number) => (isConfirm && i < 2 ? (i === 0 ? 'y' : 'n') : String(i + 1))
691
692      // The keys the hidden Buttons below carry, named once in the card.
693      const legend = [
694        hasInput ? 'i type' : null,
695        row.act === 'notify' ? 'd dismiss' : 's skip',
696        canRun ? 'r run' : null,
697        isElaborate ? 'u withdraw' : `e ${isHinting ? 'cancel' : 'elaborate'} · b break up`,
698        row.act === 'review' || isPlan ? 'x close' : 'c clear',
699        canFlip ? `m ${isFlipped ? 'single' : 'multiple'}` : null,
700        isMulti ? `g send ${mine.length}` : null,
701        row.files.length > 0 ? 'f view · o preview' : null,
702        stack.length > 0 && !isElaborate ? 'u undo' : null,
703        list.length > 1 ? 'j/k move' : null,
704      ]
705        .filter(Boolean)
706        .join(' · ')
707      const body = (
708        <Box key={`${row.key}:card`} flexDirection="column" borderStyle="round" paddingX={1}>
709          <Text bold wrap="wrap">{reflow(row.text)}</Text>
710          <Text dimColor>
711            {[
712              row.thread ? `thread ${row.thread}` : null,
713              row.agent ? `agent ${row.agent.slice(0, 8)}` : null,
714              row.blocked ? 'blocking' : null,
715              row.status === 'open' ? null : row.status,
716              row.files.length > 0 ? `${row.files.length} file(s)` : null,
717            ]
718              .filter(Boolean)
719              .join(' · ')}
720          </Text>
721          {row.context && (
722            <Box marginTop={1} flexDirection="column">
723              {reflow(row.context).split('\n\n').map((para, j) => (
724                <Box key={String(j)} marginTop={j === 0 ? 0 : 1}>
725                  <Text dimColor wrap="wrap">{para}</Text>
726                </Box>
727              ))}
728            </Box>
729          )}
730          {isElaborate && (
731            <Text color="yellow">
732              waiting on a rewrite{row.elaborate ? `: ${firstLine(row.elaborate)}` : ''}
733            </Text>
734          )}
735
736          {row.choices.length > 0 && (
737            <Box flexDirection="column" marginTop={1}>
738              {row.choices.map((c, i) => {
739                const lines = (isData ? c.description ?? '' : reflow(c.description)).split('\n')
740                const isPicked = mine.includes(c.label)
741                return (
742                  <Box key={`${row.key}:c${i}`} flexDirection="column">
743                    <Box flexDirection="row" columnGap={2}>
744                      <Text bold={isPicked}>
745                        <Text color="cyan">{choiceKey(i)}:</Text> {isPicked ? '✓ ' : ''}
746                        {c.label}
747                      </Text>
748                      {rec.includes(c.label) && <Text color="yellow">recommended</Text>}
749                      {row.chosen === c.label && <Text color="blue">chosen</Text>}
750                      {!isData && lines[0] && <Text dimColor wrap="wrap">{lines[0]}</Text>}
751                    </Box>
752                    {isData && <Text dimColor>   {firstLine(c.description)}</Text>}
753                    {!isData &&
754                      lines.slice(1).map((l, j) =>
755                        l.startsWith('+ ') ? (
756                          <Text key={String(j)} color="green" wrap="wrap">   ✓ {l.slice(2)}</Text>
757                        ) : l.startsWith('- ') ? (
758                          <Text key={String(j)} color="red" wrap="wrap">   ✗ {l.slice(2)}</Text>
759                        ) : (
760                          <Text key={String(j)} dimColor wrap="wrap">   {l}</Text>
761                        ),
762                      )}
763                  </Box>
764                )
765              })}
766            </Box>
767          )}
768
769          {rec.length > 0 && (
770            <Text color="yellow">
771              recommend {rec.join(', ')} {CONFIDENCE[row.confidence ?? ''] ?? ''} {row.confidence ?? ''}
772              {row.recommend_why ? ` — ${row.recommend_why}` : ''}
773            </Text>
774          )}
775
776          {isPlan && row.steps.length > 0 && (
777            <Box flexDirection="column" marginTop={1}>
778              {row.steps.map(s => (
779                <Text key={`step${s.n}`}>
780                  <Text color="cyan">{s.n <= 9 ? `${s.n}:` : '  '}</Text> [{s.done ? 'x' : ' '}] {s.text}
781                </Text>
782              ))}
783            </Box>
784          )}
785
786          {row.review && (
787            <Box flexDirection="column" marginTop={1}>
788              {row.review.look_at && <Text>look at  <Text dimColor>{row.review.look_at}</Text></Text>}
789              {row.review.run_cmd && <Text>run      <Text color="cyan">{row.review.run_cmd}</Text></Text>}
790              {row.review.pass_when && <Text>pass     <Text color="green">{row.review.pass_when}</Text></Text>}
791              {row.review.fail_when && <Text>fail     <Text color="red">{row.review.fail_when}</Text></Text>}
792              {row.review.then_do && <Text>then     <Text dimColor>{row.review.then_do}</Text></Text>}
793            </Box>
794          )}
795
796          {row.result && (
797            <Box flexDirection="column" marginTop={1}>
798              <Text color={row.result.exit === 0 ? 'green' : 'red'}>exit {String(row.result.exit)}</Text>
799              {row.result.tail.slice(-8).map((l, j) => (
800                <Text key={String(j)} dimColor>{l}</Text>
801              ))}
802            </Box>
803          )}
804
805          {row.files.length > 0 && (
806            <Box flexDirection="column" marginTop={1}>
807              {row.files.map((f, j) => (
808                <Box key={`${row.key}:f${j}`} flexDirection="column">
809                  <Box flexDirection="row" columnGap={2}>
810                    <Text dimColor>{j === 0 ? 'f/o ' : '    '}{f}</Text>
811                  </Box>
812                  {f in shown && <Text dimColor>{shown[f]}</Text>}
813                </Box>
814              ))}
815            </Box>
816          )}
817
818          {row.answers.length > 0 && (
819            <Box flexDirection="column" marginTop={1}>
820              {row.answers.slice(-HISTORY).map((a, j) => (
821                <Text key={String(j)} dimColor>→ {verdict(a)}</Text>
822              ))}
823            </Box>
824          )}
825
826          <Box marginTop={1}>
827            <Text dimColor wrap="wrap">{legend}</Text>
828          </Box>
829        </Box>
830      )
831      const input = hasInput ? (
832        <Box key={`${row.key}:input`} flexDirection="column" marginTop={1}>
833          {(drafted[row.key] ?? '') !== '' && (
834            <Box key={`${row.key}:draft`}>
835              <Text wrap="wrap" dimColor>{drafted[row.key]}</Text>
836            </Box>
837          )}
838          {Input !== undefined && (
839            <Input
840              key={`${row.key}:text`}
841              label={inputLabel}
842              placeholder="i to type · enter sends · end a line with \ for another line"
843              submitLabel="send"
844              onSubmit={(value: string) => void submitLine($, row.key, value)}
845            />
846          )}
847        </Box>
848      ) : null
849      // Probe (q555): every key Button sits in a display:none Box, so nothing
850      // draws, and the question is whether its hotkey still presses it.
851      const controls = (
852        <Box key={`${row.key}:controls`} display="none" flexDirection="column">
853          {row.choices.length > 0 && (
854            <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
855              {row.choices.slice(0, 9).map((c, i) => (
856                <Button
857                  key={`${row.key}:${c.label}`}
858                  label={c.label.length > 14 ? `${c.label.slice(0, 13)}…` : c.label}
859                  hotkey={choiceKey(i)}
860                  plain
861                  onPress={press => pick(c.label, press.surface)}
862                />
863              ))}
864            </Box>
865          )}
866          {isPlan && row.steps.length > 0 && (
867            <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
868              {row.steps.filter(s => s.n <= 9).map(s => (
869                <Button
870                  key={`${row.key}:step${s.n}`}
871                  label={s.done ? 'undo step' : 'step'}
872                  hotkey={String(s.n)}
873                  plain
874                  onPress={() =>
875                    void cactus($, row.key, ['plan', row.key, s.done ? '--undone' : '--done', String(s.n)])
876                  }
877                />
878              ))}
879            </Box>
880          )}
881          <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
882            {hasInput && (
883              <Button
884                key={`${row.key}:type`}
885                label="type"
886                hotkey="i"
887                plain
888                onPress={() =>
889                  void $.ui
890                    .focus({ requestId: PANE, key: `${row.key}:text` })
891                    .catch(() => undefined)
892                }
893              />
894            )}
895            {row.act === 'notify' ? (
896              <Button
897                key={`${row.key}:dismiss`}
898                label="dismiss"
899                hotkey="d"
900                plain
901                onPress={() => void answerRow($, row.key, ['--dismiss'])}
902              />
903            ) : (
904              <Button
905                key={`${row.key}:skip`}
906                label="skip"
907                hotkey="s"
908                plain
909                onPress={() => void answerRow($, row.key, ['--skip'])}
910              />
911            )}
912            {canRun && (
913              <Button
914                key={`${row.key}:run`}
915                label="run"
916                hotkey="r"
917                plain
918                onPress={() => void runRow($, row.key, isRunAct)}
919              />
920            )}
921            {isElaborate ? (
922              <Button
923                key={`${row.key}:withdraw`}
924                label="withdraw"
925                hotkey="u"
926                plain
927                onPress={() => void cactus($, row.key, ['elaborate', row.key, '--withdraw'])}
928              />
929            ) : (
930              <>
931                <Button
932                  key={`${row.key}:elaborate`}
933                  label={isHinting ? 'cancel' : 'elaborate'}
934                  hotkey="e"
935                  plain
936                  onPress={() => {
937                    void setMode($, row.key, isHinting ? null : 'elaborate')
938                    if (!isHinting) void $.ui.focus({ requestId: PANE, key: `${row.key}:text` }).catch(() => undefined)
939                  }}
940                />
941                <Button
942                  key={`${row.key}:decompose`}
943                  label="break up"
944                  hotkey="b"
945                  plain
946                  onPress={() => void cactus($, row.key, ['elaborate', row.key, '--decompose'])}
947                />
948              </>
949            )}
950            <Button
951              key={`${row.key}:clear`}
952              label={row.act === 'review' || isPlan ? 'close' : 'clear'}
953              hotkey={row.act === 'review' || isPlan ? 'x' : 'c'}
954              plain
955              onPress={() => void cactus($, row.key, ['clear', row.key, ...owner])}
956            />
957            {canFlip && (
958              <Button
959                key={`${row.key}:flip`}
960                label={isFlipped ? 'single choice' : 'multiple choice'}
961                hotkey="m"
962                plain
963                onPress={() => void setFlip($, row.key, !isFlipped)}
964              />
965            )}
966            {isMulti && (
967              <Button
968                key={`${row.key}:send`}
969                label={`send ${mine.length} picked`}
970                hotkey="g"
971                plain
972                onPress={() =>
973                  mine.length === 0
974                    ? $.ui.toast(`cactus ${row.key}: nothing picked`)
975                    : void answerRow($, row.key, mine.flatMap(l => ['-s', l]))
976                }
977              />
978            )}
979          </Box>
980          {row.files.length > 0 && (
981            <Box flexDirection="row" columnGap={2}>
982              <Button key={`${row.key}:open0`} label="view file" hotkey="f" plain onPress={() => void $.process.run(['open', row.files[0] ?? ''])} />
983              <Button
984                key={`${row.key}:prev0`}
985                label={(row.files[0] ?? '') in shown ? 'hide preview' : 'preview'}
986                hotkey="o"
987                plain
988                onPress={() => void togglePreview($, row.files[0] ?? '')}
989              />
990            </Box>
991          )}
992        </Box>
993      )
994      return { body, controls, input, legend }
995    }
996
997    const at = Math.max(0, list.findIndex(r => r === current))
998    const above = Math.max(0, Math.min(at - (LIST_WINDOW >> 1), list.length - LIST_WINDOW))
999
1000    return (
1001      <Box flexDirection="column">
1002        {failed !== null && <Text color="red">{failed}</Text>}
1003        {list.length === 0 && (
1004          <Box key="empty" flexDirection="column">
1005            {width >= ART_MIN_COLUMNS && (
1006              <Box key="sunset" flexDirection="column" marginBottom={1}>
1007              {sunsetAt(width).map((line, i) => (
1008                <Box key={`sunset:${i}`}>
1009                  <Text>
1010                    {line.map(([part, color], j) => (
1011                      <Text key={String(j)} color={color}>{part}</Text>
1012                    ))}
1013                  </Text>
1014                </Box>
1015              ))}
1016              </Box>
1017            )}
1018            <Text bold>Cactus inbox empty.</Text>
1019            <Text dimColor>Decisions incoming...</Text>
1020          </Box>
1021        )}
1022        {above > 0 && <Text dimColor>  ↑ {above} more (k)</Text>}
1023        {list.slice(above, above + LIST_WINDOW).map(row => {
1024          const isCurrent = row === current
1025          return (
1026            <Box key={row.key} flexDirection="column">
1027              <Box flexDirection="row" columnGap={2}>
1028                <Button
1029                  key={`${row.key}:sel`}
1030                  label={`${isCurrent ? '▸' : ' '} ${row.key}`}
1031                  plain
1032                  onPress={() => {
1033                    if (!isCurrent) return void select($, row.key).then(() => pinTop($, row.key))
1034                    void enterOnOpen($, row)
1035                  }}
1036                />
1037                <Text color={ACT_COLOUR[row.act] ?? 'white'}>{row.act.padEnd(6)}</Text>
1038                {row.stale === true && (
1039                  <Box key={`${row.key}:stale`}>
1040                    <Text dimColor>stale {idleLabel(row.idle_hours ?? 0)}</Text>
1041                  </Box>
1042                )}
1043                <Text wrap="truncate-end" dimColor={!isCurrent || row.stale === true} bold={isCurrent}>
1044                  {row.word ? `${row.word}  ` : ''}
1045                  {firstLine(row.text)}
1046                </Text>
1047              </Box>
1048            </Box>
1049          )
1050        })}
1051        {list.length - above - LIST_WINDOW > 0 && (
1052          <Text dimColor>  ↓ {list.length - above - LIST_WINDOW} more (j)</Text>
1053        )}
1054        {/* The list on top, the open question's card under it: arrows walk the
1055            headers, and the focus hook keeps them out of the card and keys. */}
1056        {current !== undefined && card(current).body}
1057        {current !== undefined && card(current).input}
1058        {current !== undefined && card(current).controls}
1059        <Box display="none" flexDirection="row" columnGap={2}>
1060          {stack.length > 0 && (
1061            <Button
1062              key="undo"
1063              label={`undo ${stack[stack.length - 1]}`}
1064              hotkey={current?.status === 'elaborate' ? undefined : 'u'}
1065              plain
1066              onPress={() => void undoLast($)}
1067            />
1068          )}
1069          {list.length > 1 && <Button key="prev" label="up" hotkey="k" plain onPress={() => void step($, -1)} />}
1070          {list.length > 1 && <Button key="next" label="down" hotkey="j" plain onPress={() => void step($, 1)} />}
1071        </Box>
1072      </Box>
1073    )
1074  })
1075
1076  // Arrows and tab are the person's focus steps. Only question headers may
1077  // take them; the keys below the list stay reachable by hotkey (and `i` puts
1078  // the ring in the input), so a step onto them is refused and the ring stays.
1079  on('ui.focus', async ($, e, next) => {
1080    if (e.requestId !== PANE || e.origin.kind !== 'person') return next(e)
1081    if (e.element !== undefined && !e.element.endsWith(':sel')) return { deny: 'cactus-pane: headers only' }
1082    return next(e)
1083  })
1084}
1085
types/index.d.ts 67 lines
1export type Choice = { label: string; description: string | null }
2
3export type Answer = {
4  selected: string[]
5  text: string | null
6  skipped: boolean
7  created_at: string
8}
9
10export type Step = { idx: number; n: number; text: string; done: boolean }
11
12export type Review = {
13  look_at: string | null
14  run_cmd: string | null
15  pass_when: string | null
16  fail_when: string | null
17  then_do: string | null
18}
19
20export type RunResult = { exit: number | null; tail: string[]; log: string | null }
21
22export type Row = {
23  key: string
24  text: string
25  act: string
26  kind: string
27  agent: string | null
28  thread: string | null
29  word: string | null
30  status: string
31  blocked: boolean
32  context: string | null
33  choices: Choice[]
34  allow_free: boolean
35  recommend: string[] | null
36  confidence: string | null
37  recommend_why: string | null
38  chosen: string | null
39  files: string[]
40  steps: Step[]
41  review: Review | null
42  result: RunResult | null
43  answers: Answer[]
44  elaborate: string | null
45  // Untouched past CACTUS_STALE_HOURS (cactus stale rows); absent on older cactus.
46  stale?: boolean
47  idle_hours?: number
48  // A review a pass closed (q601): cleared by the verdict, not by a decline.
49  closed_by_pass?: boolean
50}
51
52declare module 'claude-code' {
53  interface PluginState {
54    'cactus-pane': {
55      rows: Row[]
56      picks: Record<string, string[]>
57      error: string | null
58      selected: string | null
59      previews: Record<string, string>
60      undo: string[]
61      modes: Record<string, 'elaborate'>
62      flips: Record<string, true>
63      drafts: Record<string, string>
64    }
65  }
66}
67