The user's desk: a band above the prompt with status rows and buttons, an agent board pane with one card per subagent, and a file tracer pane listing the files…

One mod for Claude Code: desk. It has three parts.
/agent-board reopens it./file-tracer reopens it. It runs in a terminal. In the Claude desktop app it says it is off.Type this at the prompt of a Claude Code session running in a terminal:
/plugin install desk --marketplace KingP1197/claude-mods
Answer y to add the marketplace, then pick a scope. The user scope makes the mod load in every new session.
The install command works only in a terminal. A mod installed there at the user scope also loads in the Claude desktop app's Code tab.
SendMessage to the permissions.allow list in your settings. That rule lets every SendMessage through, including ones Claude sends on its own.TaskStop to your allow list./plugin uninstall desk
MIT. See LICENSE.
hooks/register.tsx 18 lines1// The entry file of the merged mod `desk`: it calls each area's `register(on)`, band, board, tracer, in that order.
2// The loader's rules: a hook hands `$` only to functions declared in its own file, never across an import; and
3// one hook per event without a matcher. So an event two areas hook is registered by each area in its own file,
4// behind an empty matcher `{}`, which the loader counts as matched and which every event satisfies. The engine
5// chains them in registration order, and each carries `.catch(($, e, next) => next(e))` so a throw in one area
6// leaves the others whole: the event goes on as if that area were absent, with a `next()` it made replayed.
7import type { Register } from 'claude-code'
8
9import { register as registerBand } from './band/register'
10import { register as registerBoard } from './board/register'
11import { register as registerTracer } from './tracer/register'
12
13export const register: Register = (on) => {
14 registerBand(on)
15 registerBoard(on)
16 registerTracer(on)
17}
18hooks/band/register.tsx 1348 lines1// Desktop version of ~/.claude/statusline-command.sh.
2// Row 1, the band's first line: the Tracer, Side question (terminal only), Compact and Write handoff buttons, at the trailing edge.
3// Row 2: branch, 5-hour and 7-day rate limits, context used, cache warm or cold, the Agent counter; the stats alone, with the whole width.
4// As the band narrows, figures drop out: branch, then 7d, then the cache item, then the Agent counter, then context shortens (fit.ts).
5// Hovering the cache item shows when it goes cold and the Auto warm switch, or once cold, what the next message re-stores.
6// The Agent counter (`agents N`, N the board's Live agents) opens the agent board when pressed; hovering it shows the running and finished counts.
7// Side question opens an Ask box under the Band's three lines; the Side questions panel still asks Claude over a copy of the chat, out of the main agent's sight.
8// Row 3: context bar split by source, and a Show details button.
9// On desktop the bar is an SVG whose pieces show a tooltip on hover.
10// Colors: plain text under 50% (low usage is not news), warning under 80%, error above.
11// Every hook lives in this file, the five on events other areas share (session.start, prompt.submit, session.measure,
12// turn.step, turn.complete) too: `register` here adds them all, and the entry file ../register.tsx only calls it.
13import { atom, read, update } from 'claude-code'
14import type {
15 Chunk,
16 ElementTable,
17 EngineInterface,
18 ModelUsage,
19 On,
20 RenderSurface,
21 SessionContextUsage,
22 SessionRateLimit,
23 SessionUsage,
24} from 'claude-code'
25
26import {
27 isOwnPress,
28 takenByPressHook,
29 type EventIn,
30 type EventOut,
31 type PlainNext,
32 type StreamingNext,
33} from '../handler'
34import { REFUSED_BY_HOOK, because, errorText, failToast } from '../failures'
35import { buttonPad, inputOf, isAppAttached, svgOf } from '../surface'
36import type { AgentRow, AutoWarm, CacheSeen, Handoff, Line, Part, Side, SideTurn } from '../../types'
37import {
38 AUTO_MARK,
39 KEEP_WARM_PROMPT,
40 NO_AUTO_WARM,
41 NO_PING,
42 TICK_MS,
43 TTL_SCRIPT,
44 type Ping,
45 autoWarmOf,
46 cacheAfterReply,
47 cacheView,
48 eastern,
49 prefixOf,
50 pressOn,
51 promptEndsAutoWarm,
52 tickAction,
53 ttlFrom,
54} from './cache'
55import { PANE as BOARD_PANE, TITLE as BOARD_PANE_TITLE } from '../board/agents'
56import { PANE as TRACER_PANE, TITLE as TRACER_TITLE } from '../tracer/record'
57import { agentsCard, countAgents } from './agents'
58import { BAND_DOING, bandButtonOf, type BandButton } from './keys'
59import { COMPACT, HANDOFF, SIDE, claim } from './busy'
60import { COMPACT_PROMPT } from './compact'
61import {
62 answerRows,
63 cardLeft,
64 cutAnswer,
65 cutQuestion,
66 type Figures,
67 type RowItem,
68 type RowKey,
69 SEP_TEXT,
70 statsItems,
71 type StatsItems,
72 textRows,
73} from './fit'
74import type { AskBoxPost, AskBoxProps } from './askbox'
75import { ASK_BOX, NO_SIDE, SIDE_PANE, forkFailure, forkThrew, sidePrompt } from './side'
76import { CAP, FIRST_LINE, HANDOFF_PROMPT, IDLE, TOOL, TOOL_SPEC, estimateTokens, fail, ok, textOf } from './handoff'
77
78// The validator reads state only through atoms declared in this file.
79const line = atom({ plugin: 'desk', key: 'line' } as const, null)
80const isOpen = atom({ plugin: 'desk', key: 'isOpen' } as const, false)
81const now = atom({ plugin: 'desk', key: 'bandNow' } as const, 0)
82const handoff = atom({ plugin: 'desk', key: 'handoff' } as const, IDLE)
83const isCompacting = atom({ plugin: 'desk', key: 'isCompacting' } as const, false)
84const side = atom({ plugin: 'desk', key: 'side' } as const, NO_SIDE)
85const isSideOpen = atom({ plugin: 'desk', key: 'isSideOpen' } as const, false)
86const keyboardLeaves = atom({ plugin: 'desk', key: 'keyboardLeaves' } as const, 0)
87const cache = atom({ plugin: 'desk', key: 'cache' } as const, null as CacheSeen | null)
88const autoWarm = atom({ plugin: 'desk', key: 'autoWarm' } as const, NO_AUTO_WARM)
89// The board's Batch, which the Agent counter reads and never writes. The board declares the same key.
90const agents = atom({ plugin: 'desk', key: 'agents' } as const, [] as AgentRow[])
91// The tracer's own saved flag, which the Tracer button clears. The tracer declares the same key. It stays a literal: the engine's scan refuses an atom key imported from another file. shared.test.ts proves the keys agree.
92const tracerClosed = atom({ plugin: 'desk', key: 'closed' } as const, false)
93
94// One color per source, biggest first. Readable on dark and light.
95// Neighbors sit far apart in hue, so the two biggest sources (side by side in the bar) never look alike.
96const PALETTE = ['#7aa2f7', '#ff9e64', '#9ece6a', '#f7768e', '#bb9af7', '#e0af68', '#7dcfff', '#c0caf5']
97// Free space: solid grey.
98const FREE = '#4b5563'
99// Terminal bar length when the band's width is unknown.
100const TEXT_BAR_CELLS = 40
101// Cells kept clear on the terminal bar's line: the two-cell gap, `[ Show details ]` (16, the same as
102// `[ Hide details ]`) and three to spare. A bar any wider wraps its last cell onto a row of its own.
103const TEXT_BAR_RESERVE = 21
104// Desktop bar height in CSS pixels. The picture sets it: a box can't be sized in pixels, and drew round corners.
105// No width: the bar stretches to fill its row, so Details sits at the right edge.
106const BAR_H = 9
107// The bar is split into this many parts; a box's flexGrow may not pass 10000.
108const BAR_PARTS = 10_000
109// A source too small to see still takes this many parts (0.2%), so its hover card can be reached.
110const MIN_PARTS = 20
111
112// Higher used is worse, for context and both rate limits.
113const level = (pct: number) => (pct >= 80 ? 'error' : pct >= 50 ? 'warning' : 'text')
114
115const short = (n: number) =>
116 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
117
118const pct = (n: number, of: number) => (of > 0 ? `${((n / of) * 100).toFixed(1)}%` : '0%')
119
120const rate = (limits: readonly SessionRateLimit[], kind: string) =>
121 limits.find((l) => l.kind === kind)?.percentUsed ?? null
122
123// Only sources that occupy the window; free space and the compaction reserve are left out.
124function partsOf(usage: SessionUsage): Part[] | null {
125 const cats = usage.context.breakdown?.categories
126 if (cats === undefined) return null
127 return cats
128 .filter((c) => c.kind === 'used' && c.tokens > 0)
129 .map((c) => ({ name: c.name, tokens: c.tokens }))
130 .sort((a, b) => b.tokens - a.tokens)
131}
132
133async function branchOf($: EngineInterface): Promise<string | null> {
134 try {
135 const cwd = await $.session.cwd()
136 const r = await $.process.run(['git', '-C', cwd, '--no-optional-locks', 'branch', '--show-current'], {
137 timeoutMs: 3000,
138 })
139 const name = r.stdout.trim()
140 return r.exitCode === 0 && name !== '' ? name : null
141 } catch {
142 return null
143 }
144}
145
146// While Details is open, the token-count service's figures; otherwise a free local estimate.
147// Assumed: one exact usage call per turn while Details stays open is an acceptable cost.
148async function refresh(
149 $: EngineInterface,
150 opts: { figures?: { context: SessionContextUsage; rateLimits: readonly SessionRateLimit[] } } = {},
151) {
152 const isExact = await read($, isOpen)
153 const [usage, branch] = await Promise.all([$.session.usage({ breakdown: isExact ? 'full' : 'summary' }), branchOf($)])
154 const figures = opts.figures ?? usage
155 await update($, line, () => ({
156 branch,
157 ctxUsed: figures.context.tokens ?? null,
158 ctxWindow: figures.context.window,
159 ctxPercent: figures.context.percent ?? null,
160 rate5: rate(figures.rateLimits, 'five_hour'),
161 rate7: rate(figures.rateLimits, 'seven_day'),
162 parts: partsOf(usage),
163 isExact,
164 }))
165}
166
167type BarPiece = { grow: number; color: string; label: string }
168
169// The desktop bar's pieces: one per source, then free space, each sized in parts of BAR_PARTS.
170// The picture and the hover boxes over it are both drawn from these, so they line up.
171function barPieces(parts: Part[], window: number): BarPiece[] {
172 const raw = parts.map((p) => Math.max(window > 0 ? (p.tokens * BAR_PARTS) / window : 0, MIN_PARTS))
173 // Floors on a nearly full window, or a source bigger than the window, can overrun the bar.
174 // Shrink every piece alike so the hover boxes still sit over their colors, and none passes 10000.
175 const rawSum = raw.reduce((s, g) => s + g, 0)
176 const scale = rawSum > BAR_PARTS ? BAR_PARTS / rawSum : 1
177 const pieces = parts.map((p, i) => ({
178 grow: (raw[i] ?? 0) * scale,
179 color: PALETTE[i % PALETTE.length] ?? FREE,
180 label: `${p.name}: ${short(p.tokens)} (${pct(p.tokens, window)})`,
181 }))
182 const taken = pieces.reduce((s, p) => s + p.grow, 0)
183 const free = Math.max(0, window - parts.reduce((s, p) => s + p.tokens, 0))
184 return [...pieces, { grow: Math.max(0, BAR_PARTS - taken), color: FREE, label: `Free: ${short(free)}` }]
185}
186
187// The bar's picture: square-ended rects, drawn as a plain image.
188// Not interactive: a picture in a hover frame flickered on every repaint, so hover lives in boxes laid over it.
189function barSvg(pieces: BarPiece[]): string {
190 let x = 0
191 const rects = pieces.map((p) => {
192 const r = `<rect x="${x.toFixed(1)}" y="0" width="${p.grow.toFixed(1)}" height="${BAR_H}" fill="${p.color}"/>`
193 x += p.grow
194 return r
195 })
196 return (
197 `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${BAR_H}" viewBox="0 0 ${BAR_PARTS} ${BAR_H}" preserveAspectRatio="none" style="display:block">` +
198 `${rects.join('')}</svg>`
199 )
200}
201
202// The terminal bar: one colored run of cells per source, then dim free cells.
203function textBar(parts: Part[], window: number, total: number): { cells: string; color: string }[] {
204 const runs: { cells: string; color: string }[] = []
205 let left = total
206 parts.forEach((p, i) => {
207 const n = Math.min(left, Math.max(1, Math.round((p.tokens / window) * total)))
208 if (n > 0) runs.push({ cells: '█'.repeat(n), color: PALETTE[i % PALETTE.length] ?? FREE })
209 left -= n
210 })
211 if (left > 0) runs.push({ cells: '░'.repeat(left), color: FREE })
212 return runs
213}
214
215// The one clock the band counts from. It only moves forward: a reply's send time can be older than it.
216function advance($: EngineInterface, t: number) {
217 return update($, now, (n) => Math.max(n, t))
218}
219
220// The Handoff button's press: queue the request; or, on a request already out, free the button and end its turn if it runs.
221async function startHandoff($: EngineInterface, surface: RenderSurface) {
222 // Claimed inside one update, so two presses at once cannot both queue a request. A refused claim frees the button.
223 const press = claim(HANDOFF, surface)
224 await update($, handoff, press.change)
225 if (press.isTaken()) return sendHandoff($)
226 const was = press.seen()
227 if (was?.phase === 'writing' && was.turnId !== undefined) return stopHandoff($, was.turnId)
228 $.ui.toast('Handoff reset. Press Write handoff again to write a new one.')
229}
230
231// A second press while the handoff turn runs: end that turn. The button is already free, so the turn's end says nothing.
232async function stopHandoff($: EngineInterface, turnId: string) {
233 try {
234 await $.turn.abort({ turnId })
235 $.ui.toast('Handoff stopped.')
236 } catch (err) {
237 $.ui.toast(`Could not stop the handoff ${because(errorText(err))}. The button is free again.`)
238 }
239}
240
241// Why a hook refused a prompt: the text it gave, or a plain line where it gave none.
242const dropReason = (drop: string) => drop || REFUSED_BY_HOOK
243
244// Sends the handoff request for a press that queued one.
245function sendHandoff($: EngineInterface) {
246 const cancel = async (reason: string) => {
247 await update($, handoff, HANDOFF.release)
248 $.ui.toast(`The handoff request could not be sent ${because(reason)}.`)
249 }
250 // Resolves when the handoff turn starts, which may wait for a running turn; the press does not wait for it.
251 // A hook that refuses the prompt answers { drop } rather than failing the call.
252 void $.prompt.submit({ text: HANDOFF_PROMPT }).then(
253 (r) => (r.drop === undefined ? undefined : cancel(dropReason(r.drop))),
254 (err: unknown) => cancel(errorText(err)),
255 )
256}
257
258// The Tracer button's press: bring the tracer's pane back with the keyboard, as a press on a Trail row does.
259// A pane the engine rejects is left as it is, and the toast says why.
260async function openTracer($: EngineInterface) {
261 await update($, tracerClosed, () => false)
262 await $.ui.open({ id: TRACER_PANE, title: TRACER_TITLE, focus: true }).catch((err: unknown) => {
263 $.ui.toast(`Could not open ${TRACER_TITLE}: ${errorText(err)}`)
264 })
265}
266
267// The Agent counter's press: open the board's pane with the keyboard, as the board's own `/agent-board` does.
268// The board keeps no closed flag, so there is no state to reset. A pane the engine rejects is left as it is, and the toast says why.
269async function openBoard($: EngineInterface) {
270 await $.ui.open({ id: BOARD_PANE, title: BOARD_PANE_TITLE, focus: true }).catch((err: unknown) => {
271 $.ui.toast(`Could not open ${BOARD_PANE_TITLE}: ${errorText(err)}`)
272 })
273}
274
275// The Compact button's press: run `/compact <instructions>` as if the user typed it.
276// Not $.session.compact: the desktop app runs the session headless, where that call is refused.
277// The engine queues the command until the session is idle; its result shows in the transcript.
278async function startCompact($: EngineInterface) {
279 // Claimed inside one update, so two presses at once run one command.
280 const press = claim(COMPACT)
281 await update($, isCompacting, press.change)
282 if (!press.isTaken()) return
283 try {
284 await $.command.run({ command: 'compact', args: COMPACT_PROMPT })
285 } catch (err) {
286 $.ui.toast(`Could not compact ${because(errorText(err))}.`)
287 } finally {
288 await update($, isCompacting, COMPACT.release)
289 }
290}
291
292// Reads the cache's lifetime from the transcript's last writes. A failed read keeps the last one known.
293async function learnTtl($: EngineInterface, transcriptPath: string) {
294 try {
295 const r = await $.process.run(['/bin/sh', '-c', TTL_SCRIPT, 'sh', transcriptPath], { timeoutMs: 3000 })
296 const ttlMs = ttlFrom(r.stdout)
297 if (ttlMs !== null) await update($, cache, (c) => (c === null ? c : { ...c, ttlMs }))
298 } catch {
299 // The item falls back to the shorter lifetime.
300 }
301}
302
303// Every read of the switch goes through this, so a saved value missing a field is brought up to date.
304async function readAuto($: EngineInterface): Promise<AutoWarm> {
305 return autoWarmOf(await read($, autoWarm))
306}
307
308// The Auto warm press: off when on; else on until three hours from now. Only the hour-long cache can be kept warm.
309// It answers the switch as the press left it, or null where the press was refused.
310async function pressAutoWarm($: EngineInterface): Promise<AutoWarm | null> {
311 if ((await readAuto($)).isOn) {
312 await update($, autoWarm, () => NO_AUTO_WARM)
313 return NO_AUTO_WARM
314 }
315 const press = pressOn(await read($, cache), await $.clock.now())
316 if (!press.isOk) {
317 $.ui.toast(press.toast)
318 return null
319 }
320 await update($, autoWarm, () => press.auto)
321 return press.auto
322}
323
324// The terminal's button: the same press as the card's switch, then a toast, since the card's line is not on screen there.
325async function pressAutoWarmButton($: EngineInterface) {
326 const after = await pressAutoWarm($)
327 if (after === null) return
328 $.ui.toast(after.isOn ? `Auto warm on, off at ${eastern(after.offAt)}` : 'Auto warm off')
329}
330
331// The Keep-warm ping: a tiny prompt, whose request reads the whole cache and restarts its clock.
332// False comes back where the ping never entered, so nothing will answer it.
333function sendPing($: EngineInterface): Promise<boolean> {
334 return $.prompt.submit({ text: KEEP_WARM_PROMPT }).then(
335 (r) => {
336 if (r.drop === undefined) return true
337 $.ui.toast(`The keep-warm message was refused ${because(dropReason(r.drop))}.`)
338 return false
339 },
340 (err: unknown) => {
341 $.ui.toast(`The keep-warm message could not be sent ${because(errorText(err))}.`)
342 return false
343 },
344 )
345}
346
347// Each tick: the rules in cache.ts say whether Auto warm stays, switches off or pings. True comes back when this tick
348// should ping. A ping that waits (queued behind a long tool call, say) holds the next until its reply restarts the
349// cache's clock.
350async function autoWarmTick($: EngineInterface, t: number, ping: Ping): Promise<boolean> {
351 const action = tickAction(await readAuto($), await read($, cache), t, ping)
352 if (action.kind === 'off') await update($, autoWarm, () => NO_AUTO_WARM)
353 return action.kind === 'ping'
354}
355
356// The Details button's press: opens the details, or closes them. Whether they are open is read now, not when the button was drawn.
357async function toggleDetails($: EngineInterface) {
358 let isOpening = false
359 await update($, isOpen, (open) => {
360 isOpening = !open
361 return isOpening
362 })
363 if (isOpening) await refresh($)
364}
365
366// The action of each band Button: the one copy, which the band's `ui.press` hook runs. `surface` is where the press came from.
367const bandActions = ($: EngineInterface, surface: RenderSurface): Record<BandButton, () => Promise<unknown>> => ({
368 tracer: () => openTracer($),
369 side: () => toggleSide($),
370 compact: () => startCompact($),
371 handoff: () => startHandoff($, surface),
372 agents: () => openBoard($),
373 details: () => toggleDetails($),
374 openpane: () => openSidePane($),
375 clearthread: () => clearThread($),
376 autowarm: () => pressAutoWarm($),
377 cstate: () => pressAutoWarmButton($),
378})
379
380// The Side question button's press: show or fold the Ask box under the Band's two lines.
381async function toggleSide($: EngineInterface) {
382 await update($, isSideOpen, (open) => !open)
383}
384
385// Open in pane: the existing Side questions pane, which draws the whole Thread.
386// A pane the engine rejects, or does not place, is left as it is, and the toast says why.
387async function openSidePane($: EngineInterface) {
388 try {
389 const r = await $.ui.open({ id: SIDE_PANE, title: 'Side questions', focus: true })
390 if (!r.isPlaced) $.ui.toast(`Could not open the side panel ${because(errorText(r.reason))}.`)
391 } catch (err) {
392 $.ui.toast(`Could not open the side panel ${because(errorText(err))}.`)
393 }
394}
395
396// Clear: empties the Thread. No confirmation: it holds side questions only, and the main chat is untouched.
397async function clearThread($: EngineInterface) {
398 await update($, side, (s) => ({ ...s, turns: [] }))
399}
400
401// Enter in the panel: one tool-less request over a copy of the main chat, earlier side turns included.
402// The main agent never sees it, and nothing is added to the main chat.
403async function askSide($: EngineInterface, text: string) {
404 const question = text.trim()
405 if (question === '') return
406 // Claimed inside one update, so two asks at once send one fork. The question went in last, so the turns
407 // before it are the ones the claim saw.
408 const ask = claim(SIDE, question)
409 const after = await update($, side, ask.change)
410 if (!ask.isTaken()) {
411 $.ui.toast('Still busy with the last side question.')
412 return
413 }
414 const at = after.turns.length - 1
415 const earlier = after.turns.slice(0, at)
416 let settled: SideTurn
417 try {
418 const r = await $.model.fork({ prompt: sidePrompt(earlier, question) })
419 settled = r.isAnswered
420 ? { question, state: 'answered', answer: r.text }
421 : { question, state: 'failed', failure: forkFailure(r) }
422 } catch (err) {
423 settled = { question, state: 'failed', failure: forkThrew(err) }
424 }
425 await update($, side, (s) => ({
426 ...SIDE.release(s),
427 turns: s.turns.map((t, i) => (i === at ? settled : t)),
428 }))
429}
430
431// A main-thread model request ended: it restarts the cache's clock.
432async function noteCache($: EngineInterface, sentAt: number, usage: ModelUsage) {
433 try {
434 const prefix = prefixOf(usage)
435 await update($, cache, (c) => cacheAfterReply(c, sentAt, prefix))
436 await advance($, sentAt)
437 } catch {
438 // The item keeps its last reading.
439 }
440}
441
442// Why a handoff turn ended without a copy, by how the turn ended.
443const TURN_ENDED: Record<EventIn<'turn.complete'>['reason'], string> = {
444 answer: 'the turn ended without calling save_handoff',
445 aborted: 'the turn was stopped',
446 refusal: 'the model refused the request',
447 error: 'the turn ended on an error',
448}
449
450// The handoff turn ended without a copy: free the button and say so.
451async function handoffTurnEnded($: EngineInterface, reason: EventIn<'turn.complete'>['reason']) {
452 if ((await read($, handoff)).phase !== 'writing') return
453 await update($, handoff, HANDOFF.release)
454 $.ui.toast(`The handoff was not copied (${TURN_ENDED[reason]}). Press Write handoff to try again.`)
455}
456
457// The save_handoff tool: copy the handoff to the clipboard, or say why not.
458async function saveHandoff($: EngineInterface, raw: unknown) {
459 const text = textOf(raw)
460 if (text === '') return fail('No handoff text arrived. Call save_handoff with the whole handoff as `text`.')
461
462 const tokens = estimateTokens(text)
463 if (tokens > CAP) {
464 return fail(
465 `Too long: about ${tokens} tokens, and the cap is ${CAP}. Cut about ${tokens - CAP} tokens and call save_handoff again.`,
466 )
467 }
468
469 const request = await read($, handoff)
470 const copied = await $.ui.copy(request.phase === 'idle' ? { text } : { text, surface: request.surface })
471 await update($, handoff, HANDOFF.release)
472 if (!copied.isCopied) {
473 const why = because(errorText(copied.reason))
474 $.ui.toast(`The handoff could not be copied ${why}.`)
475 return fail(
476 `The clipboard refused it ${why}. Tell the user, and put the handoff in your reply so they can copy it themselves.`,
477 )
478 }
479 $.ui.toast(`Handoff copied to your clipboard, about ${tokens} tokens.`)
480 return ok(`Copied to the user's clipboard, about ${tokens} tokens. Reply with one line saying so.`)
481}
482
483// Whether the session draws on the desktop app, which has its own side question: the band keeps none there.
484const isOnDesktop = async ($: EngineInterface) => isAppAttached(await $.session.surfaces())
485
486// Every value the band draws, and where it is drawn.
487type Band = {
488 figures: Line
489 isOpen: boolean
490 handoff: Handoff
491 isCompacting: boolean
492 cache: CacheSeen | null
493 auto: AutoWarm
494 t: number
495 surface: RenderSurface
496 bodyColumns: number
497 isSideOpen: boolean
498 batch: AgentRow[]
499}
500
501// A card under its parent, shown only while the pointer is over the parent. Unkeyed: the engine refused a keyed one.
502function hoverCard(els: ElementTable, body: JSX.Element[]) {
503 const { Box } = els
504 return (
505 <Box
506 flexDirection="column"
507 position="absolute"
508 top={1}
509 left={0}
510 display="none"
511 hover={{ display: 'flex' }}
512 borderStyle="round"
513 paddingX={1}
514 >
515 {body}
516 </Box>
517 )
518}
519
520// The terminal's card: one dim line on the item's own row, `left` cells past the item's start, which puts it a cell past the row's end
521// (cardLeft, fit.ts). Null `left`: the card would run past the band's edge, so it is not drawn. Unkeyed Box, as above.
522// A bordered box there was painted over the two rows below it.
523function lineCard(els: ElementTable, k: string, text: string, left: number | null) {
524 const { Box, Text } = els
525 if (left === null) return null
526 return (
527 <Box position="absolute" top={0} left={left} display="none" hover={{ display: 'flex' }}>
528 <Text key={k} color="inactive">
529 {text}
530 </Text>
531 </Box>
532 )
533}
534
535function sep(els: ElementTable, k: string) {
536 const { Text } = els
537 return (
538 <Text key={k} color="subtle">
539 {SEP_TEXT}
540 </Text>
541 )
542}
543
544function na(els: ElementTable, k: string, label: string) {
545 const { Text } = els
546 return (
547 <Text key={k} color="inactive">
548 {label}
549 </Text>
550 )
551}
552
553// A figure of the stats row in bold (or regular, for the branch), or a dim stand-in when it is missing.
554function figure(
555 els: ElementTable,
556 item: RowItem,
557 isMissing: boolean,
558 color: ReturnType<typeof level> | 'inactive',
559 isBold = true,
560) {
561 const { Text } = els
562 if (isMissing) return na(els, item.key, item.text)
563 return (
564 <Text key={item.key} color={color} bold={isBold}>
565 {item.text}
566 </Text>
567 )
568}
569
570const rateText = (label: string, used: number | null) =>
571 used === null ? `${label} —` : `${label} ${Math.round(used)}%`
572
573// The context figure: in full, or short (`ctx 13%`) when the line is tight.
574const ctxText = (f: Line, isShort: boolean) => {
575 if (f.ctxUsed === null || f.ctxPercent === null) return isShort ? 'ctx —' : `0/${short(f.ctxWindow)} ctx`
576 return isShort ? `ctx ${f.ctxPercent}%` : `${short(f.ctxUsed)}/${short(f.ctxWindow)} ctx (${f.ctxPercent}%)`
577}
578
579// The terminal lays out in whole cells, so it gets the text bar, as wide as the line allows.
580// Either way the bar sits in a box that grows, so it fills the row and Details lands at the right edge.
581// On the desktop, see-through boxes lie over the picture, one per piece, each holding a card
582// shown while the pointer is over it (as the cache item's is).
583function contextBar(els: ElementTable, b: Band) {
584 const { Box, Text } = els
585 const parts = b.figures.parts ?? []
586 const Svg = svgOf(b.surface, els)
587 if (Svg !== null) {
588 const pieces = barPieces(parts, b.figures.ctxWindow)
589 return (
590 <Box key="bar" flexDirection="column" flexGrow={1}>
591 <Svg key="svg" source={barSvg(pieces)} alt={`Context ${b.figures.ctxPercent ?? 0}% used`} height={BAR_H} />
592 <Box key="hover" position="absolute" top={0} bottom={0} left={0} right={0} flexDirection="row">
593 {pieces.map((p, i) => (
594 <Box key={`seg${i}`} flexGrow={p.grow}>
595 {hoverCard(els, [<Text key="label">{p.label}</Text>])}
596 </Box>
597 ))}
598 </Box>
599 </Box>
600 )
601 }
602 const cells = b.bodyColumns > 0 ? Math.max(10, b.bodyColumns - TEXT_BAR_RESERVE) : TEXT_BAR_CELLS
603 return (
604 <Box key="bar" flexDirection="row" flexGrow={1}>
605 {textBar(parts, b.figures.ctxWindow, cells).map((r, i) => (
606 <Text key={`run${i}`} color={r.color}>
607 {r.cells}
608 </Text>
609 ))}
610 </Box>
611 )
612}
613
614// Warm or cold; its card appears while the pointer is over the item.
615function cacheItem(
616 $: EngineInterface,
617 els: ElementTable,
618 seen: CacheSeen,
619 auto: AutoWarm,
620 t: number,
621 isTerminal: boolean,
622 row: StatsItems,
623) {
624 const { Box, Text, Button } = els
625 const view = cacheView(seen, t)
626 const when =
627 view.state === 'warm'
628 ? `Goes cold at ${eastern(view.coldAt)} (in ${view.minutesLeft} min)`
629 : `Went cold at ${eastern(view.coldAt)}`
630 const lines =
631 view.state === 'warm'
632 ? [
633 <Text key="cwhen">{when}</Text>,
634 ...(auto.isOn ? [<Text key="cauto">{`Auto warm on, off at ${eastern(auto.offAt)}`}</Text>] : []),
635 <Button
636 key="autowarm"
637 label={auto.isOn ? 'Turn auto warm off' : 'Turn auto warm on'}
638 dimColor
639 onPress={takenByPressHook}
640 />,
641 ]
642 : [
643 <Text key="cwhen">{when}</Text>,
644 <Text key="ccost">{`Next message re-stores ~${short(view.prefix)} tokens`}</Text>,
645 ]
646 // Inside the terminal's Button the text carries no key: the Button has it.
647 const stateText = (key?: string) => (
648 <Text key={key} color={view.state === 'warm' ? 'claude' : 'inactive'} bold={view.state === 'warm'}>
649 {`cache ${view.state}`}
650 </Text>
651 )
652 return (
653 <Box key="cache" flexDirection="row">
654 {isTerminal ? (
655 <Button key="cstate" plain label={`cache ${view.state}`} onPress={takenByPressHook}>
656 {stateText()}
657 </Button>
658 ) : (
659 stateText('cstate')
660 )}
661 {auto.isOn ? sep(els, 's5') : null}
662 {auto.isOn ? (
663 <Text key="cmark" color={view.state === 'warm' ? 'claude' : 'inactive'} bold={view.state === 'warm'}>
664 {AUTO_MARK}
665 </Text>
666 ) : null}
667 {isTerminal ? lineCard(els, 'cwhen', when, cardLeft(row, 'cache', when.length)) : hoverCard(els, lines)}
668 </Box>
669 )
670}
671
672function detailsList(els: ElementTable, f: Line) {
673 const { Box, Text } = els
674 return (
675 <Box key="list" flexDirection="column">
676 <Text key="head" color="inactive">
677 {f.isExact ? 'Exact count, by source:' : 'Estimate, by source:'}
678 </Text>
679 {(f.parts ?? []).map((p, i) => (
680 <Box key={`p${i}`} flexDirection="row">
681 <Text key="sw" color={PALETTE[i % PALETTE.length]}>
682 {'■ '}
683 </Text>
684 <Text key="nm">{p.name}</Text>
685 <Text key="tk" color="inactive">{` ${short(p.tokens)} ${pct(p.tokens, f.ctxWindow)}`}</Text>
686 </Box>
687 ))}
688 </Box>
689 )
690}
691
692// The buttons' labels now, which change while a job runs or the Ask box is open.
693// Desktop has its own side question, so there `side` is null and the row leaves the button out.
694const buttonLabels = (b: Band) => ({
695 tracer: 'Tracer',
696 side: b.surface === 'desktop' ? null : b.isSideOpen ? 'Hide side question' : 'Side question',
697 compact: b.isCompacting ? 'Compacting…' : 'Compact',
698 handoff: b.handoff.phase === 'idle' ? 'Write handoff' : 'Writing handoff…',
699})
700
701// Tracer, Side question (not on desktop), Compact and Write handoff.
702// A Button takes no layout props, so each sits in a box that never shrinks: a squeezed button clipped its label.
703function actions($: EngineInterface, els: ElementTable, b: Band) {
704 const { Box, Text, Button } = els
705 const label = buttonLabels(b)
706 return (
707 <Box key="buttons" flexDirection="row" flexShrink={0}>
708 <Box key="tracerbox" flexShrink={0}>
709 <Button key="tracer" label={label.tracer} dimColor onPress={takenByPressHook} />
710 </Box>
711 <Text key="tgap">{' '}</Text>
712 <Box key="actions" flexDirection="row" flexShrink={0}>
713 {label.side === null ? null : (
714 <Box key="sidebox" flexShrink={0}>
715 <Button key="side" label={label.side} dimColor onPress={takenByPressHook} />
716 </Box>
717 )}
718 {label.side === null ? null : <Text key="sgap">{' '}</Text>}
719 <Box key="compactbox" flexShrink={0}>
720 <Button key="compact" label={label.compact} dimColor onPress={takenByPressHook} />
721 </Box>
722 <Text key="agap">{' '}</Text>
723 <Box key="handoffbox" flexShrink={0}>
724 <Button key="handoff" label={label.handoff} dimColor onPress={takenByPressHook} />
725 </Box>
726 </Box>
727 </Box>
728 )
729}
730
731// The Cache item's text: `cache warm` or `cache cold`; Auto warm adds a separator and its mark. The terminal's press is the item's own text.
732const cacheText = (seen: CacheSeen, auto: AutoWarm, t: number) =>
733 `cache ${cacheView(seen, t).state}${auto.isOn ? SEP_TEXT + AUTO_MARK : ''}`
734
735// The text of each figure on the stats row, built once a render.
736function figuresOf(b: Band): Figures<CacheSeen> {
737 const f = b.figures
738 return {
739 branch: f.branch ?? 'no branch',
740 rate5: rateText('5h', f.rate5),
741 rate7: rateText('7d', f.rate7),
742 ctx: ctxText(f, false),
743 ctxShort: ctxText(f, true),
744 cache: b.cache === null ? null : { text: cacheText(b.cache, b.auto, b.t), seen: b.cache },
745 agents: `agents ${countAgents(b.batch).live}`,
746 agentsPad: buttonPad(b.surface),
747 }
748}
749
750// The Agent counter: `agents N`, N the Batch's Live agents, dim at zero. Drawn as a plain Button, the one pressable text.
751// Its card appears while the pointer is over the item (as the cache item's does).
752function agentsItem($: EngineInterface, els: ElementTable, b: Band, row: StatsItems) {
753 const { Box, Text, Button } = els
754 const { live } = countAgents(b.batch)
755 const card = agentsCard(b.batch)
756 return (
757 <Box key="agentsitem" flexDirection="row" flexShrink={0}>
758 <Button key="agents" label={`agents ${live}`} plain dimColor={live === 0} onPress={takenByPressHook} />
759 {b.surface === 'terminal'
760 ? lineCard(els, 'acard', card, cardLeft(row, 'agents', card.length))
761 : hoverCard(els, [<Text key="acard">{card}</Text>])}
762 </Box>
763 )
764}
765
766// The blank rows above the stats row and the bar row: none on the terminal, one elsewhere, where a native button stands taller than a line.
767const rowGap = (b: Band) => (b.surface === 'terminal' ? 0 : 1)
768
769// The key of the separator drawn before each figure. The branch, when drawn, is first, so it has none.
770const SEP_KEY: Record<Exclude<RowKey, 'br'>, string> = { r5: 's1', r7: 's2', ctx: 's3', cache: 's4', agents: 's6' }
771
772// One figure of the stats row (statsItems, fit.ts), with the separator before it unless it starts the row.
773function rowFigure(
774 $: EngineInterface,
775 els: ElementTable,
776 b: Band,
777 row: StatsItems<CacheSeen>,
778 item: RowItem<CacheSeen>,
779) {
780 const f = b.figures
781 const before = item.key === 'br' || item.start === 0 ? null : sep(els, SEP_KEY[item.key])
782 // The Cache item draws from the `seen` its row item carries (figuresOf).
783 const drawn =
784 item.key === 'cache'
785 ? cacheItem($, els, item.seen, b.auto, b.t, b.surface === 'terminal', row)
786 : {
787 br: () => figure(els, item, f.branch === null, 'inactive', false),
788 r5: () => figure(els, item, f.rate5 === null, level(f.rate5 ?? 0)),
789 r7: () => figure(els, item, f.rate7 === null, level(f.rate7 ?? 0)),
790 ctx: () => figure(els, item, f.ctxUsed === null || f.ctxPercent === null, level(f.ctxPercent ?? 0)),
791 agents: () => agentsItem($, els, b, row),
792 }[item.key]()
793 // Only drawn elements go in the row's list: no null among an array of children.
794 return [before, drawn].filter((e) => e !== null)
795}
796
797// Row 2: the stats alone, with the whole width. The buttons have the row above (buttonRow).
798// Off the terminal a native button stands taller than a line, so a row of margin keeps it off the stats text.
799// As the band narrows the figures drop out (fit.ts), so none wrap into a column.
800// The Agent counter sits beside the box that holds the other figures, not in it.
801function statsRow($: EngineInterface, els: ElementTable, b: Band) {
802 const { Box } = els
803 const row = statsItems(b.bodyColumns, figuresOf(b))
804 const drawn = (isAgents: boolean) =>
805 row.items.filter((i) => (i.key === 'agents') === isAgents).flatMap((i) => rowFigure($, els, b, row, i))
806 return (
807 <Box key="row2" flexDirection="row" alignItems="center" width="100%" marginTop={rowGap(b)}>
808 <Box key="left" flexDirection="row" flexShrink={1}>
809 <Box key="stats" flexDirection="row" flexShrink={1}>
810 {drawn(false)}
811 </Box>
812 {drawn(true)}
813 </Box>
814 </Box>
815 )
816}
817
818// Row 1, the band's first line: the buttons, pinned to the trailing edge.
819function buttonRow($: EngineInterface, els: ElementTable, b: Band) {
820 const { Box } = els
821 return (
822 <Box key="row1" flexDirection="row" justifyContent="flex-end" alignItems="center" width="100%">
823 {actions($, els, b)}
824 </Box>
825 )
826}
827
828// Row 3: the context bar and the Details button.
829// Off the terminal the Agent counter's button stands taller than a line, so it would touch Details.
830function barRow($: EngineInterface, els: ElementTable, b: Band) {
831 const { Box, Text, Button } = els
832 return (
833 <Box key="row3" flexDirection="row" alignItems="center" width="100%" marginTop={rowGap(b)}>
834 {contextBar(els, b)}
835 <Text key="gap">{' '}</Text>
836 <Button key="details" label={b.isOpen ? 'Hide details' : 'Show details'} dimColor onPress={takenByPressHook} />
837 </Box>
838 )
839}
840
841function sideReply(els: ElementTable, t: SideTurn, key: string, cut = (answer: string) => answer) {
842 const { Text, Markdown } = els
843 switch (t.state) {
844 case 'answered':
845 return <Markdown key={key} text={cut(t.answer)} />
846 case 'failed':
847 return (
848 <Text key={key} color="error">
849 {cut(t.failure)}
850 </Text>
851 )
852 case 'pending':
853 return (
854 <Text key={key} color="inactive">
855 Thinking…
856 </Text>
857 )
858 }
859}
860
861function sideThread(els: ElementTable, turns: Side['turns']) {
862 const { Box, Text } = els
863 return turns.map((t, i) => (
864 <Box key={`t${i}`} flexDirection="column">
865 {/* Yours in plain bold text, Claude's under its own colored label. */}
866 <Text key={`q${i}`} bold color="text">{`You: ${t.question}`}</Text>
867 <Text key={`c${i}`} bold color="claude">
868 Claude:
869 </Text>
870 {sideReply(els, t, `a${i}`)}
871 </Box>
872 ))
873}
874
875// Terminal and desktop get the drawn box (askbox.tsx), full width and wrapping.
876// VS Code has no drawn boxes, so it gets the engine's field with the whole draft shown under it.
877// The mobile app draws no field yet (inputOf in ../surface).
878const hasDrawnBox = (els: ElementTable, surface: RenderSurface) =>
879 (surface === 'terminal' || surface === 'desktop') && 'Client' in els && els.Client !== undefined
880
881/** What the Ask box needs from whoever draws it. */
882type AskSetup = {
883 s: Side
884 placeholder: string
885 /** Whether the surface holds the keyboard: the one sign the box gets that it lost it. */
886 isPaneFocused: boolean
887 /** Keyboard moves out of the Band so far. */
888 leaves: number
889 /** Whether the box sits beside its label in the Band, not in the pane. */
890 isInBand: boolean
891}
892
893// The drawn box. Beside its label in the Band it takes the rest of the row, and the Band's hint says
894// to click, so the box leaves its own click line out; in the panel it takes the whole width.
895function drawnBox(els: ElementTable, a: AskSetup) {
896 if (!('Client' in els) || els.Client === undefined) return null
897 const props = {
898 draft: a.s.draft,
899 seq: a.s.draftSeq,
900 placeholder: a.placeholder,
901 isPaneFocused: a.isPaneFocused,
902 leaves: a.leaves,
903 hasClickLine: !a.isInBand,
904 } satisfies AskBoxProps
905 const size = a.isInBand ? ({ flexGrow: 1 } as const) : ({ width: '100%' } as const)
906 return <els.Client key={ASK_BOX} {...size} module="./askbox.tsx" props={props} />
907}
908
909// The engine's own field, with the draft shown whole under it. Enter asks.
910function typingField($: EngineInterface, els: ElementTable, s: Side, placeholder: string) {
911 const { Box, Text } = els
912 if (!('Input' in els) || els.Input === undefined) return null
913 return (
914 <Box key="askfield" flexDirection="column">
915 <els.Input
916 key="ask"
917 value={s.draft}
918 placeholder={placeholder}
919 submitLabel="ask"
920 autoFocus
921 onInput={(v) => update($, side, (cur) => ({ ...cur, draft: v }))}
922 onSubmit={(v) => askSide($, v)}
923 />
924 {s.draft.trim() === '' ? null : (
925 <Box key="draftview">
926 <Text key="drafttext" dimColor wrap="wrap">
927 {s.draft}
928 </Text>
929 </Box>
930 )}
931 </Box>
932 )
933}
934
935/** The Ask box's placeholder: the first question, or a follow-up once the Thread has an Exchange. */
936const placeholderOf = (s: Side) => (s.turns.length === 0 ? 'Ask about this chat…' : 'Ask a follow-up…')
937
938const MOBILE_NOTICE = 'Type side questions in the desktop app or the terminal.'
939
940function askField($: EngineInterface, els: ElementTable, surface: RenderSurface, a: AskSetup) {
941 if (hasDrawnBox(els, surface)) return drawnBox(els, a)
942 if (inputOf(surface, els) !== null) return typingField($, els, a.s, a.placeholder)
943 return (
944 <els.Text key="ask" dimColor>
945 {MOBILE_NOTICE}
946 </els.Text>
947 )
948}
949
950/** The Ask box's label in the Band: `Side question` first, `Follow-up` once the Thread has an Exchange. */
951const labelOf = (s: Side) => (s.turns.length === 0 ? 'Side question' : 'Follow-up')
952
953// The cells the Ask box takes beside the draft: its label, the margin after it, its borders, padding and cursor.
954const askBoxCells = (s: Side) => labelOf(s).length + 6
955
956// The cells a label (`You`, `Claude`) takes before its text.
957const LABEL_CELLS = 7
958
959// One line of the Exchange: a label in a fixed-width cell, then its text.
960function exchangeLine(
961 els: ElementTable,
962 name: string,
963 label: { text: string; color: 'text' | 'claude' },
964 body: JSX.Element,
965) {
966 const { Box, Text } = els
967 return (
968 <Box key={`${name}line`} flexDirection="row">
969 <Box key={`${name}label`} width={LABEL_CELLS} flexShrink={0}>
970 <Text bold color={label.color}>
971 {label.text}
972 </Text>
973 </Box>
974 <Box key={`${name}text`} flexGrow={1}>
975 {body}
976 </Box>
977 </Box>
978 )
979}
980
981// The latest Exchange: `You` beside the question, `Claude` beside the answer.
982function sideExchange(els: ElementTable, t: SideTurn, rows: number, columns: number) {
983 const { Box, Text } = els
984 return (
985 <Box key="exchange" flexDirection="column">
986 {exchangeLine(
987 els,
988 'you',
989 { text: 'You', color: 'text' },
990 <Text wrap="wrap">{cutQuestion(t.question, columns)}</Text>,
991 )}
992 {exchangeLine(
993 els,
994 'claude',
995 { text: 'Claude', color: 'claude' },
996 sideReply(els, t, 'reply', (answer) => cutAnswer(answer, rows, columns)),
997 )}
998 </Box>
999 )
1000}
1001
1002// The Thread line above the latest Exchange: how many Exchanges are not shown, on the leading edge.
1003function threadLine($: EngineInterface, els: ElementTable, hidden: number) {
1004 const { Box, Text, Button } = els
1005 return (
1006 <Box key="threadline" flexDirection="row" justifyContent="space-between">
1007 <Box key="earlier">{hidden === 0 ? null : <Text color="inactive">{`Earlier ${hidden}`}</Text>}</Box>
1008 <Box key="threadactions" flexShrink={0}>
1009 <Button key="openpane" label="Open in pane" dimColor onPress={takenByPressHook} />
1010 <Text key="clearsep">{' '}</Text>
1011 <Button key="clearthread" label="Clear" dimColor onPress={takenByPressHook} />
1012 </Box>
1013 </Box>
1014 )
1015}
1016
1017// The Band's Side question rows, under its two lines: a blank row, the latest Exchange once there is one,
1018// the Ask box with its label (`Follow-up` after the first Exchange), and the hint before the first.
1019// Mobile has no box to label or click, so it gets the blank row and its one-line notice.
1020function sideRows(
1021 $: EngineInterface,
1022 els: ElementTable,
1023 surface: RenderSurface,
1024 s: Side,
1025 leaves: number,
1026 rows: number,
1027 columns: number,
1028) {
1029 const { Box, Text } = els
1030 const field = askField($, els, surface, {
1031 s,
1032 placeholder: placeholderOf(s),
1033 isPaneFocused: true,
1034 leaves,
1035 isInBand: true,
1036 })
1037 const hasBox = hasDrawnBox(els, surface) || inputOf(surface, els) !== null
1038 // Mobile has no box, so a saved Thread draws nothing there.
1039 const latest = hasBox ? s.turns.at(-1) : undefined
1040 return (
1041 <Box key="siderows" flexDirection="column">
1042 {/* Drawn text loses its key, so each line sits in a keyed Box. */}
1043 <Box key="sidegap">
1044 <Text> </Text>
1045 </Box>
1046 {latest === undefined ? null : threadLine($, els, s.turns.length - 1)}
1047 {latest === undefined ? null : sideExchange(els, latest, rows, columns)}
1048 {hasBox ? (
1049 <Box key="askrow" flexDirection="row">
1050 <Box key="sidelabel" flexShrink={0} marginRight={1}>
1051 <Text>{labelOf(s)}</Text>
1052 </Box>
1053 {field}
1054 </Box>
1055 ) : (
1056 field
1057 )}
1058 {hasBox && latest === undefined ? (
1059 <Box key="sidehint" flexDirection="row">
1060 <Text key="h1" color="inactive">
1061 The main agent won't see this.
1062 </Text>
1063 {sep(els, 'h2')}
1064 <Text key="h3" color="inactive">
1065 Click to type
1066 </Text>
1067 {sep(els, 'h4')}
1068 <Text key="h5" color="inactive">
1069 Enter asks
1070 </Text>
1071 </Box>
1072 ) : null}
1073 </Box>
1074 )
1075}
1076
1077// Hooks that gate an event end in .catch(... next(e)): if one throws, the event goes on as if
1078// this plugin were absent, and a next() it already made is replayed, never run twice.
1079
1080// The five handlers below are the shared events, registered in `register` behind the empty matcher (see the entry file).
1081
1082export async function sessionStart(
1083 $: EngineInterface,
1084 e: EventIn<'session.start'>,
1085 next: PlainNext<'session.start'>,
1086): Promise<EventOut<'session.start'>> {
1087 // The band's last Keep-warm ping; a reload forgets it, which at worst allows one early ping.
1088 let ping = NO_PING
1089 const out = await next(e)
1090 // A load or reload: the last load's waits were cancelled with it, so a busy flag they would have freed is freed here.
1091 await update($, isCompacting, COMPACT.atLoad)
1092 await update($, side, SIDE.atLoad)
1093 await update($, handoff, HANDOFF.atLoad)
1094 await $.tool.register(TOOL_SPEC)
1095 // Every TICK_MS, so the cache item's minutes count down.
1096 $.clock.every(TICK_MS, () => {
1097 void $.clock.now().then(async (t) => {
1098 await advance($, t)
1099 if (!(await autoWarmTick($, t, ping))) return
1100 ping = { at: t, isWaiting: true }
1101 if (!(await sendPing($))) ping = { ...ping, isWaiting: false }
1102 })
1103 })
1104 await advance($, await $.clock.now())
1105 await refresh($)
1106 return out
1107}
1108
1109// Any prompt but the band's own ping is the user's (or another mod's) next message: Auto warm is done.
1110export async function promptSubmit(
1111 $: EngineInterface,
1112 e: EventIn<'prompt.submit'>,
1113 next: PlainNext<'prompt.submit'>,
1114): Promise<EventOut<'prompt.submit'>> {
1115 if (promptEndsAutoWarm(e.text, await readAuto($))) await update($, autoWarm, () => NO_AUTO_WARM)
1116 return next(e)
1117}
1118
1119// Pushed by the engine after each turn and when a rate limit moves a point.
1120export async function sessionMeasure(
1121 $: EngineInterface,
1122 e: EventIn<'session.measure'>,
1123 next: PlainNext<'session.measure'>,
1124): Promise<EventOut<'session.measure'>> {
1125 await refresh($, { figures: e })
1126 return next(e)
1127}
1128
1129export async function* turnStep(
1130 $: EngineInterface,
1131 e: EventIn<'turn.step'>,
1132 next: StreamingNext<'turn.step'>,
1133): AsyncGenerator<Chunk<'turn.step'>, EventOut<'turn.step'>> {
1134 // The cache's lifetime runs from when the request is sent, so the time is taken before it goes.
1135 // A subagent's request leaves the main thread's cache alone.
1136 const sentAt = e.agentId === undefined ? await $.clock.now() : null
1137 const r = yield* next(e)
1138 if (r.usage !== null && sentAt !== null) await noteCache($, sentAt, r.usage)
1139 return r
1140}
1141
1142export async function turnComplete(
1143 $: EngineInterface,
1144 e: EventIn<'turn.complete'>,
1145 next: PlainNext<'turn.complete'>,
1146): Promise<EventOut<'turn.complete'>> {
1147 if (e.agentId === undefined) await handoffTurnEnded($, e.reason)
1148 return next(e)
1149}
1150
1151function stopHooks(on: On) {
1152 // The main turn's end: the transcript now holds its cache writes, each split by lifetime.
1153 on('classic.Stop', async ($, e, next) => {
1154 const r = await next(e)
1155 await learnTtl($, e.transcript_path)
1156 return r
1157 }).catch(($, e, next) => next(e))
1158}
1159
1160function handoffHooks(on: On) {
1161 // The handoff tool only copies text, so it runs without a permission prompt.
1162 on('tool.check', { tool: TOOL }, () => ({
1163 decision: 'allow' as const,
1164 reason: 'desk: save_handoff only copies text to the clipboard.',
1165 })).catch(($, e, next) => next(e))
1166
1167 on('tool.call', { tool: TOOL }, ($, e) => saveHandoff($, e.text)).catch(() =>
1168 fail('save_handoff hit an error. Put the handoff in your reply so the user can copy it themselves.'),
1169 )
1170
1171 on('turn.start', async ($, e, next) => {
1172 if (e.text.startsWith(FIRST_LINE)) {
1173 // The turn's id is kept with the phase, so a press after a reload can still end this turn.
1174 await update($, handoff, (h): Handoff =>
1175 h.phase === 'queued' ? { phase: 'writing', surface: h.surface, turnId: e.turnId } : h,
1176 )
1177 }
1178 return next(e)
1179 })
1180}
1181
1182function pressHooks(on: On) {
1183 // Every band Button press comes here; a key that is none of the band's goes on beneath.
1184 on('ui.press', {}, async ($, e, next) => {
1185 const name = isOwnPress(e) ? bandButtonOf(e.element) : null
1186 if (name === null) return next(e)
1187 // An action that does not report its own failure (a state write, the usage read) is caught here, so the press
1188 // is never silently dropped to the do-nothing closure beneath the hook.
1189 try {
1190 await bandActions($, e.surface)[name]()
1191 } catch (err) {
1192 $.ui.toast(failToast(BAND_DOING[name], errorText(err)))
1193 }
1194 return { element: e.element }
1195 }).catch(($, e, next) => next(e))
1196}
1197
1198function bandHooks(on: On) {
1199 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1200 // Every value the band draws, read at once.hooks/board/register.tsx 1487 lines1// The agent board: a side panel with one card per subagent of the current batch.
2// It opens by itself when a batch starts (an agent spawns while none runs); /agent-board reopens it.
3// Top: three cards with the batch's agent count, tokens used in all, and time from first start to last end.
4// Then Live and Finished sections. Each card: a pixel critter whose hat shows the effort level, the task's
5// name, agent type, effort level and model, the agent's to-do progress when it keeps a list, its context share, tokens and
6// time, and a bar. A click on the name shows the brief, the token detail, the agent's Transcript,
7// a box to message it, and a Stop button while it runs.
8// Hooks the board shares with another area (session.start, session.measure, turn.step, turn.complete, agent.spawn)
9// are exported as handlers, and ../register.tsx registers them; `register` below holds the rest.
10import { atom, read, update } from 'claude-code'
11import type { Chunk, ElementTable, EngineInterface, On, RenderSurface } from 'claude-code'
12
13import {
14 isOwnPress,
15 takenByPressHook,
16 type EventIn,
17 type EventOut,
18 type PlainNext,
19 type StreamingNext,
20} from '../handler'
21import { because, errorText, failToast } from '../failures'
22import { isLive } from '../live'
23import { inputOf, svgOf } from '../surface'
24import type { AgentReport, AgentRow, CardSections, Chat, TranscriptLine, SentLine } from '../../types'
25import {
26 BAR_KEY,
27 COMMAND,
28 type SectionName,
29 MARK,
30 NUDGE,
31 PANE,
32 PART_SEP,
33 TOOL,
34 TOOL_SPEC,
35 TITLE,
36 barKindOf,
37 cardLine,
38 clockText,
39 fail,
40 ok,
41 prettyModel,
42 refusalNote,
43 progress,
44 short,
45 statParts,
46 stepsFromReport,
47 stepsFromTodos,
48 type Tier,
49 tierOf,
50 tokenParts,
51 tokensLine,
52 totals,
53} from './agents'
54import {
55 type Fact,
56 apply,
57 highestCounter,
58 newCard,
59 nextNumber,
60 noted,
61 orphanIds,
62 pruned,
63 reconcile,
64 startBatch,
65 whereTo,
66 waitingFor,
67 withEarly,
68 withoutApplied,
69} from './book'
70import { FILL_FAILED, FILL_RAISED } from './colors'
71import { BAR_H, CRITTER_H, CRITTER_W, barSvg, critterSvg, hatColor } from './critter'
72import {
73 type MetaParts,
74 type TitleParts,
75 cutMiddle,
76 headerCells,
77 metaParts,
78 numsRow,
79 partRows,
80 targetRoom,
81 titleParts,
82} from './header'
83import { BOARD_DOING, boardKey, boardPressOf, type Pressed } from './keys'
84import {
85 BRIEF_CHARS,
86 DIVIDER_H,
87 briefShown,
88 isThoughtOpen,
89 dividerSvg,
90 isCallOpen,
91 ruleLabel,
92 ruleTail,
93 sectionsOf,
94 toggled,
95} from './sections'
96import {
97 BLANK_ICON,
98 type Dot,
99 GUTTER_W,
100 ICON_SIZE,
101 ROW_H,
102 capped,
103 childOf,
104 dotSvg,
105 elbowSvg,
106 entriesOf,
107 firstShown,
108 isHung,
109 isThinkingMissing,
110 lineSvg,
111 newChat,
112 openedSource,
113 thoughtsOf,
114 toolIcon,
115 wrappedRows,
116} from './transcript'
117
118// The validator reads state only through atoms declared in this file.
119const agents = atom({ plugin: 'desk', key: 'agents' } as const, [] as AgentRow[])
120const early = atom({ plugin: 'desk', key: 'early' } as const, { byAgent: {}, next: 0 })
121const openAgent = atom({ plugin: 'desk', key: 'openAgent' } as const, null)
122const now = atom({ plugin: 'desk', key: 'boardNow' } as const, 0)
123const effort = atom({ plugin: 'desk', key: 'effort' } as const, null)
124const contextWindow = atom({ plugin: 'desk', key: 'window' } as const, null as number | null)
125const isFinishedShown = atom({ plugin: 'desk', key: 'isFinishedShown' } as const, true)
126const chat = atom({ plugin: 'desk', key: 'chat' } as const, null as Chat | null)
127const sectionLog = atom({ plugin: 'desk', key: 'sections' } as const, {} as Record<string, CardSections>)
128const sentLog = atom({ plugin: 'desk', key: 'sent' } as const, {} as Record<string, SentLine[]>)
129
130/** A saved Chat with its defaults filled: one saved by an older board may lack a count or a mark. */
131function chatOf(c: Chat | null): Chat | null {
132 if (c === null) return null
133 const saved: Partial<Chat> = c
134 return {
135 ...c,
136 dropped: saved.dropped ?? 0,
137 isUnread: saved.isUnread ?? false,
138 isThinkingMissing: saved.isThinkingMissing ?? false,
139 sent: saved.sent ?? 0,
140 }
141}
142
143// The one clock the cards count from. It only moves forward.
144function advance($: EngineInterface, t: number) {
145 return update($, now, (n) => Math.max(n, t))
146}
147
148// Each second: while any agent runs, move the clock and fold in the engine's agent list. The open card reads
149// its agent's messages every second either way, as one opened in the app's own window can still be talked to.
150async function tick($: EngineInterface) {
151 const rows = await read($, agents)
152 if (rows.some(isLive)) {
153 const t = await $.clock.now()
154 // No list this second: the rows keep their state and the clock still moves.
155 const infos = await $.agent.list().catch(() => null)
156 if (infos !== null) await update($, agents, (list) => reconcile(list, infos, t))
157 await advance($, t)
158 }
159 const shown = await read($, openAgent)
160 const agentId = rows.find((r) => r.id === shown)?.agentId ?? null
161 if (agentId === null) return
162 // A card opened while Starting has no Chat; the spawn's answer gave it an agent, so its Transcript starts now.
163 if ((await read($, chat))?.agentId !== agentId)
164 await update($, chat, (c) => (c?.agentId === agentId ? c : newChat(agentId)))
165 await loadChat($, agentId)
166}
167
168/** Reads the open card's agent's conversation into the chat view as Transcript entries, if that card is still the open one. */
169async function loadChat($: EngineInterface, agentId: string) {
170 const [found, api, allSent, cwd, rows] = await Promise.all([
171 $.session.messages({ agentId }).catch(() => null),
172 $.session.messages({ agentId, as: 'api' }).catch(() => null),
173 read($, sentLog),
174 $.session.cwd().catch(() => null),
175 read($, agents),
176 ])
177 const brief = rows.find((r) => r.agentId === agentId)?.task ?? null
178 const sentHere = allSent[agentId] ?? []
179 const isUnread = !Array.isArray(found)
180 // Unread and nothing sent from here: nothing to show. Unread with sends: just the user's own lines.
181 const thinks = rows.find((r) => r.agentId === agentId)?.effort != null
182 const thoughts = Array.isArray(found) && Array.isArray(api) ? thoughtsOf(found, api) : undefined
183 const held =
184 isUnread && sentHere.length === 0 ? null : capped(entriesOf(isUnread ? [] : found, sentHere, cwd, brief, thoughts))
185 await update($, chat, (c) =>
186 c?.agentId === agentId
187 ? {
188 ...c,
189 lines: held?.lines ?? null,
190 dropped: held?.dropped ?? 0,
191 isUnread,
192 isThinkingMissing: isThinkingMissing(api, thinks),
193 }
194 : c,
195 )
196}
197
198// Whether the board had the keyboard at its last drawing. The desktop app spends a first click on an
199// unfocused board on focusing it and sends the button no press, so the focus hook presses it instead.
200let isBoardFocused = false
201
202/** Runs a card-bound button: the card's name, an Agent call's row (the child's card), or the card's Stop. The card
203 * is read now, not when the button was drawn. */
204async function pressOnCard($: EngineInterface, id: string, isStop: boolean) {
205 const r = (await read($, agents)).find((x) => x.id === id)
206 if (r === undefined) return
207 if (!isStop) return toggleCard($, r)
208 if (r.agentId !== null && isLive(r)) return stopAgent($, r.agentId)
209}
210
211/** The board's one press table: what the button drawn with `key` does. The board's `ui.press` hook and a first click on
212 * the unfocused board, which arrives only as focus landing on the key, both run it. A press the engine refuses
213 * (every action reads or writes saved state, so any can) is told to the user: the press hook's catch would
214 * otherwise hand it on to a do-nothing closure. */
215async function press($: EngineInterface, key: string | undefined) {
216 const p = boardPressOf(key)
217 if (p === null) return
218 await pressed($, p).catch((err: unknown) => {
219 $.ui.toast(failToast(BOARD_DOING[p.kind], errorText(err)))
220 })
221}
222
223async function pressed($: EngineInterface, p: Pressed) {
224 switch (p.kind) {
225 case 'finished':
226 return update($, isFinishedShown, (v) => !v)
227 case 'name':
228 case 'child':
229 case 'stop':
230 return pressOnCard($, p.id, p.kind === 'stop')
231 default:
232 return update($, sectionLog, (saved) => toggled(saved, p))
233 }
234}
235
236/** Opens a card, or closes it when it is the open one. Opening reads its agent's last messages. */
237async function toggleCard($: EngineInterface, r: AgentRow) {
238 let isOpening = false
239 await update($, openAgent, (cur) => {
240 isOpening = cur !== r.id
241 return isOpening ? r.id : null
242 })
243 const fresh: Chat | null = isOpening && r.agentId !== null ? newChat(r.agentId) : null
244 await update($, chat, () => fresh)
245 if (fresh !== null) await loadChat($, fresh.agentId)
246}
247
248function noteChat($: EngineInterface, agentId: string, note: string, isSent = false) {
249 return update($, chat, (saved) => {
250 const c = chatOf(saved)
251 return c?.agentId === agentId ? { ...c, note, sent: c.sent + (isSent ? 1 : 0) } : saved
252 })
253}
254
255/** A call that threw reads as a refusal with the error as its reason. */
256const thrownAsDeny = (err: unknown) => ({ deny: errorText(err) })
257
258/** Sends the user's message to the agent. A running agent gets it as a plain user message in its conversation,
259 * as the built-in agent window's messages read; a finished one (or a refused append) gets it delivered, which
260 * wakes it, so its card runs again. */
261async function sendTo($: EngineInterface, agentId: string, isRunning: boolean, typed: string) {
262 const text = typed.trim()
263 if (text === '') return
264 // Where it goes among the agent's messages, counted before it is sent.
265 const before = await $.session.messages({ agentId }).catch(() => null)
266 const line: SentLine = { text, after: Array.isArray(before) ? before.length : 0 }
267 const remember = () => update($, sentLog, (all) => ({ ...all, [agentId]: [...(all[agentId] ?? []), line] }))
268 if (isRunning) {
269 const added = await $.session
270 .append({ agentId, message: { type: 'user', content: [{ type: 'text', text }] } })
271 .catch(thrownAsDeny)
272 if (added.deny === undefined) {
273 await remember()
274 await noteChat($, agentId, 'Added to its conversation. It reads it at its next step.', true)
275 return loadChat($, agentId)
276 }
277 }
278 const delivery = await $.session.send({ to: { agentId }, text }).catch((err: unknown) => ({
279 isDelivered: false as const,
280 reason: errorText(err),
281 }))
282 if (!delivery.isDelivered) return noteChat($, agentId, refusalNote('SendMessage', delivery.reason))
283 await remember()
284 await noteChat($, agentId, 'Sent. It reads it at its next step.', true)
285 await changeCard($, agentId, { kind: 'resumed' }, await $.clock.now())
286 await loadChat($, agentId)
287}
288
289/** Stops the agent through Claude's own stop tool. The call goes through the user's permission settings as is. */
290async function stopAgent($: EngineInterface, agentId: string) {
291 await noteChat($, agentId, 'Stopping…')
292 const out = await $.tool.call({ tool: 'TaskStop', task_id: agentId }).catch(thrownAsDeny)
293 const refused = 'deny' in out ? out.deny : out.isError === true ? out.text : undefined
294 if (refused !== undefined) return noteChat($, agentId, refusalNote('TaskStop', refused))
295 const t = await $.clock.now()
296 await changeCard($, agentId, { kind: 'stopped' }, t)
297 await noteChat($, agentId, 'Stopped.')
298}
299
300/** Applies a fact to the card linked to `agentId`. */
301function changeCard($: EngineInterface, agentId: string, fact: Fact, t: number) {
302 return update($, agents, (list) => apply(list, { agentId }, fact, t))
303}
304
305/** Opens the board, or says why it could not. */
306async function openBoard($: EngineInterface): Promise<string> {
307 const r = await $.ui.open({ id: PANE, title: TITLE })
308 return r.isPlaced ? 'Agent board opened.' : `Could not open the agent board ${because(errorText(r.reason))}.`
309}
310
311/** Takes out of the early store the reports their cards have applied. It runs only after the card is saved, so a
312 * reload between the two saves loses no report: the card keeps it, and the next take skips it. */
313async function dropApplied($: EngineInterface) {
314 const rows = await read($, agents)
315 await update($, early, (store) => withoutApplied(store, rows))
316}
317
318/** Applies the reports still waiting for `agentId`'s card to it, then takes them out of the early store. */
319async function catchUp($: EngineInterface, agentId: string) {
320 const reports = waitingFor(await read($, early), agentId)
321 if (reports.length === 0) return
322 await update($, agents, (list) => withEarly(list, agentId, reports))
323 await dropApplied($)
324}
325
326/** Drops the early reports no card can take any more. Only reports from before the cards were read can go. */
327async function prune($: EngineInterface) {
328 const before = nextNumber(await read($, early))
329 const rows = await read($, agents)
330 await update($, early, (store) => pruned(store, rows, before))
331}
332
333/** Records what an agent reported: on its card once its spawn has answered, else in the early store, with its time. */
334async function tell($: EngineInterface, agentId: string, report: AgentReport) {
335 const t = await $.clock.now()
336 // Held in an object: TypeScript would read a plain variable as still 'nowhere', not seeing the write inside update.
337 const found: { where: ReturnType<typeof whereTo> } = { where: 'nowhere' }
338 await update($, agents, (list) => {
339 found.where = whereTo(list, agentId)
340 return found.where === 'card' ? apply(list, { agentId }, report, t) : list
341 })
342 if (found.where !== 'early') return
343 await update($, early, (store) => noted(store, agentId, report, t))
344 // Its spawn may have answered while the report was being saved; then the card takes it now.
345 if ((await read($, agents)).some((r) => r.agentId === agentId)) await catchUp($, agentId)
346}
347
348// A model request's start says the model and effort it runs on. The main thread's
349// effort is the session's; a subagent's own requests replace the guess on its card.
350async function noteStep($: EngineInterface, agentId: string | undefined, model: string, lvl: string | null) {
351 if (agentId !== undefined) return tell($, agentId, { kind: 'step', model, effort: lvl })
352 if (lvl !== null && lvl !== (await read($, effort))) await update($, effort, () => lvl)
353}
354
355/** The new task's id in a TaskCreate result, read with care: the result's shape is the tool's. */
356function createdId(result: unknown): string | null {
357 if (typeof result !== 'object' || result === null) return null
358 const task = (result as { task?: { id?: unknown } }).task
359 return typeof task?.id === 'string' ? task.id : null
360}
361
362// Every value the board draws, and where it is drawn.
363type Board = {
364 rows: readonly AgentRow[]
365 shown: string | null
366 chat: Chat | null
367 sections: Record<string, CardSections>
368 t: number
369 window: number | null
370 isFinishedShown: boolean
371 surface: RenderSurface
372 bodyColumns: number
373}
374
375function statCard(els: ElementTable, k: string, label: string, value: string) {
376 const { Box, Text } = els
377 return (
378 <Box key={k} flexDirection="column" flexGrow={1} borderStyle="round" paddingX={1}>
379 <Text key="label" color="inactive">
380 {label}
381 </Text>
382 <Text key="value" bold>
383 {value}
384 </Text>
385 </Box>
386 )
387}
388
389function statCards(els: ElementTable, b: Board) {
390 const { Box } = els
391 const sum = totals(b.rows, b.t)
392 return (
393 <Box key="cards" flexDirection="row" gap={1}>
394 {statCard(els, 'c-agents', 'Agents', `${sum.count}`)}
395 {statCard(els, 'c-tokens', 'Tokens', short(sum.tokens))}
396 {statCard(els, 'c-time', 'Time', clockText(sum.spanMs))}
397 </Box>
398 )
399}
400
401// The critter, off the terminal only: the terminal draws no pictures.
402function critter(els: ElementTable, b: Board, r: AgentRow, tier: Tier) {
403 const Svg = svgOf(b.surface, els)
404 if (Svg === null) return null
405 const isFaded = r.state === 'failed' || r.state === 'stopped' || r.state === 'lost'
406 return (
407 <Svg
408 key="pic"
409 source={critterSvg(tier.hat, isFaded)}
410 alt={`${tier.label} critter`}
411 width={CRITTER_W}
412 height={CRITTER_H}
413 />
414 )
415}
416
417// How many cells a Card's content has: its width less the four its border and padding take (two a side).
418const insideCard = (b: Board) => Math.max(10, b.bodyColumns - 4)
419
420// The cells a message's timeline takes beside its text: on desktop the 16px gutter (about two) and the gap; in the
421// terminal a `│` and the gap.
422const GUTTER_COLUMNS = 3
423const BAR_COLUMNS = 2
424
425// How many rows a message's text takes beside its timeline, by wrapping its words at the cells left of it.
426const textRows = (b: Board, text: string) =>
427 wrappedRows(text, insideCard(b) - (b.surface === 'terminal' ? BAR_COLUMNS : GUTTER_COLUMNS))
428
429// The progress bar: a picture off the terminal, a run of line cells on it. None while progress is unknown.
430function bar(els: ElementTable, b: Board, r: AgentRow) {
431 const { Box, Text } = els
432 const f = progress(r)
433 if (f === null) return null
434 const kind = barKindOf(r.state)
435 const Svg = svgOf(b.surface, els)
436 if (Svg !== null) {
437 const alt = r.state === 'done' ? 'Finished' : `${Math.round(f * 100)}% of its to-do list done`
438 return (
439 <Box key="bar" flexDirection="column" marginTop={1}>
440 <Svg key="barsvg" source={barSvg(f, kind)} alt={alt} height={BAR_H} />
441 </Box>
442 )
443 }
444 const cells = insideCard(b)
445 const full = Math.round(f * cells)
446 return (
447 <Box key="bar" flexDirection="row">
448 <Text key="on" color={BAR_KEY[kind]}>
449 {'━'.repeat(full)}
450 </Text>
451 <Text key="off" color="subtle">
452 {'─'.repeat(cells - full)}
453 </Text>
454 </Box>
455 )
456}
457
458// The terminal's Card edge takes no padding, so its rules meet the edge. What sits between the rules takes the
459// padding itself; on desktop the Card's own padding does.
460const insetOf = (b: Board) => (b.surface === 'terminal' ? ({ paddingX: 1 } as const) : ({} as const))
461
462// How many cells a terminal rule fills: the Card's width less its two edge characters.
463const ruleCells = (b: Board) => Math.max(10, b.bodyColumns - 2)
464
465// The line that opens the footer: a picture off the terminal; on it a bare rule from edge to edge.
466function footerRule(els: ElementTable, b: Board) {
467 const { Box, Text } = els
468 const Svg = svgOf(b.surface, els)
469 if (Svg !== null) {
470 return (
471 <Box key="divider:footer" flexDirection="column" marginTop={1}>
472 <Svg key="line" source={dividerSvg()} alt="Divider" height={DIVIDER_H} />
473 </Box>
474 )
475 }
476 return (
477 <Box key="divider:footer" flexDirection="row" marginTop={1}>
478 <Text key="start" color="subtle">
479 {`├${ruleTail(ruleCells(b), '')}┤`}
480 </Text>
481 </Box>
482 )
483}
484
485// The line that opens a closable section, with its heading: a chevron that closes it, the name in bold, and any
486// count after it. Off the terminal that is a divider picture over the heading; in the terminal a titled rule
487// from edge to edge, `├─ ▾ Brief ───┤`, the dimmest grey but for the bold title.
488function sectionHead(
489 $: EngineInterface,
490 els: ElementTable,
491 b: Board,
492 r: AgentRow,
493 which: SectionName,
494 title: string,
495 count: number | null = null,
496) {
497 const { Box, Text, Button } = els
498 const isOpen =
499 which === 'brief' ? sectionsOf(b.sections, r.id).isBriefOpen : sectionsOf(b.sections, r.id).isTranscriptOpen
500 const key = boardKey({ kind: 'section', id: r.id, which })
501 const chevron = <Button key={key} plain dimColor label={isOpen ? '▾' : '▸'} onPress={takenByPressHook} />
502 const Svg = svgOf(b.surface, els)
503 if (Svg !== null) {
504 return [
505 <Box key={`divider:${which}`} flexDirection="column" marginTop={1}>
506 <Svg key="line" source={dividerSvg()} alt="Divider" height={DIVIDER_H} />
507 </Box>,
508 <Box key={`heading:${which}`} flexDirection="row" gap={1} marginTop={1}>
509 {chevron}
510 <Text key="title" bold>
511 {title}
512 </Text>
513 {count === null ? null : (
514 <Text key="count" color="inactive">
515 {`${count}`}
516 </Text>
517 )}
518 </Box>,
519 ]
520 }
521 const label = ruleLabel(title, count)
522 return [
523 <Box key={`divider:${which}`} flexDirection="row" marginTop={1}>
524 <Text key="start" color="subtle">
525 ├─{' '}
526 </Text>
527 {chevron}
528 <Text key="head" bold>
529 {label.slice(2)}
530 </Text>
531 <Text key="end" color="subtle">
532 {`${ruleTail(ruleCells(b), label)}┤`}
533 </Text>
534 </Box>,
535 ]
536}
537
538// Under an opened card's header: the Brief section, its divider reaching the Card's edges.
539function briefSection($: EngineInterface, els: ElementTable, b: Board, r: AgentRow) {
540 const { Box, Text, Button } = els
541 const sections = sectionsOf(b.sections, r.id)
542 const more = boardKey({ kind: 'more', id: r.id })
543 return [
544 ...sectionHead($, els, b, r, 'brief', 'Brief'),
545 sections.isBriefOpen ? (
546 <Box key="brief-body" flexDirection="column" {...insetOf(b)}>
547 <Text key="brief" color="inactive" wrap="wrap">
548 {briefShown(r.task, sections.isBriefWhole)}
549 </Text>
550 {r.task.length > BRIEF_CHARS ? (
551 <Button
552 key={more}
553 plain
554 dimColor
555 label={sections.isBriefWhole ? 'Show less' : 'Show whole brief'}
556 onPress={takenByPressHook}
557 />
558 ) : null}
559 </Box>
560 ) : null,
561 ]
562}
563
564// Where one entry sits among the rows drawn: the entries, the first one drawn, and whether the agent runs (a waiting one writes nothing).
565type Flow = { card: string; lines: readonly TranscriptLine[]; first: number; isRunning: boolean }
566
567// The start of a row that hangs off the timeline, a Tool call or a Thought: the elbow (in the terminal `├─`, or `└─`
568// for the last of its run), the icon of the tool of that name and the name.
569function rowLead(els: ElementTable, b: Board, flow: Flow, i: number, name: string, isLive = false) {
570 const { Text } = els
571 const Svg = svgOf(b.surface, els)
572 const picture = toolIcon(name)
573 return [
574 Svg === null ? (
575 <Text key="mark" color="subtle">
576 {isHung(flow.lines[i + 1]) ? '├─' : '└─'}
577 </Text>
578 ) : (
579 <Svg key="elbow" source={elbowSvg(i < flow.lines.length - 1)} alt="Tool call" width={GUTTER_W} height={ROW_H} />
580 ),
581 Svg === null ? null : (
582 <Svg
583 key="icon"
584 source={(picture ?? BLANK_ICON).source}
585 alt={(picture ?? BLANK_ICON).alt}
586 width={ICON_SIZE}
587 height={ICON_SIZE}
588 />
589 ),
590 isLive ? (
591 <Text key="name" color="claude" bold>
592 {name}
593 </Text>
594 ) : (
595 <Text key="name">{name}</Text>
596 ),
597 ]
598}
599
600/** What ends a Tool call's row after its target: `✗ Failed`, the child's state mark, or what came back; null where nothing does. */
601function tailOf(
602 l: Extract<TranscriptLine, { kind: 'tool' }>,
603 child: AgentRow | null,
604 isLive: boolean,
605): { text: string; color: string; isBold: boolean } | null {
606 if (l.isFailed) return { text: '✗ Failed', color: 'error', isBold: true }
607 if (isLive) return null
608 if (child !== null) {
609 const mark = MARK[child.state]
610 return { text: `${mark.glyph} ${mark.word}`, color: mark.color, isBold: false }
611 }
612 return l.summary ? { text: l.summary, color: 'inactive', isBold: false } : null
613}
614
615/** The cells a Tool call's target may take in its row, from the Card's inside. */
616const roomOf = (b: Board, name: string, tail: ReturnType<typeof tailOf>, hasChevron: boolean): number =>
617 targetRoom(insideCard(b), b.surface === 'terminal', name, tail === null ? null : tail.text, hasChevron)
618
619/** How a Tool call's row ends: whose Card an Agent call opens, whether the call is in flight, its tail, and the cells its target may take. */
620type Tail = { child: AgentRow | null; isLive: boolean; tail: ReturnType<typeof tailOf>; room: number }
621
622/** The button an Agent call's target becomes: it opens the child's Card. */
623function childButton($: EngineInterface, els: ElementTable, child: AgentRow, label: string) {
624 const { Button } = els
625 const key = boardKey({ kind: 'child', id: child.id })
626 return <Button key={key} plain dimColor label={label} onPress={takenByPressHook} />
627}
628
629// The end of a Tool call's row: what it touched, cut in the middle to the cells the row leaves it, then the tail.
630function toolTail($: EngineInterface, els: ElementTable, l: Extract<TranscriptLine, { kind: 'tool' }>, t: Tail) {
631 const { Box, Text } = els
632 const { child } = t
633 const target = l.target ? cutMiddle(l.target, t.room) : null
634 return [
635 target ? (
636 <Box key="target" flexShrink={1} flexGrow={1} minWidth={0}>
637 {child === null ? (
638 <Text {...(t.isLive ? { color: 'claude', bold: true } : { color: 'inactive' })}>{target}</Text>
639 ) : (
640 childButton($, els, child, target)
641 )}
642 </Box>
643 ) : null,
644 t.tail === null ? null : (
645 <Box key={l.isFailed ? 'failed' : 'summary'} flexShrink={0}>
646 <Text color={t.tail.color} bold={t.tail.isBold}>
647 {t.tail.text}
648 </Text>
649 </Box>
650 ),
651 ]
652}
653
654// What an opened Tool call shows, indented under its row: in the terminal a failed one's output as plain lines, else
655// the engine's Code element.
656function toolDetail(els: ElementTable, b: Board, l: Extract<TranscriptLine, { kind: 'tool' }>) {
657 const { Box, Text, Code } = els
658 const source = openedSource(l)
659 return (
660 <Box key="detail" marginLeft={4} flexDirection="column">
661 {l.isFailed && b.surface === 'terminal' ? (
662 source.split('\n').map((line, n) => (
663 <Text key={`out${n}`} wrap="wrap">
664 {line}
665 </Text>
666 ))
667 ) : (
668 <Code source={source} />
669 )}
670 </Box>
671 )
672}
673
674// Whether a Tool call has anything to open. A call in flight has no result to open yet. A failed call opens to its
675// output alone, so with none it has nothing to open.
676const hasDetail = (l: Extract<TranscriptLine, { kind: 'tool' }>, isLive: boolean) =>
677 !isLive && (l.isFailed ? !!l.output : !!(l.input || l.output))
678
679// A Tool call hangs off the timeline. With something to show it has a chevron that opens it; a failed one starts open
680// on the failed fill, off the terminal. While the agent runs, a call in flight draws in Claude colour.
681function toolEntry(
682 $: EngineInterface,
683 els: ElementTable,
684 b: Board,
685 flow: Flow,
686 i: number,
687 l: Extract<TranscriptLine, { kind: 'tool' }>,
688) {
689 const { Box, Button } = els
690 const key = boardKey({ kind: 'call', id: flow.card, callId: l.id })
691 // An Agent call whose child the board holds draws as an Agent call, in flight or not.
692 const child = childOf(b.rows, flow.card, l)
693 const isLive = flow.isRunning && l.isInFlight === true && child === null
694 const isOpenable = hasDetail(l, isLive)
695 const isOpen = isOpenable && isCallOpen(b.sections, flow.card, l.id, l.isFailed)
696 const tail = tailOf(l, child, isLive)
697 const room = roomOf(b, l.name, tail, isOpenable)
698 return (
699 <Box
700 key="call"
701 flexDirection="column"
702 {...(l.isFailed && b.surface !== 'terminal' ? { backgroundColor: FILL_FAILED } : {})}
703 >
704 <Box key="tool" flexDirection="row" gap={1}>
705 {rowLead(els, b, flow, i, l.name, isLive)}
706 {toolTail($, els, l, { child, isLive, tail, room })}
707 {isOpenable ? <Button key={key} plain dimColor label={isOpen ? '▾' : '▸'} onPress={takenByPressHook} /> : null}
708 </Box>
709 {isOpen ? toolDetail(els, b, l) : null}
710 </Box>
711 )
712}
713
714// A Thought hangs off the timeline like a Tool call, closed to its name and a chevron.
715function thoughtEntry(
716 $: EngineInterface,
717 els: ElementTable,
718 b: Board,
719 flow: Flow,
720 i: number,
721 l: Extract<TranscriptLine, { kind: 'thought' }>,
722) {
723 const { Box, Text, Button } = els
724 const isOpen = isThoughtOpen(b.sections, flow.card, l.id)
725 const key = boardKey({ kind: 'thought', id: flow.card, thoughtId: l.id })
726 return (
727 <Box key="thought" flexDirection="column">
728 <Box key="tool" flexDirection="row" gap={1}>
729 {rowLead(els, b, flow, i, 'Thought')}
730 <Box key="room" flexGrow={1} />
731 <Button key={key} plain dimColor label={isOpen ? '▾' : '▸'} onPress={takenByPressHook} />
732 </Box>
733 {isOpen ? (
734 <Box key="detail" marginLeft={4} flexDirection="column">
735 <Text wrap="wrap">{l.body}</Text>
736 {l.more > 0 ? <Text key="more" color="inactive">{`… ${l.more} more characters`}</Text> : null}
737 </Box>
738 ) : null}
739 </Box>
740 )
741}
742
743// What leads a message's first row: its dot, or in the terminal `⏺` or `>`.
744function messageMark(els: ElementTable, b: Board, flow: Flow, i: number, dot: Dot) {
745 const { Text } = els
746 const Svg = svgOf(b.surface, els)
747 if (Svg === null)
748 return (
749 <Text key="mark" color={dot === 'writing' ? 'claude' : 'inactive'}>
750 {dot === 'user' ? '>' : '⏺'}
751 </Text>
752 )
753 return (
754 <Svg
755 key="dot"
756 source={dotSvg(dot, i > flow.first)}
757 alt={dot === 'user' ? 'Your message' : 'Agent message'}
758 width={GUTTER_W}
759 height={ROW_H}
760 />
761 )
762}
763
764// A message's first row: its mark, its label, and `Writing…` while the agent writes it.
765function messageHead(els: ElementTable, b: Board, flow: Flow, i: number, l: Extract<TranscriptLine, { text: string }>) {
766 const { Box, Text } = els
767 const isAgent = l.kind === 'agent'
768 // The agent is writing the newest message while it runs and nothing has followed it.
769 const isWriting = flow.isRunning && isAgent && i === flow.lines.length - 1
770 return (
771 <Box key="head" flexDirection="row" gap={1}>
772 {messageMark(els, b, flow, i, isWriting ? 'writing' : isAgent ? 'agent' : 'user')}
773 <Text key="who" color={isAgent ? 'claude' : 'text'} bold>
774 {isAgent ? 'Agent' : 'You'}
775 </Text>
776 {isWriting ? (
777 <Text key="writing" color="claude" bold>
778 Writing…
779 </Text>
780 ) : null}
781 </Box>
782 )
783}
784
785// In the terminal, the line beside a message of more than one row: a `│` for each row while another row follows, else
786// blank cells to keep the text in line. A one-row message has none; the `│` below it joins it to the next entry.
787function timelineBars(els: ElementTable, rows: number, hasBelow: boolean) {
788 const { Box, Text } = els
789 if (rows < 2) return null
790 return (
791 <Box key="bars" flexDirection="column">
792 {Array.from({ length: rows }, (_, n) => (
793 <Text key={`bar${n}`} color="subtle">
794 {hasBelow ? '│' : ' '}
795 </Text>
796 ))}
797 </Box>
798 )
799}
800
801// A message hangs on a dot: filled grey for the agent's, hollow for the user's, orange while the agent writes it.
802function messageEntry(
803 els: ElementTable,
804 b: Board,
805 flow: Flow,
806 i: number,
807 l: Extract<TranscriptLine, { text: string }>,
808) {
809 const { Box, Text } = els
810 const Svg = svgOf(b.surface, els)
811 const rows = textRows(b, l.text)
812 const hasBelow = i < flow.lines.length - 1
813 return (
814 <Box key="message" flexDirection="column">
815 {messageHead(els, b, flow, i, l)}
816 <Box key="body" flexDirection="row" gap={1}>
817 {Svg === null ? (
818 timelineBars(els, rows, hasBelow)
819 ) : (
820 <Svg key="line" source={lineSvg(hasBelow, rows)} alt="Timeline" width={GUTTER_W} height={rows * ROW_H} />
821 )}
822 <Text key="said" wrap="wrap">
823 {l.text}
824 </Text>
825 </Box>
826 </Box>
827 )
828}
829
830// One entry of the Transcript. In the terminal a `│` follows it where the line goes on to a message.
831function entry($: EngineInterface, els: ElementTable, b: Board, flow: Flow, i: number) {
832 const { Box, Text } = els
833 const l = flow.lines[i]
834 const next = flow.lines[i + 1]
835 if (l === undefined) return null
836 // A message of more than one row carries its own line down its side, so it needs no `│` below.
837 const isBarred = !isHung(l) && textRows(b, l.text) > 1
838 const joint =
839 b.surface !== 'terminal' || next === undefined || isHung(next) || isBarred ? null : (
840 <Text key="joint" color="subtle">
841 │
842 </Text>
843 )
844 return (
845 <Box key={`line${i}`} flexDirection="column">
846 {l.kind === 'tool'
847 ? toolEntry($, els, b, flow, i, l)
848 : l.kind === 'thought'
849 ? thoughtEntry($, els, b, flow, i, l)
850 : messageEntry(els, b, flow, i, l)}
851 {joint}
852 </Box>
853 )
854}
855
856// What the opened card has read of the agent's conversation.
857function chatLines($: EngineInterface, els: ElementTable, b: Board, r: AgentRow, c: Chat | null) {
858 const { Box, Text, Button } = els
859 if (c === null) return null
860 const unread = (
861 <Text key="unread" color="inactive" wrap="wrap">
862 Its messages can't be read here.
863 </Text>
864 )
865 if (c.lines === null) return unread
866 const lines = c.lines
867 if (lines.length === 0)
868 return (
869 <Text key="unread" color="inactive">
870 No messages yet.
871 </Text>
872 )
873 const isAll = sectionsOf(b.sections, r.id).isEarlierShown
874 const earlier = boardKey({ kind: 'earlier', id: r.id })
875 const first = firstShown(lines.length, isAll)
876 const body = (
877 <Box key="lines" flexDirection="column">
878 {c.isThinkingMissing ? (
879 <Text key="thinking-missing" color="inactive">
880 Thinking is not available for this agent
881 </Text>
882 ) : null}
883 {c.dropped > 0 ? (
884 <Text key="dropped" color="inactive">
885 {`Earlier entries dropped: ${c.dropped}.`}
886 </Text>
887 ) : null}
888 {firstShown(lines.length, false) > 0 ? (
889 <Button
890 key={earlier}
891 plain
892 dimColor
893 label={isAll ? 'Show fewer' : `Show ${first} earlier`}
894 onPress={takenByPressHook}
895 />
896 ) : null}
897 {lines.map((_, i) =>
898 i < first ? null : entry($, els, b, { card: r.id, lines, first, isRunning: r.state === 'running' }, i),
899 )}
900 </Box>
901 )
902 // The user's own messages still show, under a note that the agent's could not be read.
903 return c.isUnread ? (
904 <Box key="chat" flexDirection="column">
905 {unread}
906 {body}
907 </Box>
908 ) : (
909 body
910 )
911}
912
913// The box to message the agent. The phone draws no text field (its table's Input draws nothing), so there it says where to type instead.
914function sayBox($: EngineInterface, els: ElementTable, b: Board, r: AgentRow, agentId: string, c: Chat | null) {
915 const Input = inputOf(b.surface, els)
916 if (Input === null)
917 return (
918 <els.Text key="say-elsewhere" color="inactive" wrap="wrap">
919 Message it from the desktop app or the terminal.
920 </els.Text>
921 )
922 // A fresh key after each send draws an empty box.
923 return (
924 <Input
925 key={`say:${r.id}:${c?.sent ?? 0}`}
926 placeholder={isLive(r) ? 'Message this agent' : 'Message it to pick up again'}
927 submitLabel="send"
928 onSubmit={(text: string) => void sendTo($, agentId, isLive(r), text)}
929 />
930 )
931}
932
933// The footer of an opened card: its rule, the box to message the agent, and Stop while it runs, and the last send's note.
934function footer($: EngineInterface, els: ElementTable, b: Board, r: AgentRow, agentId: string, c: Chat | null) {
935 const { Box, Text, Button } = els
936 const stop = boardKey({ kind: 'stop', id: r.id })
937 return [
938 footerRule(els, b),
939 <Box key="say-row" flexDirection="row" gap={1} marginTop={1} {...insetOf(b)}>
940 <Box key="say-box" flexGrow={1} flexShrink={1}>
941 {sayBox($, els, b, r, agentId, c)}
942 </Box>
943 {isLive(r) ? <Button key={stop} label="Stop" onPress={takenByPressHook} /> : null}
944 </Box>,
945 c === null || c.note === null ? null : (
946 <Box key="note-room" {...insetOf(b)}>
947 <Text key="note" color="inactive" wrap="wrap">
948 {c.note}
949 </Text>
950 </Box>
951 ),
952 ]
953}
954
955// Under an opened card's Brief: the Transcript, then the footer.
956function talk($: EngineInterface, els: ElementTable, b: Board, r: AgentRow) {
957 const { Box, Text } = els
958 if (r.agentId === null)
959 return [
960 <Box key="talk" {...insetOf(b)}>
961 <Text key="starting" color="inactive">
962 Starting. Its messages show here once it runs.
963 </Text>
964 </Box>,
965 ]
966 const agentId = r.agentId
967 const c = b.chat?.agentId === agentId ? b.chat : null
968 return [
969 ...sectionHead(
970 $,
971 els,
972 b,
973 r,
974 'transcript',
975 'Transcript',
976 c === null || c.lines === null ? null : c.lines.length + c.dropped,
977 ),
978 sectionsOf(b.sections, r.id).isTranscriptOpen ? (
979 <Box key="transcript-body" marginTop={1} {...insetOf(b)}>
980 {chatLines($, els, b, r, c)}
981 </Box>
982 ) : null,
983 ...footer($, els, b, r, agentId, c),
984 ]
985}
986
987// The type line: agent type, effort word and model, set apart by dim separators. The header has already cut them to its
988// cells (metaParts), and a separator is drawn only between two parts that are drawn. The engine cannot be trusted to
989// shrink a row, so the type is not asked to: it is drawn as cut, with no shrink or truncate setting, and the rest of
990// the row (the `effort-room` Box) is held at its size.
991function typeLine(els: ElementTable, meta: MetaParts) {
992 const { Box, Text } = els
993 return (
994 <Box key="who" flexDirection="row">
995 {meta.type === '' ? null : (
996 <Text key="type" color="inactive">
997 {meta.type}
998 </Text>
999 )}
1000 <Box key="effort-room" flexShrink={0}>
1001 {meta.type === '' ? null : (
1002 <Text key="sep-type" color="subtle">
1003 {PART_SEP}
1004 </Text>
1005 )}
1006 {meta.effort === '' ? null : (
1007 <Text key="tier" color="inactive">
1008 {meta.effort}
1009 </Text>
1010 )}
1011 {meta.effort === '' ? null : (
1012 <Text key="sep-model" color="subtle">
1013 {PART_SEP}
1014 </Text>
1015 )}
1016 <Text key="model" color="inactive">
1017 {meta.model}
1018 </Text>
1019 </Box>
1020 </Box>
1021 )
1022}
1023
1024// The card's first row: its name, and its state mark at the right edge. In the terminal a hat mark leads the
1025// name; the name gives way before the state mark does, so a narrow pane keeps the mark.
1026function titleRow($: EngineInterface, els: ElementTable, b: Board, r: AgentRow, tier: Tier, parts: TitleParts) {
1027 const { Box, Text, Button } = els
1028 const mark = MARK[r.state]
1029 const key = boardKey({ kind: 'name', id: r.id })
1030 // The label carries the cut name too: the desktop app draws a Button's label, not the Text inside it (the user's
1031 // 2026-10-15 screenshot showed the whole name pushing the header past the Card's edge); the terminal draws the Text.
1032 const name = (
1033 <Button key={key} plain label={parts.name} onPress={takenByPressHook}>
1034 <Text color="text" bold>
1035 {parts.name}
1036 </Text>
1037 </Button>
1038 )
1039 const state = (
1040 <Box key="mark-box" flexDirection="row" gap={1}>
1041 <Text key="mark" color={mark.color} bold>
1042 {mark.glyph}
1043 </Text>
1044 <Text key="word" color={mark.color} bold={mark.isWordBold}>
1045 {parts.word}
1046 </Text>
1047 </Box>
1048 )
1049 const isHatShown = svgOf(b.surface, els) === null
1050 return (
1051 <Box key="top" flexDirection="row" justifyContent="space-between">
1052 {isHatShown ? (
1053 <Box key="name-room" flexDirection="row" gap={1} flexShrink={1} minWidth={0}>
1054 <Text key="hat" color={hatColor(tier.hat) ?? 'inactive'}>
1055 ▲
1056 </Text>
1057 {name}
1058 </Box>
1059 ) : (
1060 name
1061 )}
1062 {isHatShown ? (
1063 <Box key="mark-room" flexShrink={0}>
1064 {state}
1065 </Box>
1066 ) : (
1067 state
1068 )}
1069 </Box>
1070 )
1071}
1072
1073// What makes a card one block. The desktop paints a raised fill with inner padding. The terminal paints no
1074// see-through fill, so its edge is the engine's round border (╭ ╮ ╰ ╯ │) in the dimmest grey.
1075const blockOf = (b: Board) =>
1076 b.surface === 'terminal'
1077 ? ({ borderStyle: 'round', borderColor: 'subtle' } as const)
1078 : ({ padding: 1, backgroundColor: FILL_RAISED } as const)
1079
1080// The Card's stats (`ctx 9% | 992k | 1:43`); they never shrink, so the header counts them out of its cells.
1081function metricsBox(els: ElementTable, stats: string) {
1082 const { Box, Text } = els
1083 return (
1084 <Box key="metrics" flexShrink={0}>
1085 <Text key="metrics-text" color="inactive">
1086 {stats}
1087 </Text>
1088 </Box>
1089 )
1090}
1091
1092// The open Card's token line. The terminal wraps it as one Text. The desktop app wraps a Text inside its parts, where a
1093// row could begin with `|` (the user's 2026-10-15 screenshot), so there the parts share the header's `cells` by rows.
1094function tokensBox(els: ElementTable, b: Board, r: AgentRow, cells: number) {
1095 const { Box, Text } = els
1096 const lines = b.surface === 'terminal' ? [tokensLine(r)] : partRows(tokenParts(r), cells)
1097 return (
1098 <Box key="details" flexDirection="column" marginTop={1}>
1099 {/* A drawn Text loses its key, so the lines sit in a keyed Box. */}
1100 <Box key="tokens" flexDirection="column">
1101 {lines.map((line, i) => (
1102 <Text key={`tokens-${i}`} color="inactive" wrap="wrap">
1103 {line}
1104 </Text>
1105 ))}
1106 </Box>
1107 </Box>
1108 )
1109}
1110
1111// Where the title cuts the name, the open Card draws all of it once, dim and wrapping, under the title: the design
1112// foundations keep a way to see the whole of a cut label.
1113function fullName(els: ElementTable, r: AgentRow, title: TitleParts, isOpen: boolean) {
1114 const { Box, Text } = els
1115 if (!isOpen || title.name === r.description || r.description.trim() === '') return null
1116 return (
1117 <Box key="full-name">
1118 <Text color="inactive" wrap="wrap">
1119 {r.description}
1120 </Text>
1121 </Box>
1122 )
1123}
1124
1125function agentCard($: EngineInterface, els: ElementTable, b: Board, r: AgentRow) {
1126 const { Box, Text } = els
1127 const tier = tierOf(r.effort)
1128 const cells = headerCells(b.surface === 'terminal', insideCard(b))
1129 const { steps, stats, isStacked } = numsRow(cardLine(r), statParts(r, b.t, b.window), cells)
1130 const isOpen = b.shown === r.id
1131 const title = titleParts(r.description, r.state, cells)
1132 return (
1133 <Box key={`card:${r.id}`} flexDirection="column" marginBottom={1} {...blockOf(b)}>
1134 <Box key="header" flexDirection="row" gap={1} {...insetOf(b)}>
1135 {critter(els, b, r, tier)}
1136 <Box key="body" flexDirection="column" flexGrow={1} flexShrink={1}>
1137 {titleRow($, els, b, r, tier, title)}
1138 {fullName(els, r, title, isOpen)}
1139 {typeLine(
1140 els,
1141 metaParts(
1142 { type: r.type, effort: tier.label, model: prettyModel(r.model) },
1143 cells,
1144 svgOf(b.surface, els) !== null,
1145 ),
1146 )}
1147 {/* A long step name gives way, cut short with an ellipsis; the numbers keep one line. */}
1148 <Box key="nums" flexDirection="row" justifyContent={steps === '' ? 'flex-end' : 'space-between'} gap={1}>
1149 {steps === '' ? null : (
1150 <Box key="steps" flexShrink={1} minWidth={0}>
1151 <Text key="step-text" wrap="truncate-end">
1152 {steps}
1153 </Text>
1154 </Box>
1155 )}
1156 {stats === '' || isStacked ? null : metricsBox(els, stats)}
1157 </Box>
1158 {stats === '' || !isStacked ? null : (
1159 <Box key="stats-row" flexDirection="row" justifyContent="flex-end">
1160 {metricsBox(els, stats)}
1161 </Box>
1162 )}
1163 {bar(els, b, r)}
1164 {isOpen ? tokensBox(els, b, r, cells) : null}
1165 </Box>
1166 </Box>
1167 {/* The sections sit below the header, outside its inset, so their dividers reach the Card's edges. */}
1168 {isOpen ? briefSection($, els, b, r) : null}
1169 {isOpen ? talk($, els, b, r) : null}
1170 </Box>
1171 )
1172}
1173
1174function runningSection($: EngineInterface, els: ElementTable, b: Board) {
1175 const { Box, Text } = els
1176 const live = b.rows.filter(isLive)
1177 return (
1178 <Box key="running" flexDirection="column">
1179 <Text key="head" color="inactive">{`Live | ${live.length}`}</Text>
1180 {live.map((r) => agentCard($, els, b, r))}
1181 </Box>
1182 )
1183}
1184
1185function finishedSection($: EngineInterface, els: ElementTable, b: Board) {
1186 const { Box, Button } = els
1187 const ended = b.rows.filter((r) => !isLive(r))
1188 if (ended.length === 0) return null
1189 const finished = boardKey({ kind: 'finished' })
1190 return (
1191 <Box key="finished" flexDirection="column">
1192 <Button
1193 key={finished}
1194 plain
1195 dimColor
1196 label={`${b.isFinishedShown ? '▾' : '▸'} Finished | ${ended.length}`}
1197 onPress={takenByPressHook}
1198 />
1199 {b.isFinishedShown ? ended.map((r) => agentCard($, els, b, r)) : null}
1200 </Box>hooks/tracer/register.tsx 2342 lines1// The file tracer: a pane with the folder tree of the files Claude touched.
2// It opens by itself on the first Touch. Every hook lives in this file: the validator follows `$` only within one file.
3import { atom, read, update } from 'claude-code'
4import type { ElementTable, EngineInterface, On, RenderSurface } from 'claude-code'
5
6import { isOwnPress, takenByPressHook, type EventIn, type EventOut, type PlainNext } from '../handler'
7import { REFUSED_BY_HOOK, because, errorText, failToast } from '../failures'
8import { inputOf, svgOf } from '../surface'
9import { isAppFormOn } from './form'
10import type { EditNote, FileViewValue, LogEntry, RanEntry, Tag, RecordEntry, TouchBy, TouchEntry } from '../../types'
11import {
12 fileTouchesBetween,
13 headHashesNeeded,
14 maybeVanished,
15 touchesBetween,
16 type FileSnapshot,
17 type GitSnapshot,
18 type Snapshot,
19} from './changes'
20import { ACTING_SLACK, fitWindow, jumpsOf, lineLabel, lineOfferOf, linesOf, textOf, windowOf } from './acting'
21import { filesKey, filesPressOf, trailKey, trailPressOf, type FilesButton } from './keys'
22import { attempt, mcpFailure, saveFailure } from './attempt'
23import { pressAction } from './pressfailed'
24import { classify } from './classify'
25import { attachedOf, attachedWords, commentText, isUsersPrompt, withComments } from './comments'
26import { noteText, notesOf, withNote } from './notes'
27import { RAISED_FILL, SELECTED_FILL } from './colours'
28import { describe, inputFor, keptOf, keyOf, withKept, type DescribeInput, type DescriptionRequest } from './description'
29import { failedNotice, insertAfter, noticeOf, removeLine, replaceLine, staleNotice } from './edit'
30import {
31 actingFlipped,
32 actingIn,
33 boxClosed,
34 commentOpened,
35 editOpened,
36 fileClosed,
37 fileOpened,
38 fileViewOf,
39 jumpedToLine,
40 lineAdded,
41 lineDeleted,
42 linePicked,
43 saveRefused,
44 wholeFlipped,
45} from './fileview'
46import { goneNotice, openingOf } from './openrule'
47import { endCut, folderRoom, lineRoom, locationOf, middleCut, nameRoom, placeRoom, shortPath } from './paths'
48import { parentOf, reveal, systemOf } from './reveal'
49import { parseStatus } from './status'
50import { drawnChars, hunksOf, named, viewOfChange, viewOfRead, type FileView } from './view'
51import { mayBeInUnlistedWorktree, parseWorktrees } from './worktrees'
52import { changedOf, summaryOf, type Summary } from './summary'
53import { COMMAND, PANE, TITLE, clearRecent, fold, isUserClose, tagWords, type Touch } from './record'
54import {
55 MAX_TAIL,
56 byText,
57 commandLine,
58 countsOfTouch,
59 currentPath,
60 currentTurn,
61 inOrder,
62 lastNumber,
63 logOf,
64 numbered,
65 linesText,
66 touchRowsOf,
67 startTurn,
68 tableChars,
69 touchesOf,
70 type Helper,
71 type NewTouch,
72 type Turn,
73} from './log'
74import {
75 TIMELINE_ALT,
76 TRAIL_PANE,
77 TRAIL_TITLE,
78 trailOf,
79 type BoxRow,
80 type HelperBox,
81 type TrailRow,
82 type TrailTurn,
83} from './trail'
84import { chevronSvg, folderSvg, iconSvg, typeIconOf } from './icons'
85import { LINE_ALT, LINE_HEIGHT, LINE_LETTERS, dividerSvg, lineSvg, type LineCell } from './lines'
86import {
87 absoluteOf,
88 COUNT_CELLS,
89 countCellsOf,
90 countPair,
91 countsOf,
92 diffCounts,
93 dividerChars,
94 MAX_FILES,
95 treeRowsOf,
96 type Row,
97} from './tree'
98
99// The validator reads state only through atoms declared in this file.
100const records = atom({ plugin: 'desk', key: 'records' } as const, {} as Record<string, RecordEntry>)
101// Every Touch in session order, but the newest few, which wait in `logTail`: a write costs as much as the value it writes.
102const log = atom({ plugin: 'desk', key: 'log' } as const, [] as LogEntry[])
103// The newest entries of the Touch log, until there are enough to move into `log`.
104const logTail = atom({ plugin: 'desk', key: 'logTail' } as const, [] as LogEntry[])
105// The Turns so far: each prompt the person submitted starts one.
106const turns = atom({ plugin: 'desk', key: 'turns' } as const, [] as Turn[])
107// The Turns the person closed in the Trail, by number. Turns start open.
108const closedTurns = atom({ plugin: 'desk', key: 'closedTurns' } as const, [] as number[])
109// Whether the person chose `Show Claude only` in the Trail: the helpers' boxes are left out.
110const isClaudeOnly = atom({ plugin: 'desk', key: 'isClaudeOnly' } as const, false)
111// The Touch the person pressed last in the Trail, by its number; null before any press.
112const trailSelected = atom({ plugin: 'desk', key: 'trailSelected' } as const, null as number | null)
113// The helpers Claude started, each by agent id.
114const helpers = atom({ plugin: 'desk', key: 'helpers' } as const, [] as Helper[])
115// Set when the person closes the pane; only `/file-tracer` clears it.
116const closed = atom({ plugin: 'desk', key: 'closed' } as const, false)
117// The folders the person closed in the tree, by path in the project.
118const closedFolders = atom({ plugin: 'desk', key: 'closedFolders' } as const, [] as string[])
119
120// The groups the person closed: 'worktrees' and 'outside'.
121const closedGroups = atom({ plugin: 'desk', key: 'closedGroups' } as const, [] as string[])
122
123// The row the person clicked last, by path in the project.
124const selected = atom({ plugin: 'desk', key: 'selected' } as const, null as string | null)
125
126// Whether the person chose `Changed only`.
127const isChangedOnly = atom({ plugin: 'desk', key: 'isChangedOnly' } as const, false)
128
129// The tree, or the selected file's File view with its acting form, its one box and its notice. Read it through `readFileView`.
130const fileView = atom({ plugin: 'desk', key: 'fileView' } as const, { kind: 'tree' } as FileViewValue)
131
132/** The File view as saved, with the defaults filled in. */
133const readFileView = async ($: EngineInterface) => fileViewOf(await read($, fileView))
134
135/** Writes the File view once, as `move` makes it from the saved one. */
136const moveFileView = ($: EngineInterface, move: (view: FileViewValue) => FileViewValue) =>
137 update($, fileView, (saved) => move(fileViewOf(saved)))
138
139// The notice a failed Open in Files left under the buttons of the File view; null while there is none.
140const filesNotice = atom({ plugin: 'desk', key: 'filesNotice' } as const, null as string | null)
141
142// The comments the person attached to their next message, each already in the shape Claude reads, oldest first.
143const attached = atom({ plugin: 'desk', key: 'attached' } as const, [] as string[])
144
145// What the person changed in files from the pane since their last message, oldest first.
146const notes = atom({ plugin: 'desk', key: 'notes' } as const, [] as EditNote[])
147
148// The Descriptions written so far, each by the key of the file and content it describes.
149const descriptions = atom({ plugin: 'desk', key: 'descriptions' } as const, [] as Array<{ key: string; text: string }>)
150
151/** The file the view area shows, what it was when it was last touched, what was made of its text when the person selected it, and what to ask the model about it; one read per selection, none per drawing. */
152let shown: {
153 path: string
154 order: number | null
155 view: FileView | null
156 whole: FileView | null
157 ask: DescribeInput | null
158} | null = null
159
160/** The most characters of text one drawing holds, the code view's source and the tree together; the engine cuts the drawing off past it. */
161const MAX_DRAWN = 100_000
162
163/** Room kept for the text of the drawing that is neither a row nor the file: the toolbar's counts and buttons, the "+N more files" row and the lines leading the view. */
164const SLACK = 300
165
166/** The view area's file for a selected path: a Read file's text, a changed file's diff, no view for a folder or a path the records lack. */
167async function showFile($: EngineInterface, path: string, touched: Record<string, RecordEntry>) {
168 const cwd = await $.session.cwd()
169 const entry = touched[path]
170 if (entry === undefined) return { path, order: null, view: null, whole: null, ask: null }
171 // A deleted file is not on the disk: its view is its baseline, all of it removed.
172 const read = entry.tag === 'Deleted' ? '' : await $.fs.read(path).catch(() => null)
173 // What a deleted file lost is the last text the mod saw of it, else its text before the session's first change.
174 const changed = entry.tag === 'Deleted' ? { ...entry, baseline: entry.lastSeen ?? entry.baseline } : entry
175 const view = entry.tag === 'Read' ? viewOfRead(read) : viewOfChange(changed, read, { cwd, path })
176 // The whole file is on offer beside a diff, as the text a read of the file gave.
177 const text = 'diff' in view ? viewOfRead(read) : null
178 const whole = text !== null && 'text' in text ? { ...text, leads: view.leads } : null
179 return { path, order: entry.order, view, whole, ask: inputFor(changed, view, read, { cwd, path }) }
180}
181
182/** The keys of the Descriptions whose request is out. */
183const pending = new Set<string>()
184
185/** Asks the small model for the shown file's Description and keeps the reply; a reply that does not come changes nothing. */
186async function describeShown($: EngineInterface) {
187 const ask = shown?.ask
188 if (ask == null) return
189 const key = keyOf(ask)
190 if (pending.has(key) || keptOf(await read($, descriptions)).some((d) => d.key === key)) return
191 pending.add(key)
192 $.ui.invalidate('ui.render')
193 // The engine rejects only a request it refuses to send; that, too, leaves no Description.
194 const askModel = async (request: DescriptionRequest) => {
195 const r = await $.model.complete(request).catch(() => null)
196 return r?.isAnswered === true ? r.text : null
197 }
198 const text = await describe(ask, askModel)
199 pending.delete(key)
200 if (text !== null) await update($, descriptions, (c) => withKept(c, { key, text }))
201 // A failed request writes nothing, so the line standing in for the Description goes by a redraw of its own.
202 $.ui.invalidate('ui.render')
203}
204
205/** A press on a file row in the App form, which has no File view: the row is selected and nothing else changes. `path` is absolute. */
206const selectFile = ($: EngineInterface, path: string) => update($, selected, () => path)
207
208/** A click on a file row: reads the file for the view area, then selects it. `path` is absolute. */
209async function clickFile($: EngineInterface, path: string) {
210 shown = await showFile($, path, await read($, records))
211 forgetTyped()
212 // Together, not one after another: the Description's request waits behind these, and a test counts the engine's turns until it is out.
213 await Promise.all([update($, filesNotice, () => null), moveFileView($, fileOpened)])
214 await update($, selected, () => path)
215 await describeShown($)
216}
217
218// Whether the pane had the keyboard at its last drawing. The desktop app spends a first click on an unfocused
219// pane on focusing it and sends the button no press, so the focus hook runs the click's action instead.
220let isPaneFocused = false
221
222/** The git program with its arguments for one directory; it takes no repository lock a command of Claude's might want. */
223const gitIn = (dir: string, ...args: string[]) => ['git', '-C', dir, '--no-optional-locks', ...args]
224
225/** The folders of the project's worktrees as git last listed them; null while git has not answered since the mod loaded. */
226let worktreeRoots: string[] | null = null
227
228/** Asks git for the project's worktrees again; where git cannot answer, the list stays as it was. */
229async function listWorktrees($: EngineInterface) {
230 const cwd = await $.session.cwd()
231 const r = await $.process.run(gitIn(cwd, 'worktree', 'list', '--porcelain'), { timeoutMs: 3000 }).catch(() => null)
232 if (r !== null && r.exitCode === 0) worktreeRoots = parseWorktrees(r.stdout)
233}
234
235/** The one request for the list that a load of the mod makes when a Touch or a drawing finds it not yet answered. */
236let firstListing: Promise<void> | null = null
237
238/** The worktrees git lists. A list git has not answered since the mod loaded (a reload wipes it, and a session start that failed leaves it) is asked for once, whether or not that answers. */
239async function knownWorktrees($: EngineInterface): Promise<string[]> {
240 if (worktreeRoots === null) {
241 firstListing ??= listWorktrees($)
242 await firstListing
243 }
244 return worktreeRoots ?? []
245}
246
247/** A click on a folder row: closes the folder if it is open, opens it if closed, and selects it. */
248async function clickFolder($: EngineInterface, path: string, abs: string) {
249 await update($, closedFolders, (c) => (c.includes(path) ? c.filter((p) => p !== path) : [...c, path]))
250 await update($, selected, () => abs)
251}
252
253/** Asks git for the worktrees again when a Touch lands in a path new to the records that may sit in one it has not listed. */
254async function relistWorktreesFor($: EngineInterface, touches: Touch[]) {
255 const [saved, cwd] = [await read($, records), await $.session.cwd()]
256 const roots = await knownWorktrees($)
257 const isNew = touches.some((t) => saved[t.path] === undefined && mayBeInUnlistedWorktree(t.path, cwd, roots))
258 if (isNew) await listWorktrees($)
259}
260
261/** A click on a group header: hides the group's files if they show, shows them if hidden. */
262const clickGroup = ($: EngineInterface, name: string) =>
263 update($, closedGroups, (c) => (c.includes(name) ? c.filter((g) => g !== name) : [...c, name]))
264
265/** The address of the toggle that shows every touched file or only the changed ones. */
266const CHANGED_ONLY_KEY = filesKey({ kind: 'button', name: 'changed-only' })
267
268/** The address of the `Trail` button. */
269const TRAIL_KEY = filesKey({ kind: 'button', name: 'show-trail' })
270
271/** Closes the Turn if it is open in the Trail, opens it if closed. */
272const toggleTurn = ($: EngineInterface, n: number) =>
273 update($, closedTurns, (c) => (c.includes(n) ? c.filter((x) => x !== n) : [...c, n]))
274
275/** The address of the Trail's `Show Claude only` toggle. */
276const CLAUDE_ONLY_KEY = trailKey({ kind: 'claudeOnly' })
277
278/** Flips the Trail between `Show Claude only` and `Show all`. */
279const toggleClaudeOnly = ($: EngineInterface) => update($, isClaudeOnly, (c) => c !== true)
280
281/** A press on a Touch in the Trail: its file is selected in the Files pane, the Files pane is opened with the keyboard if it was closed, and the Touch is the Trail's selected one. The terminal opens the File view on the file; the App form runs the Open rule on its current path instead. A Files pane that cannot be opened fails the press, which `reported` tells the user, as Show Trail does; in the App form the Open rule has run by then, so the app's own pane still opens. `n` is the Touch's number: the Touch is read from the log when pressed. */
282async function pressTouch($: EngineInterface, n: number, surface: RenderSurface) {
283 const log = await readLog($)
284 const t = log.find((x) => x.type === 'touch' && x.n === n)
285 if (t?.type !== 'touch') return
286 await pressFile($, currentPath(log, t), surface, async () => {
287 await update($, trailSelected, () => n)
288 await update($, closed, () => false)
289 await $.ui.open({ id: PANE, title: TITLE, focus: true })
290 })
291}
292
293/** Opens the Trail pane, beside Files. */
294const openTrail = ($: EngineInterface) => $.ui.open({ id: TRAIL_PANE, title: TRAIL_TITLE })
295
296/** Flips the toggle between `Changed only` and `All touched`. */
297const toggleChangedOnly = ($: EngineInterface) => update($, isChangedOnly, (c) => c !== true)
298
299/** The address of the `‹ All files` button. */
300const BACK_KEY = filesKey({ kind: 'button', name: 'back' })
301
302/** Closes the File view: the tree comes back with the same row selected. */
303const closeFile = ($: EngineInterface) => moveFileView($, fileClosed)
304
305/** The address of the button that switches the Changes section between the diff and the whole file. */
306const WHOLE_KEY = filesKey({ kind: 'button', name: 'whole' })
307
308/** Flips the Changes section between the diff and the whole file. */
309const toggleWholeFile = ($: EngineInterface) => moveFileView($, wholeFlipped)
310
311/** The address of the button that switches the whole-file text between the code view and the acting form. */
312const ACTING_KEY = filesKey({ kind: 'button', name: 'acting' })
313
314/** Flips the whole-file text between the code view and the acting form. */
315const toggleActing = ($: EngineInterface) => moveFileView($, actingFlipped)
316
317/** The address of the `Discard` button beside the line counting the attached comments. */
318const DISCARD_KEY = filesKey({ kind: 'button', name: 'discard' })
319
320/** The line counting the comments attached to the next message, with the button that drops them; null while none are attached. */
321function attachedRow($: EngineInterface, els: ElementTable, count: number) {
322 const { Box, Text, Button } = els
323 if (count === 0) return null
324 return (
325 <Box key="attached" flexDirection="row" gap={1}>
326 <Text color={GREY}>{attachedWords(count)}</Text>
327 <Button key={DISCARD_KEY} onPress={takenByPressHook}>
328 Discard
329 </Button>
330 </Box>
331 )
332}
333
334/** The address of the `Comment` button, and of the box and buttons that replace it. */
335const COMMENT_KEY = filesKey({ kind: 'button', name: 'comment' })
336const COMMENT_INPUT_KEY = 'comment-input'
337const SEND_KEY = filesKey({ kind: 'button', name: 'send' })
338const ATTACH_KEY = filesKey({ kind: 'button', name: 'attach' })
339const CANCEL_KEY = filesKey({ kind: 'button', name: 'cancel' })
340
341/** What the person has typed in the Comment box so far. It is kept here, not in saved state: the field holds it, and a reload of the mod starts a blank box. */
342let draft = ''
343
344/** What the person has typed in the Edit box so far; null before any typing, when the box shows the line it was opened with. A reload of the mod starts it null, so a box kept in saved state holds its line again and not an empty field. */
345let typed: string | null = null
346
347/** Forgets what was typed in the Comment box and the Edit box. */
348function forgetTyped() {
349 draft = ''
350 typed = null
351}
352
353/** Opens the Comment box under the selected line. */
354async function openComment($: EngineInterface) {
355 forgetTyped()
356 await moveFileView($, commentOpened)
357}
358
359/** Opens the Edit box under the selected line, holding the line's text as the pane shows it. */
360async function openEdit($: EngineInterface) {
361 const n = actingIn(await readFileView($))?.line ?? null
362 const text = shown === null ? null : textOf(shown.view ?? { note: '' }, shown.whole)
363 const original = n === null || text === null ? undefined : linesOf(text)[n - 1]
364 if (original === undefined) return
365 forgetTyped()
366 await moveFileView($, (view) => editOpened(view, original))
367}
368
369/** The open Edit box: the line it belongs to and the text it was opened with; null while no Edit box is open. */
370async function editBoxOf($: EngineInterface): Promise<{ line: number; original: string } | null> {
371 const acting = actingIn(await readFileView($))
372 const box = acting?.box
373 return acting?.line == null || box?.kind !== 'edit' ? null : { line: acting.line, original: box.original }
374}
375
376/** The new text of a file, made from the text the file has now; null where the box's line is no longer what it was opened with. */
377type Change = (file: string) => string | null
378
379/** What the File view becomes once a change is written, from the view and the text written. */
380type Written = (view: FileViewValue, text: string) => FileViewValue
381
382/**
383 * Reads the file and writes what `change` makes of it. `wrote` is the text written, null where nothing was; `failure`
384 * is why, where the read or the write failed.
385 */
386async function tryChange($: EngineInterface, path: string, change: Change) {
387 const readIt = await attempt($.fs.read(path))
388 const file = readIt.ok ? readIt.value : null
389 const next = file === null ? null : change(file)
390 forgetTyped()
391 const writeIt = next === null ? null : await attempt($.fs.write(path, next))
392 return { file, next, wrote: writeIt?.ok === true ? next : null, failure: saveFailure(readIt, writeIt) }
393}
394
395/**
396 * Writes the file the change makes, unless the file's line is no longer the one the box was opened with: then it
397 * writes nothing, reads the file again for the pane and says so. A write the file refuses says so too. Either way the
398 * box closes and the File view is written once: as `written` makes it, or with the notice.
399 */
400async function changeFile(
401 $: EngineInterface,
402 line: number,
403 change: Change,
404 written: Written,
405 note: Omit<EditNote, 'path'>,
406) {
407 const path = await read($, selected)
408 if (path === null) return
409 const { file, next, wrote, failure } = await tryChange($, path, change)
410 if (wrote !== null) {
411 await recordUserEdit($, path, { before: file ?? '', after: wrote })
412 const cwd = await $.session.cwd()
413 await update($, notes, (own) => withNote(notesOf(own), { ...note, path: named(cwd, absoluteOf(cwd, path)) }))
414 }
415 shown = await showFile($, path, await read($, records))
416 const said = file !== null && next === null ? staleNotice(line) : failedNotice(line, failure)
417 await moveFileView($, (view) => (wrote === null ? saveRefused(view, said) : written(view, wrote)))
418 if (wrote !== null) await describeShown($)
419}
420
421/** Writes the typed line over the line the Edit box was opened on. */
422async function saveEdit($: EngineInterface, typedWords: string | null) {
423 const box = await editBoxOf($)
424 if (box === null) return
425 const words = typedWords ?? box.original
426 if (words === box.original) return closeBox($)
427 const note = { line: box.line, kind: 'changed', was: box.original, now: words } as const
428 await changeFile($, box.line, (file) => replaceLine(file, box.line, box.original, words), boxClosed, note)
429}
430
431/** Writes the file without the line the Edit box was opened on. */
432async function deleteLine($: EngineInterface) {
433 const box = await editBoxOf($)
434 if (box === null) return
435 const note = { line: box.line, kind: 'deleted', was: box.original, now: '' } as const
436 const deleted: Written = (view, text) => lineDeleted(view, linesOf(text).length)
437 await changeFile($, box.line, (file) => removeLine(file, box.line, box.original), deleted, note)
438}
439
440/** Writes a blank line after the line the Edit box was opened on, and opens the box on it. */
441async function addLine($: EngineInterface) {
442 const box = await editBoxOf($)
443 if (box === null) return
444 const note = { line: box.line + 1, kind: 'added', was: '', now: '' } as const
445 await changeFile($, box.line, (file) => insertAfter(file, box.line, box.original), lineAdded, note)
446}
447
448/** Closes the Comment box and the Edit box, and forgets what was typed in them. */
449async function closeBox($: EngineInterface) {
450 forgetTyped()
451 // Only an open box or a notice is written away: the keyboard leaving the pane closes the box each time, and every write redraws.
452 const acting = actingIn(await readFileView($))
453 if (acting?.box != null || acting?.notice != null) await moveFileView($, boxClosed)
454}
455
456/** The comment on the selected line, as Claude reads it; null where no line of a shown file is selected. */
457async function commentOnSelected($: EngineInterface, words: string): Promise<string | null> {
458 const [path, n] = [await read($, selected), actingIn(await readFileView($))?.line ?? null]
459 const text = shown === null ? null : textOf(shown.view ?? { note: '' }, shown.whole)
460 if (path === null || n === null || text === null) return null
461 const line = linesOf(text)[n - 1]
462 if (line === undefined) return null
463 return commentText(
464 { path: named(await $.session.cwd(), absoluteOf(await $.session.cwd(), path)), line: n, text: line },
465 words,
466 )
467}
468
469/** Holds the comment until the person's next message, and closes the box. */
470async function attachComment($: EngineInterface, words: string) {
471 if (words.trim() === '') return
472 const text = await commentOnSelected($, words)
473 await closeBox($)
474 if (text !== null) await update($, attached, (own) => [...attachedOf(own), text])
475}
476
477/** Drops the comments attached to the next message. */
478const discardComments = ($: EngineInterface) => update($, attached, () => [])
479
480/** Gives Claude the comment at once, as a message of its own, and closes the box. A message the engine refuses or cannot send leaves the box open with what was typed, and says so. */
481async function sendComment($: EngineInterface, words: string) {
482 if (words.trim() === '') return
483 const text = await commentOnSelected($, words)
484 if (text === null) return closeBox($)
485 const failure = await $.prompt.submit({ text, asUser: true }).then(
486 (r) => (r.drop === undefined ? null : `The comment was refused ${because(r.drop || REFUSED_BY_HOOK)}.`),
487 (err: unknown) => `The comment could not be sent ${because(errorText(err))}.`,
488 )
489 if (failure === null) await closeBox($)
490 else $.ui.toast(failure)
491}
492
493/** Moves the File view to the line `move` selects. A box that closes forgets what was typed in it; a box that stays keeps it. */
494async function moveToLine($: EngineInterface, move: (view: FileViewValue) => FileViewValue) {
495 if (actingIn(move(await readFileView($)))?.box == null) forgetTyped()
496 await moveFileView($, move)
497}
498
499/** Selects a line of the acting form: `Comment` and `Edit` are drawn under it. A Comment or Edit box open on another line closes. */
500const pickLine = ($: EngineInterface, n: number) => moveToLine($, (view) => linePicked(view, n))
501
502/** Switches the whole-file text to the acting form with a line selected: what a press on a `Line N` button under Changes does. A box kept while Read only on that line comes back. */
503const jumpToLine = ($: EngineInterface, n: number) => moveToLine($, (view) => jumpedToLine(view, n))
504
505/** Opens the pane at the default size; the engine keeps a size the person dragged. */
506const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: TITLE })
507
508/** The text a record last held of its file, or null where it holds none. */
509const textBefore = (e: RecordEntry | undefined): string | null => e?.lastSeen ?? e?.baseline ?? null
510
511/** The Touch log whole: its older piece and its newer one. */
512async function readLog($: EngineInterface) {
513 return logOf(await read($, log), await read($, logTail))
514}
515
516const inLogOrder = inOrder()
517
518/** Adds the entries to the Touch log. They wait in the small newer piece; once it is full, both move into the older piece. */
519const appendToLog = ($: EngineInterface, entries: Array<RanEntry | NewTouch>) =>
520 inLogOrder(async () => {
521 const tail = await read($, logTail)
522 const base = lastNumber(tail) ?? lastNumber(await read($, log)) ?? 0
523 const next = [...tail, ...numbered(base, entries)]
524 if (next.length <= MAX_TAIL) {
525 await update($, logTail, () => next)
526 return
527 }
528 await update($, log, (own) => logOf(own, next))
529 await update($, logTail, () => [])
530 })
531
532/** Adds the Touches to the records in one write; the first one of the session opens the pane. */
533async function recordAll(
534 $: EngineInterface,
535 touches: Touch[],
536 by: TouchBy,
537 afters: Map<string, string> = new Map(),
538 command?: string,
539) {
540 if (touches.length === 0) return
541 await relistWorktreesFor($, touches)
542 let isFirst = false
543 let prior: Record<string, RecordEntry> = {}
544 await update($, records, (own) => {
545 isFirst = Object.keys(own).length === 0
546 prior = own
547 return touches.reduce(fold, own)
548 })
549 const turn = currentTurn(await read($, turns))
550 const ran =
551 command === undefined
552 ? []
553 : [
554 {
555 type: 'ran' as const,
556 turn,
557 command,
558 files: touches.length,
559 ...(by.kind === 'helper' ? { helperId: by.id } : {}),
560 },
561 ]
562 const made = touches.map((t) => ({
563 kind: t.tag,
564 path: t.path,
565 turn,
566 by,
567 counts: countsOfTouch(t, { after: afters.get(t.path) ?? null, had: textBefore(prior[t.oldPath ?? t.path]) }),
568 ...(t.oldPath === undefined ? {} : { oldPath: t.oldPath }),
569 }))
570 await appendToLog($, [...ran, ...made])
571 if (isFirst && !(await read($, closed))) await openPane($)
572}
573
574/** Records the user's saved edit of a file: the file's record takes the Tag `Edited by you` and the new text, and the log gains a Touch made by the user with the lines it added and removed. */
575async function recordUserEdit($: EngineInterface, path: string, text: { before: string; after: string }) {
576 await update($, records, (own) =>
577 fold(own, { path, tag: 'Edited', baseline: null, seen: text.after, isByUser: true }),
578 )
579 const turn = currentTurn(await read($, turns))
580 await appendToLog($, [
581 { kind: 'Edited', path, turn, by: { kind: 'user' }, counts: diffCounts(text.before, text.after) },
582 ])
583}
584
585/** The address of the Reveal button, which a press or a focus names back. */
586const REVEAL_KEY = filesKey({ kind: 'button', name: 'reveal' })
587
588/** The address of the Open in Files button. */
589const OPEN_FILES_KEY = filesKey({ kind: 'button', name: 'openFiles' })
590
591/**
592 * Runs the action of a press. One that fails tells the user: the press hook's `.catch` would otherwise answer for the
593 * throw, leave the pane as it was and say nothing.
594 */
595async function reported($: EngineInterface, doing: string, action: () => Promise<unknown>) {
596 try {
597 await action()
598 } catch (err) {
599 $.ui.toast(failToast(doing, errorText(err)))
600 }
601}
602
603/** The action of each named button of the Files pane: the one copy, which `press` runs for a click and a first click alike. */
604const buttonActions = ($: EngineInterface, surface: RenderSurface): Record<FilesButton, () => Promise<unknown>> => ({
605 [REVEAL_KEY]: () => revealSelected($),
606 [OPEN_FILES_KEY]: () => openInFiles($, surface),
607 [CHANGED_ONLY_KEY]: () => toggleChangedOnly($),
608 [TRAIL_KEY]: () => openTrail($),
609 [WHOLE_KEY]: () => toggleWholeFile($),
610 [ACTING_KEY]: () => toggleActing($),
611 [COMMENT_KEY]: () => openComment($),
612 [SEND_KEY]: () => sendComment($, draft),
613 [ATTACH_KEY]: () => attachComment($, draft),
614 [EDIT_KEY]: () => openEdit($),
615 [SAVE_KEY]: () => saveEdit($, typed),
616 [ADD_KEY]: () => addLine($),
617 [DELETE_KEY]: () => deleteLine($),
618 [CANCEL_KEY]: () => closeBox($),
619 [DISCARD_KEY]: () => discardComments($),
620 [BACK_KEY]: () => closeFile($),
621})
622
623/**
624 * Runs the press a Files pane key names. The tracer's `ui.press` hook and a first click on the unfocused pane both call
625 * it, so each action is written once, and what it needs (the typed text, whether the app draws the session) is read
626 * when pressed, not when drawn. `surface` is where the press came from.
627 */
628const press = ($: EngineInterface, key: string | undefined, surface: RenderSurface) =>
629 reported($, pressAction(key), () => filesAction($, key, surface))
630
631/** The action a Files pane key names. */
632async function filesAction($: EngineInterface, key: string | undefined, surface: RenderSurface) {
633 const p = filesPressOf(key)
634 if (p === null) return
635 if (p.kind === 'button') return buttonActions($, surface)[p.name]()
636 if (p.kind === 'line') return pickLine($, p.n)
637 if (p.kind === 'jump') return jumpToLine($, p.n)
638 if (p.kind === 'group') return clickGroup($, p.path)
639 const abs = absoluteOf(await $.session.cwd(), p.path)
640 return p.kind === 'dir' ? clickFolder($, p.path, abs) : pressFile($, abs, surface)
641}
642
643/** The address of a tree row's button. */
644const rowKey = (r: Pick<Row, 'kind' | 'path'>) => filesKey({ kind: r.kind, path: r.path })
645
646/** The address of a line row's button in the acting form. */
647const lineKey = (n: number) => filesKey({ kind: 'line', n })
648
649/** The address of a `Line N` button under Changes. */
650const jumpKey = (n: number) => filesKey({ kind: 'jump', n })
651
652/** Who made a Touch: Claude's own loop (no agent id) or the helper whose loop raised the call. */
653const madeBy = (agentId: string | undefined): TouchBy =>
654 agentId === undefined ? { kind: 'claude' } : { kind: 'helper', id: agentId }
655
656/**
657 * The surface a first click replayed through `ui.focus` counts as coming from. The focus event names no surface, and
658 * the desktop app is the surface that spends a first click on focusing the pane; with the app attached, the replay
659 * takes the App form, as every press did before the form was picked per surface. The limit: should a terminal ever
660 * send a person's focus onto an unfocused pane, that replay would follow the Open rule too, not open the File view.
661 */
662const REPLAY_SURFACE = 'desktop'
663
664/**
665 * Whether a drawing or a press on `surface` takes the App form: the tree and the Trail without the File view. Where
666 * the desktop app is attached the mod still records, runs git and opens the pane as in the terminal. The roster is
667 * read per event, not once at start, because the app attaches after the session starts.
668 */
669const isAppOn = async ($: EngineInterface, surface: RenderSurface) => isAppFormOn(surface, await $.session.surfaces())
670
671function toolHooks(on: On) {
672 on('tool.call', async ($, e, next) => {
673 const r = await next(e)
674 const touch = r.deny === undefined && r.isError !== true ? classify(e, r.result) : null
675 if (touch !== null) await recordAll($, [touch], madeBy(e.agentId))
676 return r
677 }).catch(($, e, next) => next(e))
678}
679
680/** The most runs of git's hashing one command makes: the first, and a new one each time git stopped at a path it cannot hash. */
681const MAX_HASH_RUNS = 10
682
683/**
684 * One content hash per path (relative to `root`) that git could hash. Git hashes the paths in turn and stops at the
685 * first it cannot hash (a link to a folder, a submodule), having printed the hashes before it: skip that path and go
686 * on with the ones after it, up to MAX_HASH_RUNS runs in all.
687 */
688async function hashesOf($: EngineInterface, root: string, paths: string[]): Promise<Map<string, string>> {
689 const hashes = new Map<string, string>()
690 let rest = paths
691 for (let runs = 0; rest.length > 0 && runs < MAX_HASH_RUNS; runs += 1) {
692 // Each run needs the one before it: it starts where that one stopped.
693 const r = await $.process.run(gitIn(root, 'hash-object', '--stdin-paths'), {
694 stdin: `${rest.join('\n')}\n`,
695 timeoutMs: 3000,
696 })
697 const lines = r.stdout.split('\n').filter((line) => line !== '')
698 const done = r.exitCode === 0 ? rest.length : Math.min(lines.length, rest.length)
699 rest.slice(0, done).forEach((path, i) => hashes.set(path, lines[i] ?? ''))
700 rest = rest.slice(done + 1)
701 }
702 return hashes
703}
704
705/** The content hash at HEAD of each path (relative to `root`) that HEAD has. */
706async function headHashesOf($: EngineInterface, root: string, paths: string[]): Promise<Map<string, string>> {
707 if (paths.length === 0) return new Map()
708 const r = await $.process.run(gitIn(root, 'cat-file', '--batch-check'), {
709 stdin: paths.map((path) => `HEAD:${path}\n`).join(''),
710 timeoutMs: 3000,
711 })
712 const lines = r.stdout.split('\n')
713 const answers = paths.map((path, i) => [path, (lines[i] ?? '').split(' ')] as const)
714 return new Map(
715 answers.flatMap(([path, [hash, kind]]) => (kind === 'blob' && hash !== undefined ? [[path, hash]] : [])),
716 )
717}
718
719/** The text of a path (relative to `root`) at HEAD; null when HEAD has none. */
720async function headTextOf($: EngineInterface, root: string, path: string | null): Promise<string | null> {
721 if (path === null) return null
722 const r = await $.process.run(gitIn(root, 'show', `HEAD:${path}`), { timeoutMs: 3000 })
723 return r.exitCode === 0 ? r.stdout : null
724}
725
726/** The repository's top folder, from the session folder and where the session folder sits in the repository. */
727function rootOf(cwd: string, prefix: string): string | null {
728 const inside = prefix.trim().replace(/\/$/, '')
729 if (inside === '') return cwd
730 return cwd.endsWith(`/${inside}`) ? cwd.slice(0, cwd.length - inside.length - 1) : null
731}
732
733/** The most files a list of changed files, or of all files, holds. A longer list is given up: a part of one would show files as created or deleted that were not. */
734const MAX_LISTED = 20_000
735
736/**
737 * The files git lists as changed right now, with a hash of each one's content; null where git says it is no
738 * repository, 'too-many' past MAX_LISTED files or when the engine cut the list short, and a rejection where git cannot run.
739 */
740async function gitSnapshotOf($: EngineInterface, cwd: string): Promise<GitSnapshot | null | 'too-many'> {
741 const [listing, where] = await Promise.all([
742 $.process.run(gitIn(cwd, 'status', '--porcelain=v1', '-z', '--untracked-files=all'), { timeoutMs: 3000 }),
743 $.process.run(gitIn(cwd, 'rev-parse', '--show-prefix'), { timeoutMs: 3000 }),
744 ])
745 const root = rootOf(cwd, where.stdout)
746 if (listing.exitCode !== 0 || where.exitCode !== 0 || root === null) return null
747 const listed = parseStatus(listing.stdout)
748 if (listing.isStdoutTruncated || listed.length > MAX_LISTED) return 'too-many'
749 const hashes = await hashesOf(
750 $,
751 root,
752 listed.filter((entry) => !entry.code.includes('D')).map((entry) => entry.path),
753 )
754 return {
755 kind: 'git',
756 root,
757 entries: new Map(listed.map((entry) => [entry.path, { ...entry, hash: hashes.get(entry.path) ?? null }])),
758 }
759}
760
761/** The most files whose text at HEAD one command asks git for. */
762const MAX_BASELINES = 100
763
764/** Every file under the session folder, except under `.git` and `node_modules`, found with `find`; null when there are too many or the engine cut the list short. */
765async function fileSnapshotOf($: EngineInterface, cwd: string): Promise<FileSnapshot | null> {
766 const r = await $.process.run(
767 [
768 'find',
769 cwd,
770 '-type',
771 'd',
772 '(',
773 '-name',
774 '.git',
775 '-o',
776 '-name',
777 'node_modules',
778 ')',
779 '-prune',
780 '-o',
781 '-type',
782 'f',
783 '-print0',
784 ],
785 { timeoutMs: 3000 },
786 )
787 const paths = r.stdout.split('\0').filter((path) => path !== '')
788 return r.isStdoutTruncated || paths.length > MAX_LISTED ? null : { kind: 'files', paths: new Set(paths) }
789}
790
791/**
792 * Whether git cannot start at all. A program that cannot start rejects, and so does one that outruns its time, in
793 * words the engine does not document; so ask git its version, which it answers at once wherever it is installed.
794 */
795const isGitMissing = ($: EngineInterface) =>
796 $.process.run(['git', '--version'], { timeoutMs: 3000 }).then(
797 () => false,
798 () => true,
799 )
800
801/**
802 * Where Claude's command can be watched: git's list of changed files, else a list of every file; null when neither
803 * answers, and when git outruns its time (then nothing is watched, whatever else could answer).
804 */
805async function snapshotOf($: EngineInterface): Promise<Snapshot | null> {
806 const cwd = await $.session.cwd()
807 let git: GitSnapshot | null | 'too-many' = null
808 try {
809 git = await gitSnapshotOf($, cwd)
810 } catch {
811 if (!(await isGitMissing($))) return null
812 }
813 if (git === 'too-many') return null
814 if (git !== null) return git
815 return fileSnapshotOf($, cwd).catch(() => null)
816}
817
818/** The untracked files git listed before the command and no more after it, that are no longer on disk: the command deleted them. */
819async function vanishedSince($: EngineInterface, before: GitSnapshot, after: GitSnapshot): Promise<Set<string>> {
820 const checked = await Promise.all(
821 maybeVanished(before, after).map(async (path) => [path, await $.fs.exists(`${after.root}/${path}`)] as const),
822 )
823 return new Set(checked.filter(([, isThere]) => !isThere).map(([path]) => path))
824}
825
826/** The Touches of a command that ran between `before` and now, each with the text it had before when HEAD can say. */
827async function touchesSince($: EngineInterface, before: Snapshot): Promise<Touch[]> {
828 const after = await snapshotOf($)
829 if (before.kind === 'files') return after?.kind === 'files' ? fileTouchesBetween(before, after) : []
830 if (after?.kind !== 'git') return []
831 const vanished = await vanishedSince($, before, after)
832 const heads = await headHashesOf($, after.root, headHashesNeeded(before, after, vanished))
833 const saved = await read($, records)
834 const found = touchesBetween(before, after, vanished, heads)
835 // A file the records hold no baseline for, clean before the command: HEAD holds the text it had at the session's start.
836 // A moved file whose old place the records hold a baseline for needs none: the records carry that one over.
837 // Only the first MAX_BASELINES are asked for, so a command that changes thousands of files starts few programs.
838 const hasBaseline = (path: string | undefined) => {
839 const e = path === undefined ? undefined : saved[path]
840 return e !== undefined && (e.baseline !== null || e.isBaselineTooBig)
841 }
842 const isKnown = (t: Touch) => hasBaseline(t.path) || hasBaseline(t.oldPath)
843 const unseen = found.filter((f) => f.headPath !== null && !isKnown(f.touch))
844 const asked = new Set(unseen.slice(0, MAX_BASELINES))
845 return Promise.all(
846 found.map(async (f) =>
847 asked.has(f) ? { ...f.touch, baseline: await headTextOf($, after.root, f.headPath) } : f.touch,
848 ),
849 )
850}
851
852/** A helper Claude starts is kept by its agent id with its type and description, so its Touches can be named. */
853export async function agentSpawn(
854 $: EngineInterface,
855 e: EventIn<'agent.spawn'>,
856 next: PlainNext<'agent.spawn'>,
857): Promise<EventOut<'agent.spawn'>> {
858 const r = await next(e)
859 const id = r.agentId
860 if (r.deny === undefined && id !== undefined) {
861 const helper = { id, type: e.subagentType, description: e.description }
862 await update($, helpers, (own) => [...own.filter((h) => h.id !== id), helper])
863 }
864 return r
865}
866
867/** The most files whose text after a command is read to count the lines it changed. */
868const MAX_COUNT_READS = 100
869
870/** The text each file a command changed has now, for the first MAX_COUNT_READS files that are still there; a file that cannot be read has none. */
871async function textsAfter($: EngineInterface, touches: Touch[]): Promise<Map<string, string>> {
872 const asked = touches.filter((t) => t.tag !== 'Deleted' && t.tag !== 'Read').slice(0, MAX_COUNT_READS)
873 const read = await Promise.all(asked.map(async (t) => [t.path, await $.fs.read(t.path).catch(() => null)] as const))
874 return new Map(read.flatMap(([path, text]) => (text === null ? [] : [[path, text] as const])))
875}
876
877function commandHooks(on: On) {
878 // A shell command Claude runs touches the files it changes: git's list of changed files, before and after.
879 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
880 const before = await snapshotOf($)
881 const r = await next(e)
882 // A command that failed or was refused changes nothing here, whatever it left on disk.
883 if (before !== null && r.deny === undefined && r.isError !== true) {
884 // A command whose Touches could not all be found makes none: git ran out of time, or answered half.
885 const touches = await touchesSince($, before).catch(() => [])
886 const command = commandLine(e.command)
887 const by = e.agentId === undefined ? { kind: 'command' as const, text: command } : madeBy(e.agentId)
888 await recordAll($, touches, by, await textsAfter($, touches), command)
889 }
890 return r
891 }).catch(($, e, next) => next(e))
892}
893
894export async function sessionStart(
895 $: EngineInterface,
896 e: EventIn<'session.start'>,
897 next: PlainNext<'session.start'>,
898): Promise<EventOut<'session.start'>> {
899 const out = await next(e)
900 await listWorktrees($)
901 await $.command.register({ name: COMMAND, description: 'Show the file tracer: the files Claude touched' })
902 return out
903}
904
905function sessionHooks(on: On) {
906 on('command.run', { command: COMMAND }, async ($, e) => {
907 if (e.args.trim() === 'trail') {
908 await openTrail($)
909 return { text: 'Trail opened.' }
910 }
911 await update($, closed, () => false)
912 await openPane($)
913 return { text: 'File tracer opened.' }
914 })
915}
916
917/** The person's next message ends the stretch the dots mark. The prompt itself goes on untouched. */
918export async function promptSubmit(
919 $: EngineInterface,
920 e: EventIn<'prompt.submit'>,
921 next: PlainNext<'prompt.submit'>,
922): Promise<EventOut<'prompt.submit'>> {
923 await update($, records, (own) => clearRecent(own))
924 const logged = await readLog($)
925 await update($, turns, (own) => startTurn(own, logged, e.text))
926 // The comments the person attached ride on their next prompt, then none is left.
927 const isUsers = isUsersPrompt(e.origin)
928 const taken = isUsers ? attachedOf(await read($, attached)) : []
929 const told = isUsers ? notesOf(await read($, notes)).map(noteText) : []
930 const out = await next(
931 taken.length + told.length === 0 ? e : { ...e, text: withComments(e.text, [...taken, ...told]) },
932 )
933 if (taken.length > 0) await update($, attached, (own) => attachedOf(own).slice(taken.length))
934 if (told.length > 0) await update($, notes, (own) => notesOf(own).slice(told.length))
935 return out
936}
937
938function closeHooks(on: On) {
939 on('ui.close', async ($, e, next) => {
940 if (isUserClose(e)) await update($, closed, () => true)
941 return next(e)
942 })
943}
944
945const GREY = 'inactive'
946
947/** The counts at the near end of the top row, `24 files | 6 changed | +120 -14`, a count of zero left out; a blank cell while nothing is touched, so the buttons stay at the far end. */
948function summaryText(els: ElementTable, { files, changed, added, removed }: Summary) {
949 const { Text } = els
950 if (files === 0) return <Text> </Text>
951 const separator = <Text color="subtle">{' | '}</Text>
952 return (
953 <Text color={GREY}>
954 {files === 1 ? '1 file' : `${files} files`}
955 {separator}
956 {`${changed} changed`}
957 {added === 0 && removed === 0 ? null : separator}
958 {added === 0 ? null : <Text color="success">{`+${added}`}</Text>}
959 {added === 0 || removed === 0 ? null : ' '}
960 {removed === 0 ? null : <Text color="error">{`-${removed}`}</Text>}
961 </Text>
962 )
963}
964
965/** The line the pane draws while no file has been touched. */
966const EMPTY = 'No files yet. A file appears here when Claude touches one.'
967
968/** The line the pane draws while `Changed only` is on and no touched file has changed. */
969const NONE_CHANGED = 'No changed files yet. A file appears here when Claude changes one.'
970
971/** The line that says why the tree is empty, or nothing while the tree has rows: no file touched yet, or `Changed only` with none changed. */
972function emptyLine(els: ElementTable, touchedFiles: number, rows: Row[]) {
973 const { Box, Text } = els
974 if (rows.length > 0) return null
975 return (
976 <Box key="empty">
977 <Text color={GREY}>{touchedFiles === 0 ? EMPTY : NONE_CHANGED}</Text>
978 </Box>
979 )
980}
981
982/** The line standing in the Description's place while its request is out. */
983const WRITING = 'Writing a description…'
984// Theme colours by Tag: grey for a Read, yellow for an Edit, green for a new file, red for a deletion.
985// A move takes the theme's `merged` key.
986const COLOR: Partial<Record<Tag, string>> = {
987 Read: GREY,
988 Edited: 'warning',
989 Created: 'success',
990 Deleted: 'error',
991 Moved: 'merged',
992}
993
994/**
995 * The heading of a Group: a chevron to open or close it, its name in bold, its count, and its place on disk at the
996 * trailing edge. In the terminal only the chevron is a Button, since a Button's label takes no colour. On the desktop
997 * the chevron is a picture, as in the tree's folder rows, and the name is the Button.
998 */
999function groupRow($: EngineInterface, els: ElementTable, look: Look, r: Row) {
1000 const { Box, Text, Button } = els
1001 const Svg = svgOf(look.surface, els)
1002 return (
1003 <Box key={`section:${r.path}`} flexDirection="row" gap={1}>
1004 <Text> </Text>
1005 {Svg === null ? (
1006 <Button key={rowKey(r)} plain onPress={takenByPressHook}>
1007 {r.isOpen ? '▾' : '▸'}
1008 </Button>
1009 ) : (
1010 <Box key={`chevron:${r.path}`} width={2} flexShrink={0}>
1011 <Svg source={chevronSvg(r.isOpen)} alt={r.isOpen ? 'chevron down' : 'chevron right'} width={14} height={14} />
1012 </Box>
1013 )}
1014 <Box key={`name:${r.path}`} flexShrink={0}>
1015 {Svg === null ? (
1016 <Text color="text" bold>
1017 {r.name}
1018 </Text>
1019 ) : (
1020 <Button key={rowKey(r)} plain onPress={takenByPressHook}>
1021 <Text bold>{r.name}</Text>
1022 </Button>
1023 )}
1024 </Box>
1025 <Text color={GREY}>{String(r.count)}</Text>
1026 <Box flexGrow={1} />
1027 <Box key={`place:${r.path}`} flexShrink={1}>
1028 <Text color={GREY} wrap="truncate-middle">
1029 {middleCut(r.place, placeRoom(look.columns, r.name, r.count))}
1030 </Text>
1031 </Box>
1032 </Box>
1033 )
1034}
1035
1036/** The props that fill the selected row across its width on the desktop; none for any other row, and none in the terminal, which marks the row with `›` and bold. A prop left undefined makes the engine refuse the drawing, so an unselected row gets no key at all. */
1037const fillOf = (isSelected: boolean, look: Look) =>
1038 isSelected && look.isDesktop ? { backgroundColor: SELECTED_FILL } : {}
1039
1040/** The marker column of a row: `›` in the focus colour on the selected row, a blank cell otherwise. */
1041function marker(els: ElementTable, isSelected: boolean) {
1042 const { Text } = els
1043 return isSelected ? <Text color="suggestion">›</Text> : <Text> </Text>
1044}
1045
1046/** The cells a row's tree lines take: two per level. */
1047const lineWidth = (r: Row) => r.lines.length * 2
1048
1049/** The tree lines of a row, one two-cell box per level: a picture on the desktop, `├─`, `│` or `└─` in the terminal; none for a row at the top. They sit inside the chevron's cell, so the last of them falls in the column of its folder's chevron. */
1050function lineCells(els: ElementTable, look: Look, r: Row) {
1051 const { Box, Text } = els
1052 const Svg = svgOf(look.surface, els)
1053 if (r.lines.length === 0) return null
1054 const cellView = (cell: LineCell) => {
1055 if (Svg === null) return <Text color="subtle">{LINE_LETTERS[cell]}</Text>
1056 if (cell === 'blank') return null
1057 return <Svg source={lineSvg(cell)} alt={LINE_ALT[cell]} width={14} height={LINE_HEIGHT} />
1058 }
1059 return (
1060 <Box flexShrink={0}>
1061 {r.lines.map((cell) => (
1062 <Box width={2} flexShrink={0}>
1063 {cellView(cell)}
1064 </Box>
1065 ))}
1066 </Box>
1067 )
1068}
1069
1070/** A folder row: the marker, the chevron, on the desktop the folder icon, then the name as the only clickable part; in the terminal the chevron is `▸` or `▾` and the name ends in a slash. */
1071function dirRow($: EngineInterface, els: ElementTable, selectedPath: string | null, look: Look, r: Row) {
1072 const { Box, Text, Button } = els
1073 const Svg = svgOf(look.surface, els)
1074 const isSelected = r.abs === selectedPath
1075 const label = endCut(Svg === null ? `${r.name}/` : r.name, folderRoom(look.columns, r.lines.length, Svg !== null))
1076 return (
1077 <Box key={`row:${r.path}`} flexDirection="row" gap={1} {...fillOf(isSelected, look)}>
1078 {marker(els, isSelected && !look.isDesktop)}
1079 <Box key={`chevron:${r.path}`} flexDirection="row" width={lineWidth(r) + (Svg === null ? 1 : 2)} flexShrink={0}>
1080 {lineCells(els, look, r)}
1081 {Svg === null ? (
1082 <Text color={GREY}>{r.isOpen ? '▾' : '▸'}</Text>
1083 ) : (
1084 <Svg source={chevronSvg(r.isOpen)} alt={r.isOpen ? 'chevron down' : 'chevron right'} width={14} height={14} />
1085 )}
1086 </Box>
1087 {Svg === null ? null : (
1088 <Box key={`icon:${r.path}`} width={2} flexShrink={0}>
1089 <Svg source={folderSvg(r.isOpen)} alt="folder" width={14} height={14} />
1090 </Box>
1091 )}
1092 <Box key={`name:${r.path}`} flexShrink={1}>
1093 <Button key={rowKey(r)} plain onPress={takenByPressHook}>
1094 {isSelected ? <Text bold>{label}</Text> : label}
1095 </Button>
1096 </Box>
1097 </Box>
1098 )
1099}
1100
1101/** The text in a file row's name button, cut by the mod to the room its row leaves: at its end, or, for a whole path, in the middle. The desktop app does not cut a row to its column, and its own cut would fall on the button's chrome. */
1102function nameText(els: ElementTable, look: Look, r: Row, isSelected: boolean) {
1103 const { Text } = els
1104 const bold = isSelected ? { bold: true } : {}
1105 if (r.isPath)
1106 return (
1107 <Text wrap="truncate-middle" {...bold}>
1108 {middleCut(r.name, nameRoom(look.columns, { ...r, tag: r.tagWords, countCells: countCellsOf(r.counts) }))}
1109 </Text>
1110 )
1111 return (
1112 <Text wrap="truncate-end" {...bold}>
1113 {endCut(r.name, nameRoom(look.columns, { ...r, tag: r.tagWords, countCells: countCellsOf(r.counts) }))}
1114 </Text>
1115 )
1116}
1117
1118/** The Tag and the dot after a file's name: coloured Text beside the button, since a button's label takes no colour. A deleted file's Tag is struck, since the app drops a line through the name inside a button. */
1119function tagCells(els: ElementTable, r: Row) {
1120 const { Box, Text } = els
1121 const color = r.tag === null ? 'text' : (COLOR[r.tag] ?? GREY)
1122 return (
1123 <Box key={`tail:${r.path}`} flexShrink={0} gap={1}>
1124 {r.tag === null ? null : (
1125 <Text color={color} strikethrough={r.tag === 'Deleted'}>
1126 {r.tagWords}
1127 </Text>
1128 )}
1129 {r.isRecent ? <Text color={color}>●</Text> : null}
1130 </Box>
1131 )
1132}
1133
1134/** The lines added and removed, in two four-cell columns at the row's trailing edge; a column is blank where its figure is zero or unknown. */
1135function countCells(els: ElementTable, r: Row) {
1136 const { Box, Text } = els
1137 const [added, removed] = countPair(r.counts)
1138 return (
1139 <Box key={`counts:${r.path}`} flexShrink={0} gap={1}>
1140 <Box width={Math.max(COUNT_CELLS, added.length)} flexShrink={0} justifyContent="flex-end">
1141 {added === '' ? null : <Text color="success">{added}</Text>}
1142 </Box>
1143 <Box width={Math.max(COUNT_CELLS, removed.length)} flexShrink={0} justifyContent="flex-end">
1144 {removed === '' ? null : <Text color="error">{removed}</Text>}
1145 </Box>
1146 </Box>
1147 )
1148}
1149
1150/**
1151 * A file row: the marker, the name as the only clickable part, the Tag, the dot. The desktop app drops colour on
1152 * anything inside a Button, so the Tag and the dot sit beside it as coloured Text. A Button's label can only be dimmed.
1153 */
1154function fileRow($: EngineInterface, els: ElementTable, selectedPath: string | null, look: Look, r: Row) {
1155 const { Box, Button } = els
1156 const icon = typeIconOf(r.name)
1157 const Svg = svgOf(look.surface, els)
1158 const isSelected = r.abs === selectedPath
1159 return (
1160 <Box key={`row:${r.path}`} flexDirection="row" gap={1} {...fillOf(isSelected, look)}>
1161 {marker(els, isSelected && !look.isDesktop)}
1162 <Box key={`chevron:${r.path}`} flexDirection="row" width={lineWidth(r) + 2} flexShrink={0}>
1163 {lineCells(els, look, r)}
1164 </Box>
1165 <Box key={`icon:${r.path}`} width={2} flexShrink={0}>
1166 {Svg !== null ? (
1167 <Svg source={iconSvg(icon)} alt={icon.alt} width={14} height={14} />
1168 ) : (
1169 <els.Text color={icon.color}>{icon.letters}</els.Text>
1170 )}
1171 </Box>
1172 <Box key={`name:${r.path}`} flexShrink={1}>
1173 <Button key={rowKey(r)} plain dimColor={r.tag === 'Read'} onPress={takenByPressHook}>
1174 {nameText(els, look, r, isSelected)}
1175 </Button>
1176 </Box>
1177 {tagCells(els, r)}
1178 <Box flexGrow={1} />
1179 {countCells(els, r)}
1180 </Box>
1181 )
1182}
1183
1184/** What a row's drawing depends on beyond the row: the pane's width in cells, the surface it is on, whether that surface draws pictures, and whether the Files pane is in its App form (set by that pane's render only; a press there selects the row, with no File view). */
1185type Look = { columns: number; surface: RenderSurface; isDesktop: boolean; isApp: boolean }
1186
1187/** How a pane is drawn, for the Files pane and the Trail alike: its width, the surface it is on and whether that is the desktop app's App form. */
1188const lookOf = async ($: EngineInterface, surface: RenderSurface, columns: number): Promise<Look> => ({
1189 columns,
1190 surface,
1191 isDesktop: surface !== 'terminal',
1192 isApp: await isAppOn($, surface),
1193})
1194
1195/**
1196 * The letters that press the panes' buttons in the terminal, each its own so that no two buttons drawn at once share
1197 * one.
1198 */
1199const HOTKEY = {
1200 changedOnly: 'c',hooks/handler.ts 27 lines1// The types an area's shared-event handler is declared with. The engine's `Next` and `StreamNext` carry members no
2// area uses, and a handler declared with these narrower types still fits `on(event, {}, handler)`: the engine's
3// `next` has the call these name. Also the one closure every drawn Button carries. No engine calls, no `$`.
4import type { Args, Chunk, EventName, EventResult, Frozen, StreamingEventName } from 'claude-code'
5
6export type EventIn<N extends EventName> = Frozen<Args<N>>
7export type EventOut<N extends EventName> = EventResult<N>
8/** A plain event's `next`, narrowed to its call. */
9export type PlainNext<N extends EventName> = (e: EventIn<N>) => Promise<EventOut<N>>
10/** A streaming event's `next`: opens the stream beneath, afresh on every call. */
11export type StreamingNext<N extends StreamingEventName> = (e: EventIn<N>) => AsyncGenerator<Chunk<N>, EventOut<N>>
12
13/**
14 * The `onPress` every drawn Button passes, since the prop is required. Each area's `ui.press` hook takes the press by
15 * returning `{ element }`, so the engine never gets down to this closure: it never runs.
16 */
17export const takenByPressHook = (): void => {}
18
19/** The mod's name as the engine stamps it on a press: the `name` in desk/.claude-plugin/plugin.json. handler.test.ts fails if the two differ. */
20export const OWN_PLUGIN = 'desk'
21
22/**
23 * Whether a pressed Button is one this mod drew. The press chain runs for every plugin's Buttons, and another plugin may
24 * key one like ours. A `ui.focus` event may carry no plugin; that is not ours.
25 */
26export const isOwnPress = (e: { plugin?: string }): boolean => e.plugin === OWN_PLUGIN
27hooks/failures.ts 52 lines1// The words every failure toast and notice uses to give its reason, shared by the three areas. Plain logic, no engine
2// calls.
3
4/** What a reason reads when the thrown value or the refusal gave none. */
5const NO_REASON = 'no reason given'
6
7/** What a refused prompt's reason reads when the hook that refused it gave none. */
8export const REFUSED_BY_HOOK = 'another hook refused it'
9
10/**
11 * A reason trimmed, with the full stops it ends with cut off so a sentence made around it never doubles the period.
12 * Empty when the reason was blank.
13 */
14const trimmed = (reason: string): string => reason.trim().replace(/\.+$/, '').trim()
15
16/** The text as it is, or "no reason given" where it is blank. */
17const orNone = (text: string): string => (text.trim() === '' ? NO_REASON : text)
18
19/** A reason as a sentence part: trimmed, final full stops cut, and "no reason given" where it was blank. */
20export const reasonText = (reason: string): string => orNone(trimmed(reason))
21
22/**
23 * The words to show for a thrown value: an Error's message, or whatever else was thrown as a string. Never empty: a
24 * blank one reads "no reason given".
25 */
26export const errorText = (err: unknown): string => {
27 const text = err instanceof Error ? err.message : String(err)
28 return orNone(text)
29}
30
31/**
32 * Two styles of failure toast. "Could not <action>: <reason>." (this function) is for a press whose action threw: the
33 * catch-all of a press hook, and the tracer's wrapper. Every other failure toast keeps the words the user already saw,
34 * with `because`'s bracketed reason added: "<old words> (<reason>)." Exceptions, by what they are. A press that catches
35 * its own failure beside an old toast for the same action (the side panel's open) keeps that old bracket style. The
36 * Files pane and Agents pane opens keep their old colon wording, with no closing full stop and no trimming of the
37 * reason. The reveal toast and the open-in-Files notice keep their old words too; reveal has no closing full stop. The
38 * turn-ended toast writes its brackets from fixed words, not from a thrown reason. The reason loses any full stops it
39 * ends with, so the period is never doubled. A reason left empty (an Error with no message, or only dots and spaces)
40 * drops the colon: "Could not <action>." Advice for the user goes after it as its own sentence.
41 */
42export const failToast = (action: string, reason: string, advice?: string): string => {
43 const why = trimmed(reason)
44 return `Could not ${action}${why === '' ? '' : `: ${why}`}.${advice === undefined ? '' : ` ${advice}`}`
45}
46
47/**
48 * A reason in round brackets, for the toasts that keep their old words and add why: full stops cut off its end, and a
49 * blank one reads "no reason given".
50 */
51export const because = (reason: string): string => `(${reasonText(reason)})`
52hooks/surface.ts 32 lines1// What a screen can draw: the facts the band, the board and the tracer share. Plain values only; each area's own
2// register.tsx asks the engine for the surface or the roster and passes it here.
3import type { ElementTable, RenderSurface } from 'claude-code'
4
5/**
6 * The Svg element, or null where this surface draws no pictures: the terminal, whose table offers an Svg that draws
7 * nothing, and any table without one. Text marks stand in for the pictures there.
8 */
9export const svgOf = (surface: RenderSurface, els: ElementTable) =>
10 surface !== 'terminal' && 'Svg' in els && els.Svg !== undefined ? els.Svg : null
11
12/**
13 * The Input element, or null where this surface draws no typing field: the phone, whose table offers an Input that
14 * draws nothing (the engine docs: mobile has no Input or Select), and any table without one.
15 */
16export const inputOf = (surface: RenderSurface, els: ElementTable) =>
17 surface !== 'mobile' && 'Input' in els && els.Input !== undefined ? els.Input : null
18
19/**
20 * The cells a native Button draws beyond its label, off the terminal. Approximate: estimated from a pixel scan of the
21 * 2026-10-15 20:03 EDT screenshot of the narrow Agents pane (docs/log/2026-10-15.md); which Button was scanned was not
22 * recorded. About 5 CSS px of padding each side, about 1.2 columns in all, so 2 is a safe overestimate. Change it here
23 * if the app shows another width.
24 */
25export const BUTTON_PAD = 2
26
27/** The cells a Button draws beyond its label on this surface: none in the terminal, whose Buttons are text. */
28export const buttonPad = (surface: RenderSurface): number => (surface === 'terminal' ? 0 : BUTTON_PAD)
29
30/** Whether the desktop app is attached, given the roster `$.session.surfaces()` returns (it can list several). */
31export const isAppAttached = (roster: readonly string[]): boolean => roster.includes('desktop')
32hooks/band/cache.ts 116 lines1// The cache item: is the main thread's prompt cache still alive, and what a lapse costs.
2// Nothing here calls the engine: the validator follows `$` only within one file, so the hooks live in register.tsx.
3import type { AutoWarm, CacheSeen } from '../../types'
4
5export const FIVE_MIN = 5 * 60_000
6export const ONE_HOUR = 60 * 60_000
7// Used until the transcript names the lifetime. The shorter one, so the item never claims warm past a lapse.
8const DEFAULT_TTL = FIVE_MIN
9export const TICK_MS = 30_000
10/** How long Auto warm stays on once pressed. */
11const AUTO_WARM_SPAN = 3 * 60 * 60_000
12/** What the band's cache item adds while Auto warm is on, after a separator like the band's others. */
13export const AUTO_MARK = 'auto'
14/** The text of the Keep-warm ping. The band tells its own ping from the user's prompt by this text. */
15export const KEEP_WARM_PROMPT = 'Keep-warm ping from desk Auto warm. Reply with only: ok'
16/** Auto warm pings once the hour-long cache has this many minutes or fewer left. */
17const PING_MARGIN_MIN = 10
18/** No second ping this soon after the last one, so two ticks never ping twice. */
19const PING_GAP = 60_000
20export const NO_AUTO_WARM: AutoWarm = { isOn: false, offAt: 0 }
21
22/** Saved state outlives a reload: fills in a field a half-written save left out, so a missing off time cannot keep the switch on. */
23export const autoWarmOf = (saved: AutoWarm): AutoWarm => ({ ...NO_AUTO_WARM, ...saved })
24
25// The turn's usage carries no lifetime; the transcript records each write split by it.
26// Its tail holds the last writes; a whole read would hit the 4 MiB limit on a long session.
27export const TTL_SCRIPT = `tail -c 2000000 "$1" | grep -o '"cache_creation":{[^}]*}' | tail -n 50`
28
29/** The lifetime of the newest cache write in `grep` output from TTL_SCRIPT; null when none wrote. */
30export function ttlFrom(out: string): number | null {
31 const lines = out.trim().split('\n').reverse()
32 for (const l of lines) {
33 const hour = Number(/"ephemeral_1h_input_tokens":(\d+)/.exec(l)?.[1] ?? 0)
34 const five = Number(/"ephemeral_5m_input_tokens":(\d+)/.exec(l)?.[1] ?? 0)
35 if (hour > 0) return ONE_HOUR
36 if (five > 0) return FIVE_MIN
37 }
38 return null
39}
40
41type Usage = {
42 input_tokens: number
43 output_tokens: number
44 cache_read_input_tokens: number
45 cache_creation_input_tokens: number
46}
47
48export const prefixOf = (u: Usage) =>
49 u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
50
51export type CacheView =
52 { state: 'warm'; coldAt: number; minutesLeft: number } | { state: 'cold'; coldAt: number; prefix: number }
53
54export function cacheView(c: CacheSeen, now: number): CacheView {
55 const coldAt = c.sentAt + (c.ttlMs ?? DEFAULT_TTL)
56 if (now >= coldAt) return { state: 'cold', coldAt, prefix: c.prefix }
57 return { state: 'warm', coldAt, minutesLeft: Math.max(1, Math.ceil((coldAt - now) / 60_000)) }
58}
59
60/** A time of day in Eastern time, labeled EDT or EST. */
61export function eastern(ms: number): string {
62 try {
63 return new Date(ms).toLocaleTimeString('en-US', {
64 timeZone: 'America/New_York',
65 hour: 'numeric',
66 minute: '2-digit',
67 timeZoneName: 'short',
68 })
69 } catch {
70 return `${new Date(ms).toISOString().slice(11, 16)} UTC`
71 }
72}
73
74/** Whether the cache lasts an hour: the only kind Auto warm can keep warm. False while the lifetime is unread. */
75const isHourCache = (c: CacheSeen | null): boolean => c?.ttlMs === ONE_HOUR
76
77type PressOn = { isOk: true; auto: AutoWarm } | { isOk: false; toast: string }
78
79/** Whether Auto warm may be turned on now, and the switch it sets: on until AUTO_WARM_SPAN from `now`. Else the toast's reason. */
80export function pressOn(c: CacheSeen | null, now: number): PressOn {
81 if (!isHourCache(c)) return { isOk: false, toast: 'Auto warm needs the hour-long cache. Yours lasts 5 minutes now.' }
82 return { isOk: true, auto: { isOn: true, offAt: now + AUTO_WARM_SPAN } }
83}
84
85/** The band's last Keep-warm ping: when it went, and whether it still waits for its answer. */
86export type Ping = { at: number; isWaiting: boolean }
87export const NO_PING: Ping = { at: Number.NEGATIVE_INFINITY, isWaiting: false }
88
89/** What one tick does about Auto warm: nothing, switch off, or send the Keep-warm ping. */
90type TickAction = { kind: 'stay' } | { kind: 'off' } | { kind: 'ping' }
91
92export function tickAction(auto: AutoWarm, c: CacheSeen | null, t: number, ping: Ping): TickAction {
93 if (!auto.isOn) return { kind: 'stay' }
94 if (t >= auto.offAt || c?.ttlMs === FIVE_MIN) return { kind: 'off' }
95 return isPingDue(c, t, ping) ? { kind: 'ping' } : { kind: 'stay' }
96}
97
98// Whether the cache is at the point to ping: hour-long and warm with ten minutes or fewer left, the guard passed,
99// and the last ping answered (the cache's last request came after it) or never sent.
100function isPingDue(c: CacheSeen | null, t: number, ping: Ping): boolean {
101 if (c === null || !isHourCache(c) || t - ping.at < PING_GAP) return false
102 if (ping.isWaiting && c.sentAt < ping.at) return false
103 const view = cacheView(c, t)
104 return view.state === 'warm' && view.minutesLeft <= PING_MARGIN_MIN
105}
106
107/** The cache after a main-thread request sent at `sentAt`: its clock restarts, and the lifetime already read stays. */
108export const cacheAfterReply = (c: CacheSeen | null, sentAt: number, prefix: number): CacheSeen => ({
109 sentAt,
110 prefix,
111 ttlMs: c?.ttlMs ?? null,
112})
113
114/** Whether a submitted prompt ends Auto warm: any but the band's own ping is the user's (or another mod's) next message. */
115export const promptEndsAutoWarm = (text: string, auto: AutoWarm): boolean => text !== KEEP_WARM_PROMPT && auto.isOn
116hooks/board/agents.ts 219 lines1// The agent cards' data and wording. Nothing here calls the engine: the
2// validator follows `$` only within one file, so the hooks live in register.tsx.
3// A card appears when an agent spawns. Finished cards stay until the next batch
4// starts, that is, until an agent spawns while none is running.
5import type { ToolSpec } from 'claude-code'
6
7import type { AgentRow, AgentState, Step, StepStatus } from '../../types'
8import { reasonText } from '../failures'
9import { isLive, isLiveState } from '../live'
10import type { BarKind, Hat } from './critter'
11
12export const PANE = 'agent-board'
13export const TITLE = 'Agents'
14export const COMMAND = 'agent-board'
15
16/** The two sections of an open card that close; its footer does not. */
17export type SectionName = 'brief' | 'transcript'
18
19// Subagents get no to-do tools, so the board gives them its own: a whole list of steps per call.
20const TOOL_NAME = 'report_progress'
21export const TOOL = `mcp__desk__${TOOL_NAME}`
22
23const STATUSES: readonly StepStatus[] = ['pending', 'in_progress', 'completed']
24
25// The tool's input, by hand: until the engine has loaded this mod with the tool, its generated
26// types lack it, and a tool.call hook on an unknown tool sends TypeScript searching every tool's
27// shape until it runs out of memory. This matches the shape the engine writes from TOOL_SPEC.
28declare module 'claude-code' {
29 interface McpToolInputs {
30 mcp__desk__report_progress: { steps: { title: string; status: StepStatus }[] }
31 }
32}
33
34export const TOOL_SPEC: ToolSpec = {
35 name: TOOL_NAME,
36 description:
37 'Shows your steps on the agent board, for subagents. Send the whole list each time: once when you start, then whenever a step starts or finishes. Keep each title under six words.',
38 inputSchema: {
39 type: 'object',
40 properties: {
41 steps: {
42 type: 'array',
43 items: {
44 type: 'object',
45 properties: { title: { type: 'string' }, status: { type: 'string', enum: STATUSES } },
46 required: ['title', 'status'],
47 },
48 },
49 },
50 required: ['steps'],
51 },
52 isDeferred: false,
53}
54
55/** Added to every agent's task, so it reports its steps without being asked by Claude. */
56export const NUDGE = `\n\nAs you work, report your steps with the ${TOOL} tool: the whole list when you start, then again each time a step starts or finishes.`
57
58export const ok = (message: string) => ({ result: message, text: message })
59export const fail = (message: string) => ({ ...ok(message), isError: true as const })
60
61const isStatus = (x: unknown): x is StepStatus => STATUSES.some((s) => s === x)
62
63/** The tool call's `steps`, read with care: the model writes them. Null when any step is malformed. */
64export function stepsFromReport(steps: unknown): Step[] | null {
65 if (!Array.isArray(steps)) return null
66 const out: Step[] = []
67 for (const [i, x] of steps.entries()) {
68 if (typeof x !== 'object' || x === null) return null
69 const { title, status } = x as { title?: unknown; status?: unknown }
70 if (typeof title !== 'string' || title.trim() === '' || !isStatus(status)) return null
71 out.push({ id: `step${i}`, title: title.trim(), status })
72 }
73 return out
74}
75
76const capital = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
77
78/** `claude-opus-5-5` reads `Opus 5.5`; a date suffix is dropped; an alias is capitalised. */
79export function prettyModel(m: string | null): string {
80 if (m === null) return 'model pending'
81 const x = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-|$)/.exec(m)
82 if (x !== null) return `${capital(x[1] ?? '')} ${x[2]}${x[3] === undefined ? '' : `.${x[3]}`}`
83 return capital(m)
84}
85
86const pad = (n: number) => String(n).padStart(2, '0')
87
88/** A stopwatch reading: `0:27`, `12:05`, `1:02:09`. */
89export function clockText(ms: number): string {
90 const s = Math.max(0, Math.floor(ms / 1000))
91 const h = Math.floor(s / 3600)
92 const m = Math.floor((s % 3600) / 60)
93 return h > 0 ? `${h}:${pad(m)}:${pad(s % 60)}` : `${m}:${pad(s % 60)}`
94}
95
96export const short = (n: number) =>
97 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
98
99/** What sets a line's parts apart where they share a row: the token line's, and the rows `partRows` packs. */
100export const PART_SEP = ' | '
101
102/** The opened card's token line in its parts, which the line joins with PART_SEP; a row breaks only between them. */
103export function tokenParts(r: AgentRow): string[] {
104 const t = r.tokens
105 if (t === undefined)
106 return [isLive(r) ? 'Tokens: none yet. The count starts when its first request answers.' : 'Tokens: none reported.']
107 const when = isLive(r) ? 'now' : 'at the end'
108 const reqs = `${t.requests} request${t.requests === 1 ? '' : 's'}`
109 return [
110 `Tokens: ${short(t.context)} in its context ${when}`,
111 `${short(t.total)} used in all, over ${reqs}`,
112 `${short(t.output)} written by it`,
113 ]
114}
115
116/** The opened card's token line, under its brief. */
117export const tokensLine = (r: AgentRow): string => tokenParts(r).join(PART_SEP)
118
119/** The mark at a card's right edge, with the word that names the state, both in the state's colour.
120 * Only the running word is bold: Claude's colour on text is bold throughout the design system.
121 * `short` is the word where the full one does not fit beside the name (see `stateWord` in header.ts):
122 * Waiting reads `idle`, Failed `fail`, Stopped `stop`; Running is `Live` at every width; Done and Lost are short already. */
123export const MARK: Record<
124 AgentState,
125 { glyph: string; word: string; short: string; color: string; isWordBold: boolean }
126> = {
127 running: { glyph: '●', word: 'Live', short: 'Live', color: 'claude', isWordBold: true },
128 idle: { glyph: '◌', word: 'Waiting', short: 'idle', color: 'inactive', isWordBold: false },
129 done: { glyph: '✓', word: 'Done', short: 'Done', color: 'success', isWordBold: false },
130 failed: { glyph: '✗', word: 'Failed', short: 'fail', color: 'error', isWordBold: false },
131 stopped: { glyph: '■', word: 'Stopped', short: 'stop', color: 'inactive', isWordBold: false },
132 lost: { glyph: '?', word: 'Lost', short: 'Lost', color: 'warning', isWordBold: false },
133}
134
135/** Which fill a card's bar wears: orange while it runs, red when it failed, grey once it ended otherwise. */
136export const barKindOf = (state: AgentState): BarKind =>
137 isLiveState(state) ? 'live' : state === 'failed' ? 'failed' : 'done'
138
139/** The terminal's bar cannot draw the Svg's hex, so each kind takes a theme key. */
140export const BAR_KEY: Record<BarKind, string> = { live: 'claude', done: 'inactive', failed: 'error' }
141
142/** An effort level as the board shows it: its name and the critter's hat. */
143export type Tier = { label: string; hat: Hat }
144
145const HATS: Record<string, Hat> = { low: 'light', medium: 'medium', high: 'careful', xhigh: 'heavy', max: 'max' }
146
147/** The card names the real effort level; each level keeps its own hat. */
148export const tierOf = (effort: string | null): Tier => ({
149 label: effort ?? 'effort —',
150 hat: (effort === null ? undefined : HATS[effort]) ?? 'none',
151})
152
153/** A TodoWrite call's list, which replaces the whole list. */
154export const stepsFromTodos = (todos: readonly { content: string; status: StepStatus }[]): Step[] =>
155 todos.map((t, i) => ({ id: `todo${i}`, title: t.content, status: t.status }))
156
157/** `3/5 | Hero copy`: items done of all, then the one in progress. Null without a list. */
158function stepLine(steps: readonly Step[] | undefined): string | null {
159 if (steps === undefined || steps.length === 0) return null
160 const done = steps.filter((s) => s.status === 'completed').length
161 const current = steps.find((s) => s.status === 'in_progress')
162 return `${done}/${steps.length}${current === undefined ? '' : ` | ${current.title}`}`
163}
164
165/** The card's step line: the count done and the step in progress. How the agent ended is the header's word to say. */
166export const cardLine = (r: AgentRow): string | null => stepLine(r.steps)
167
168/** How full the card's bar is: whole when done, else its to-do list's share done. Null draws no bar. */
169export function progress(r: AgentRow): number | null {
170 if (r.state === 'done') return 1
171 const steps = r.steps ?? []
172 if (steps.length === 0) return null
173 return steps.filter((s) => s.status === 'completed').length / steps.length
174}
175
176/**
177 * The stats parts: context share of the window, tokens used in all, time (`ctx 2%`, `44k tok`, `0:27`). `full` keeps
178 * the word `tok` after the tokens; `bare` leaves it off, for a header with too little room (the user's ruling of
179 * 2026-10-15). An agent with no request answered yet has the time alone, in both.
180 */
181export type StatParts = { full: string[]; bare: string[] }
182
183/** The stats parts of a card at time `t`, given the model's context window if known. */
184export function statParts(r: AgentRow, t: number, window: number | null): StatParts {
185 const time = clockText((r.endedAt ?? t) - r.startedAt)
186 const k = r.tokens
187 if (k === undefined) return { full: [time], bare: [time] }
188 const ctx = window !== null && window > 0 ? `${Math.round((k.context / window) * 100)}%` : short(k.context)
189 const tokens = short(k.total)
190 return { full: [`ctx ${ctx}`, `${tokens} tok`, time], bare: [`ctx ${ctx}`, tokens, time] }
191}
192
193/** The cards at the top: how many agents, tokens used in all, and first start to last end. */
194export function totals(rows: readonly AgentRow[], t: number) {
195 const tokens = rows.reduce((s, r) => s + (r.tokens?.total ?? 0), 0)
196 if (rows.length === 0) return { count: 0, tokens, spanMs: 0 }
197 const start = Math.min(...rows.map((r) => r.startedAt))
198 const end = Math.max(...rows.map((r) => r.endedAt ?? t))
199 return { count: rows.length, tokens, spanMs: end - start }
200}
201
202/** What the user can do after a permission check, such as auto mode's, refused the board's call to a tool. */
203export const permissionHint = (tool: CardTool) => `Switch modes, or add ${tool} to your allow list.`
204
205/** The engine gives no way to read the permission mode, so the refusal's own words say whether a check refused
206 * it. "denied" and "needs approval" are how a permission rule's refusals read. */
207const isPermissionRefusal = (reason: string) => /auto.?mode|classifier|permission|denied|approval/i.test(reason)
208
209/** The tools a card's buttons use, and what a refusal of each says first. */
210const REFUSED = { SendMessage: 'Not sent', TaskStop: 'Not stopped' } as const
211type CardTool = keyof typeof REFUSED
212
213/** The note under the box after a card's send or stop was refused: only what to do when a permission check
214 * refused it (its own reason is written for Claude, not the user), else the reason. */
215export function refusalNote(tool: CardTool, reason: string): string {
216 if (isPermissionRefusal(reason)) return permissionHint(tool)
217 return `${REFUSED[tool]}: ${reasonText(reason)}.`
218}
219hooks/tracer/record.ts 113 lines1import type { Tag, RecordEntry } from '../../types'
2
3/** The longest baseline kept: the engine's code view draws no more characters than this. */
4const MAX_BASELINE = 100_000
5
6export const PANE = 'file-tracer'
7export const TITLE = 'Files'
8export const COMMAND = 'file-tracer'
9
10/** One thing Claude did to one file. The path is absolute; `baseline` is the file's content before this change, and `seen` its content after, where the mod saw it. */
11export type Touch = {
12 path: string
13 tag: Tag
14 baseline: string | null
15 oldPath?: string
16 seen?: string
17 /** True for the user's own edit saved from the pane, which is not a Touch of Claude's. */
18 isByUser?: true
19}
20
21/** Tags from smallest to biggest. */
22const RANK: readonly Tag[] = ['Read', 'Edited', 'Moved', 'Created', 'Deleted']
23
24/** The baseline a record keeps for a text: the text, or none and a note when it runs over the most kept. */
25function baselineOf(text: string | null): Pick<RecordEntry, 'baseline' | 'isBaselineTooBig'> {
26 const isTooBig = (text?.length ?? 0) > MAX_BASELINE
27 return { baseline: isTooBig ? null : text, isBaselineTooBig: isTooBig }
28}
29
30/** Whether a record's text before the session's first change is unknown: none kept, and not for being too big. */
31const isUnknown = (e: RecordEntry) => e.baseline === null && !e.isBaselineTooBig
32
33/** A record of the new path with the baseline, and the last-seen content it lacks, from the old path's record. */
34const withBaselineOf = (own: RecordEntry, old: RecordEntry): RecordEntry => {
35 if (!isUnknown(own) || isUnknown(old)) return own
36 const lastSeen = own.lastSeen ?? old.lastSeen
37 return {
38 ...own,
39 baseline: old.baseline,
40 isBaselineTooBig: old.isBaselineTooBig,
41 ...(lastSeen === undefined ? {} : { lastSeen }),
42 }
43}
44
45/**
46 * The records without the old path of a moved file, and the record that goes on at the touch's path: the path's own
47 * (with the old path's baseline if it has none), else the old path's. A deleted file cannot be what moved, so a Deleted record stays where it is.
48 */
49function takeMoved(records: Record<string, RecordEntry>, touch: Touch) {
50 const old = touch.oldPath === undefined ? undefined : records[touch.oldPath]
51 if (touch.oldPath === undefined || old === undefined || old.tag === 'Deleted')
52 return { rest: records, had: records[touch.path] }
53 const rest = Object.fromEntries(Object.entries(records).filter(([path]) => path !== touch.oldPath))
54 const own = rest[touch.path]
55 return { rest, had: own === undefined ? old : withBaselineOf(own, old) }
56}
57
58/**
59 * The last-seen content a record keeps after a touch: the touch's, if it saw the file, else the one it had. None
60 * when the text is too big, or is the baseline, which stands for it.
61 */
62function lastSeenAfter(had: string | undefined, seen: string | undefined, baseline: string | null) {
63 const text = seen ?? had
64 return text === undefined || text.length > MAX_BASELINE || text === baseline ? {} : { lastSeen: text }
65}
66
67/**
68 * Who owns a record's Tag and whether its dot shows after a touch. Whoever edited last owns the Tag: a Read leaves it
69 * be, and anything else of Claude's takes it. The user's edit is not a Touch, so it leaves the dot as it was.
70 */
71function ownerOf(had: RecordEntry | undefined, touch: Touch) {
72 const isByUser = touch.isByUser === true || (touch.tag === 'Read' && had?.isByUser === true)
73 return {
74 isRecent: touch.isByUser === true ? (had?.isRecent ?? false) : true,
75 ...(isByUser ? { isByUser } : {}),
76 }
77}
78
79/** The record of a file after a touch: its biggest Tag, its baseline (a later Touch fills it only while it is unknown), and the touch's number. */
80function entryAfter(had: RecordEntry | undefined, touch: Touch, order: number): RecordEntry {
81 const base = had === undefined || isUnknown(had) ? baselineOf(touch.baseline) : had
82 const oldPath = touch.oldPath ?? had?.oldPath
83 const tag = had !== undefined && RANK.indexOf(had.tag) >= RANK.indexOf(touch.tag) ? had.tag : touch.tag
84 return {
85 tag,
86 ...ownerOf(had, touch),
87 order,
88 baseline: base.baseline,
89 isBaselineTooBig: base.isBaselineTooBig,
90 ...lastSeenAfter(had?.lastSeen, touch.seen, base.baseline),
91 ...(oldPath === undefined ? {} : { oldPath }),
92 }
93}
94
95/** The records with the touch folded in. A moved file's old path leaves the records and its record goes on at the new path, unless the new path has a record of its own. */
96export function fold(records: Record<string, RecordEntry>, touch: Touch): Record<string, RecordEntry> {
97 const { rest, had } = takeMoved(records, touch)
98 const order = Object.values(records).reduce((top, e) => Math.max(top, e.order), 0) + 1
99 return { ...rest, [touch.path]: entryAfter(had, touch, order) }
100}
101
102/** The records with every dot cleared; Tags and baselines stay. */
103export const clearRecent = (records: Record<string, RecordEntry>): Record<string, RecordEntry> =>
104 Object.fromEntries(Object.entries(records).map(([path, e]) => [path, { ...e, isRecent: false }]))
105
106/** Whether a close is the person closing this mod's pane. */
107export const isUserClose = (e: { id: string; origin: { kind: string } }): boolean =>
108 e.id === PANE && e.origin.kind === 'person'
109
110/** The words a file's Tag is drawn with: `Edited by you` where the user made the latest edit, whatever the Tag was before, else the Tag. */
111export const tagWords = (e: Pick<RecordEntry, 'tag' | 'isByUser'>): string =>
112 e.isByUser === true ? 'Edited by you' : e.tag
113hooks/band/agents.ts 17 lines1// The Agent counter's figures, from the board's Batch. Nothing here calls the engine.
2import type { AgentRow } from '../../types'
3import { isLive } from '../live'
4
5/** How many agents of the Batch are Live (running or idle) and how many have finished (every other state). */
6export function countAgents(batch: readonly AgentRow[]): { live: number; finished: number } {
7 const live = batch.filter(isLive).length
8 return { live, finished: batch.length - live }
9}
10
11/** The hover card's line for a Batch: the Live and finished counts, or that there is no Batch. */
12export function agentsCard(batch: readonly AgentRow[]): string {
13 if (batch.length === 0) return 'No agents this session'
14 const { live, finished } = countAgents(batch)
15 return `${live} running · ${finished} finished`
16}
17hooks/band/keys.ts 40 lines1// The addresses of the band's buttons: each drawn Button's key, and the name a pressed key stands for.
2// Nothing here calls the engine.
3
4/** The band's pressable Buttons; each one's key is its name. */
5export const BAND_BUTTONS = [
6 'tracer',
7 'side',
8 'compact',
9 'handoff',
10 'agents',
11 'details',
12 'openpane',
13 'clearthread',
14 'autowarm',
15 'cstate',
16] as const
17
18/** One of the band's Buttons. */
19export type BandButton = (typeof BAND_BUTTONS)[number]
20
21const isBandButton = (key: string): key is BandButton => (BAND_BUTTONS as readonly string[]).includes(key)
22
23/** The band Button a pressed key names; null for a key that is none of the band's, such as a board or tracer button. */
24export const bandButtonOf = (key: string | undefined): BandButton | null =>
25 key !== undefined && isBandButton(key) ? key : null
26
27/** What each Button's press does, in words that finish "Could not ...": the toast for a press whose action threw. */
28export const BAND_DOING: Record<BandButton, string> = {
29 tracer: 'open the file tracer',
30 side: 'show or hide the side question',
31 compact: 'compact the chat',
32 handoff: 'write the handoff',
33 agents: 'open the agent board',
34 details: 'show or hide the details',
35 openpane: 'open the side questions pane',
36 clearthread: 'clear the side questions',
37 autowarm: 'turn auto warm on or off',
38 cstate: 'turn auto warm on or off',
39}
40