SLOPSHOPPER

desk

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…

panebandguardcommandprompttool
v0.1.0MITupdated 2026-10-21KingP1197/claude-mods/desk
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · desk
│ ┃ Files ✕ › fix the failing auth test and add an audit log call │ ┃ 3 files | 3 changed[ Changed only ] [ Trail │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ ▾ This project 3 /work/app ⏺ Update(src/auth.ts) │ ┃ ▾ src/ ⎿ Added 2 lines, removed 1 line │ ┃ ├─ TS file:src/audit.ts Edited ● ⏺ Bash(bun test) │ ┃ ├─ TS file:src/auth.ts Edited ● ⎿ 3 pass, 1 fail │ ┃ └─ TS file:src/cache.ts Edited ● │ ┃ ──────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ──────────── │ ✻ Worked for 42s · done 4:20 PM │ │ › /file-tracer │ ⎿ desk: File tracer opened. │ │ [ Tracer ] [ Side question ] [ Compact ] [ Write handoff ] no branch | 5h 31% | 7d — | 97k/200k ctx (49%) | agents 0 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ [ Show details ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ Tracer ] [ Side question ] [ Compact ] [ Write handoff ] no branch | 5h 31% | 7d — | 97k/200k ctx (49%) | agents 0 ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ [ Show details ]
Pane · Files
3 files | 3 changed [ Changed only ] [ Trail ] ──────────────────────────────────────────────────────── ▾ This project 3 /work/app ▾ src/ ├─ TS file:src/audit.ts Edited ● ├─ TS file:src/auth.ts Edited ● └─ TS file:src/cache.ts Edited ● ────────────────────────────────────────────────────────
Pane · Agents
No agents yet. A card appears here when Claude starts one.
Pane · side-questions
Ask about the main chat. The main agent won't see this. ▣ client module ./askbox.tsx
Pane · file-tracer-trail
[ Show Claude only ] ▾ turn:1 4 ├─ src/ ├─ 1 Read trailFile:1 ├─ 2 Edited trailFile:2 ├─ 3 Edited trailFile:3 └─ 4 Edited trailFile:4
README

kp-mods

One mod for Claude Code: desk. It has three parts.

  • The band sits above the prompt. It shows your branch, your rate limits and a bar of how full the context window is, split by source. A Details list breaks the bar down. Buttons write a handoff note to your clipboard, compact the session, or ask a side question.
  • The agent board is a side panel with one card per subagent. Each card shows the agent's type, effort level, model, to-do progress, share of the context window, tokens and running time. Click a card's name to see the agent's last three messages, send it a message, or stop it. The panel opens by itself when a batch of agents starts. /agent-board reopens it.
  • The file tracer is a pane that lists the files Claude touched in a session, with tags, a before-and-after view of each change, and a button that reveals the file. /file-tracer reopens it. It runs in a terminal. In the Claude desktop app it says it is off.

Install

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.

Good to know

  • Only one mod can draw above the prompt. If another installed mod also does, the two will clash.
  • A message to a running agent goes straight into its conversation, without a permission check. The agent reads it as a plain user message.
  • A message to a finished agent wakes it up through Claude Code's SendMessage tool. Auto mode may block that, and the card then says so. To send anyway, switch modes, or add SendMessage to the permissions.allow list in your settings. That rule lets every SendMessage through, including ones Claude sends on its own.
  • Stop uses Claude Code's TaskStop tool. If auto mode or a permission rule blocks it, the card says to switch modes or add TaskStop to your allow list.
  • Tested with Claude Code 2.1.300. Older versions may not support mods.

Remove

/plugin uninstall desk

License

MIT. See LICENSE.

Source 53 files
hooks/register.tsx 18 lines
1// 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}
18
hooks/band/register.tsx 1348 lines
1// 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 lines
1// 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 lines
1// 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 lines
1// 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
27
hooks/failures.ts 52 lines
1// 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)})`
52
hooks/surface.ts 32 lines
1// 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')
32
hooks/band/cache.ts 116 lines
1// 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
116
hooks/board/agents.ts 219 lines
1// 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}
219
hooks/tracer/record.ts 113 lines
1import 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
113
hooks/band/agents.ts 17 lines
1// 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}
17
hooks/band/keys.ts 40 lines
1// 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