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

A live cactus inbox inside Claude Code. Your agent's questions show up as a pane; answer them in place.
cactus CLI: uv tool install git+https://github.com/eighteyes/cactus/cactus-pane opens it again.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.cactus ask --no-wait and keeps working.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.MIT
hooks/register.tsx 1085 lines1// 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}
1085types/index.d.ts 67 lines1export 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