SLOPSHOPPER

sessiondeck

Sessiondeck: Flightdeck's live session dashboard plus prompt, cache, tool latency, hot files, tests, aborts, and a chart band for context and up to a hundred…

newpaneguardcommandstatustimer
★ 2v0.4.2MITupdated 2026-10-05NetipunJi/sessiondeck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sessiondeck
│ ┃ Sessiondeck ✕ › fix the failing auth test and add an audit log call │ ┃ SESSIONDECK · — IDLE │ ┃ ■ main ■ agents ■ gate ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ — · main ⏺ Update(src/auth.ts) │ ┃ │ effort ▯▯▯▯ — 0 req ⎿ Added 2 lines, removed 1 line │ ┃ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k ⏺ Bash(bun test) │ ┃ │ $0.42 5h ▰▰▱▱▱ 31% ⎿ 3 pass, 1 fail │ ┃ ╰─────────────────────────────────────────── │ ┃ PROMPT "fix the failing auth test and…" — · ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ TOOLS Bash 0.0s • Write 0.0s Edit 0.0s G │ ┃ TURN tests ■ FAIL 1 hot file src/audit.ts ✻ Worked for 42s · done 4:20 PM │ ┃ ▣ client module ./rail.tsx │ ┃ ╭─────────────────────────────────────────── › /sessiondeck │ ┃ │ ✓ last turn 42s · 0 agents · 3 edits · 1 e ⎿ sessiondeck: Sessiondeck opened. Focus it with ctrl+x tab; 1-9 s │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ session log │ ┃ │ 01:53:20 main Edit → src/auth.ts │ ┃ │ 01:53:20 main Write → src/audit.ts │ ┃ │ 01:53:20 main Write → src/cache.ts │ ┃ │ 01:53:20 main Bash → bun test ✗ │ ┃ ╰─────────────────────────────────────────── │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ sessiondeck: ctx 49%

Draws

Pane · Sessiondeck
SESSIONDECK · — IDLE ■ main ■ agents ■ gate ╭──────────────────────────────────────────────────────╮ │ — · main ○ idle │ │ effort ▯▯▯▯ — 0 req │ │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% 97k/200k │ │ $0.42 5h ▰▰▱▱▱ 31% │ ╰──────────────────────────────────────────────────────╯ PROMPT "fix the failing auth test and…" — · done TOOLS Bash 0.0s • Write 0.0s Edit 0.0s Grep 0.0s R… TURN tests ■ FAIL 1 hot file src/audit.ts ×1 abort… ▣ client module ./rail.tsx ╭──────────────────────────────────────────────────────╮ │ ✓ last turn 42s · 0 agents · 3 edits · 1 error │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ session log │ │ 01:53:20 main Edit → src/auth.ts │ │ 01:53:20 main Write → src/audit.ts │ │ 01:53:20 main Write → src/cache.ts │ │ 01:53:20 main Bash → bun test ✗ │ ╰──────────────────────────────────────────────────────╯
README

Sessiondeck

A live session dashboard for Claude Code. It keeps everything Flightdeck shows and adds seven session signals and a chart band. Every number comes from a real session event, and nothing leaves your machine.

Sessiondeck docked in a fullscreen terminal while three subagents finish

What it shows

Seven signals Flightdeck does not show:

  • Prompt. The current prompt, its status and its elapsed time, including when the session waits on you.
  • Cache. The share of input tokens served from the prompt cache.
  • Tool latency. Call time, errors and denies for each tool.
  • Hot files. The file edited most often since the last prompt.
  • Tests. Passing and failing runs of Bash commands that match the test pattern.
  • Aborts. How many turns you interrupted.
  • Agents. Counts by type and state, for one agent or a hundred.

Three charts in a band above the panels. Each chart is a titled panel whose title gives its live value and a color square for each series:

  • Context chart. Context use over the last minutes. The title gives context and cache hit percent, and marks a compaction in the window.
  • Agent grid. One cell per agent, grouped by type, sized by how many agents are live. The title counts agents running, done and failed.
  • Agent history. Agents running over the last minutes. The title gives the window and the peak agent count.

The context and history charts span the session so far. Once the session is longer than historyMinutes, they show only the last historyMinutes.

The band draws with the pane docked: as a raster in a fullscreen terminal and as SVG in the desktop Code tab. Other surfaces show the same facts as text.

In the desktop Code tab, the gauges are SVG bars and one-line rows wrap between phrases instead of being cut short. ADR 0005 explains why.

Panels

PanelShowsFrom
mainModel, effort, permission mode, request count, a context gauge with compactions (⟲), cache hit, cost and rate-limit windows.turn.step, session.measure, session.compact, $.session.usage()
promptThe prompt in flight, its status and elapsed time.classic.UserPromptSubmit, turn.start, turn.complete, turn.abort
gateOne cell per permission check, totals, and a drill-down per tool family with credentials masked.tool.check, settled by the tool.call around it
agentsCards while they fit, swimlanes beyond that. On the desktop, VS Code and mobile, also an SVG time axis.agent.spawn, turn.step, tool.call, turn.complete
toolsThe busiest tools, their mean latency, and a dot per recent error or deny.tool.call
turnTest light, hot file, aborts, and the gate's file and shell tallies.tool.call, turn.abort
loopsModel loops that match no card: workflow agents, compactions, memory forks.turn.step ids no card claims
receiptThe running turn or the last one: duration, agents, edits, errors, cost added.turn.start, turn.complete
logPrompts, spawns, completions, edits, errors and denials.All of the above

Panels with nothing to show take no room.

Install

Inside Claude Code:

/plugin marketplace add NetipunJi/sessiondeck
/plugin install sessiondeck@sessiondeck

Or from a shell:

claude plugin marketplace add NetipunJi/sessiondeck
claude plugin install sessiondeck@sessiondeck

Then run /reload-plugins and /sessiondeck. To update later, run claude plugin marketplace update sessiondeck and claude plugin update sessiondeck@sessiondeck.

To load a local clone for one session instead, run claude --plugin-dir <path to clone>.

Commands and keys

CommandEffect
/sessiondeckOpen the pane.
/sessiondeck closeClose the pane.
/sessiondeck resetClear agents, checks, log, turn, prompt and every deck signal. Cost, rate limits, model and mode stay.
`/sessiondeck layout auto\compact\wide\mini`Set the layout for this session.

/clear resets the pane along with the conversation.

Focus the pane with ctrl+x tab, then:

KeyEffect
1 to 9Expand that agent card and outline it in the grid: its full task, last 3 tool calls and the start of its answer.
nMove the cursor to the next agent.
bMove the cursor back.
xClear the expansion and the cursor.
f s oOpen the gate's file, shell or other drill-down: the last 5 checks and their verdicts.

A hotkey can be one digit or one lowercase letter, and the pane keeps Tab and Esc for focus. So those two keys stay unbound.

Where it runs

  • Fullscreen terminal. Docked beside the transcript, two columns from 110 columns wide, with the raster chart band.
  • Main-screen terminal. Inline above the prompt, as a summary of at most 8 rows. No chart band.
  • Desktop Code tab. Docked, with the band, gauges and agent lanes drawn as SVG.
  • VS Code and mobile. The same panels as text, plus the SVG agent lanes. These surfaces can't animate, so connectors and clocks are static.

With openOnStart, the pane opens when a session starts. Below 144 columns Claude Code won't seat a pane nobody asked for, so run /sessiondeck to open it at any width.

What it can reach

What it reads: session events (turn.*, tool.check, tool.call, agent.spawn, session.*) and the prompt text from classic.UserPromptSubmit.

What it keeps: short summaries in session state, which ends with the session. A tool name plus a path or command, with credentials masked. The prompt, redacted and cut to 48 characters. Counters and chart samples.

It makes no network requests, runs no processes, reads and writes no files, stores nothing across sessions, and calls no model. claude plugin validate . prints exactly what it hooks and calls.

What is inferred, not measured

  • Tests. A run counts when a Bash command matches testPattern. It passes when the call succeeds and fails otherwise. The pane counts runs, not individual test cases.
  • Hot file. The file with the most Edit, Write, MultiEdit and NotebookEdit calls since the last prompt. It is not a diff size.
  • Chart history. Context and agent counts are sampled every 10 seconds, so a spike shorter than that can fall between two samples.
  • Per-agent context is the latest request's whole input (uncached + cache read + cache write). It is labelled ctx, not cost: the API has no per-agent cost.
  • Other loops can't tell a workflow agent from a compaction fork. Both are model loops that no card claims.
  • A background agent's first step can arrive before its card exists, so its usage may show one step late.

Config

In /config, or under pluginConfigs["sessiondeck"].options in settings.json:

KeyDefaultMeaning
panelsmain,prompt,gate,agents,tools,turn,loops,receipt,logPanels in display order. Leave one out to hide it.
motionwhile-activeAnimate connectors while work flows, or off.
maxCards64Agent cards kept for the detail view (1-200). Finished cards past this fold into the per-type counts.
layoutautoauto, compact, wide or mini.
palettethemetheme follows your Claude Code theme. pastel and dracula use fixed colours.
openOnStarttrueOpen the pane when a session starts.
statusLinetrueShow context, agents and denials in the status line.
testPattern`\b(vitest\jest\pytest\go test\cargo test\npm test\pnpm test\bun test\mocha)\b`Case-insensitive regular expression for Bash commands that count as a test run. A pattern that does not compile falls back to the default, and the log says so once.
historyMinutes30The most minutes the history charts show (1-30).
chartstrueDraw the chart band. Set false for text rows only.

Coming from Flightdeck? maxCards means something else here. In Flightdeck it is the number of cards drawn side by side before swimlanes (1-6). In Sessiondeck it is how many cards are kept, and the pane width decides how many fit side by side. The architect options (architectPattern, architectLabel, matchDescriptions, moments) and gateLabel are gone.

Troubleshooting

The pane doesn't appear.

  • Run /reload-plugins, then /sessiondeck.
  • Below 144 columns, openOnStart can't seat the pane. /sessiondeck opens it at any width.
  • Look in the session log for a line from sessiondeck. It names the first deck update that failed, or a testPattern that did not compile.

No chart band. The band needs the pane docked, at least 60 columns and 15 rows, a layout other than mini, and charts: true. Inline above the prompt, it shows as text.

Colours look wrong. Set palette to pastel or dracula.

It's too much motion. Set motion to off.

Counters look stale after an update. Run /sessiondeck reset.

How it works

FileHolds
hooks/register.tsxEvent hooks, state atoms, the 10 s sampler, commands and the pane registration.
hooks/draw.tsxEvery panel, the layouts and the band, for every surface.
hooks/core.tsEvery reducer, formatter and layout rule as pure functions.
hooks/charts.tsPure builders for the context chart, agent grid and agent history.
hooks/raster.ts, hooks/svg.tsGrid encoders: half-block raster for the terminal, SVG for the desktop.
hooks/rail.tsx, hooks/elapsed.tsxSurface modules: animated connectors and live clocks that redraw only themselves.
types/index.d.tsThe state contract.
tests/105 tests: pure behaviour, charts and encoders, and drawings mounted on every surface.
docs/adr/, docs/postmortems/Design decisions and bug write-ups.

State lives in $.state atoms. Every read is merged over defaults, so a missing or older field never breaks the pane. An update that changes the state's shape may still reset its counters once.

Develop

claude --plugin-dir .            # load it; edits hot-reload
claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .       # after the first load, which writes .claude-plugin/types/

Changes are listed in CHANGELOG.md.

Credits

Forked from claude-flightdeck 0.3.2 by Stephen Casella (MIT). The Raster encoder is Battledeck's raster.ts.

Source 9 files
hooks/register.tsx 620 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentCard, AgentTally, Check, Gate, Layout, LogLine, Loop, Main, Turn, Usage, View } from '../types'
5import {
6  LOG_WHO_MAX,
7  DEFAULT_ABORTS,
8  DEFAULT_AGENTS,
9  DEFAULT_CACHE,
10  DEFAULT_CONTEXT,
11  DEFAULT_GATE,
12  DEFAULT_MAIN,
13  DEFAULT_OPTIONS,
14  DEFAULT_PROMPT,
15  DEFAULT_TESTS,
16  DEFAULT_TURN,
17  DEFAULT_USAGE,
18  DEFAULT_VIEW,
19  EDIT_TOOLS,
20  SAMPLE_MS,
21  SCHEMA_VERSION,
22  admitCards,
23  afterCall,
24  applyStep,
25  bucketOf,
26  cardTitle,
27  describeInput,
28  fmtDuration,
29  gateSummary,
30  gauge,
31  gridOrder,
32  isSampling,
33  listOf,
34  normalize,
35  normalizeCard,
36  normalizeDeck,
37  normalizeGate,
38  normalizeLog,
39  noteTool,
40  PALETTES,
41  parseConfig,
42  promptLine,
43  receiptOf,
44  redact,
45  recordCheck,
46  reduceDeck,
47  settleCheck,
48  shorten,
49  stepLoop,
50  tallyByType,
51  totals,
52} from './core'
53import type { Config, Deck, DeckEvent, DeckOptions } from './core'
54import { draw } from './draw'
55
56const PANE = 'sessiondeck'
57const TITLE = 'Sessiondeck'
58const PANE_COLUMNS = 66
59
60
61// ---------------------------------------------------------------- state
62
63const meta = atom({ plugin: 'sessiondeck', key: 'meta' } as const, { schemaVersion: SCHEMA_VERSION })
64const main = atom({ plugin: 'sessiondeck', key: 'main' } as const, DEFAULT_MAIN)
65const usage = atom({ plugin: 'sessiondeck', key: 'usage' } as const, DEFAULT_USAGE)
66const gate = atom({ plugin: 'sessiondeck', key: 'gate' } as const, DEFAULT_GATE)
67const agents = atom({ plugin: 'sessiondeck', key: 'agents' } as const, [])
68const loops = atom({ plugin: 'sessiondeck', key: 'loops' } as const, [])
69const log = atom({ plugin: 'sessiondeck', key: 'log' } as const, [])
70const turn = atom({ plugin: 'sessiondeck', key: 'turn' } as const, DEFAULT_TURN)
71const receipt = atom({ plugin: 'sessiondeck', key: 'receipt' } as const, null)
72const view = atom({ plugin: 'sessiondeck', key: 'view' } as const, DEFAULT_VIEW)
73const prompt = atom({ plugin: 'sessiondeck', key: 'prompt' } as const, DEFAULT_PROMPT)
74const cache = atom({ plugin: 'sessiondeck', key: 'cache' } as const, DEFAULT_CACHE)
75const tools = atom({ plugin: 'sessiondeck', key: 'tools' } as const, {})
76const hotFiles = atom({ plugin: 'sessiondeck', key: 'hotFiles' } as const, {})
77const tests = atom({ plugin: 'sessiondeck', key: 'tests' } as const, DEFAULT_TESTS)
78const aborts = atom({ plugin: 'sessiondeck', key: 'aborts' } as const, DEFAULT_ABORTS)
79const agentStats = atom({ plugin: 'sessiondeck', key: 'agentStats' } as const, DEFAULT_AGENTS)
80const context = atom({ plugin: 'sessiondeck', key: 'context' } as const, DEFAULT_CONTEXT)
81
82// Every read goes through these, so a value saved under an older shape still reads.
83async function getMain($: EngineInterface): Promise<Main> {
84  return normalize(DEFAULT_MAIN, await read($, main))
85}
86async function getUsage($: EngineInterface): Promise<Usage> {
87  return normalize(DEFAULT_USAGE, await read($, usage))
88}
89async function getGate($: EngineInterface): Promise<Gate> {
90  return normalizeGate(await read($, gate))
91}
92async function getCards($: EngineInterface): Promise<AgentCard[]> {
93  return listOf<unknown>(await read($, agents)).map(normalizeCard)
94}
95async function getLoops($: EngineInterface): Promise<Loop[]> {
96  return listOf<Loop>(await read($, loops))
97}
98async function getLog($: EngineInterface): Promise<LogLine[]> {
99  return normalizeLog(await read($, log))
100}
101async function getTurn($: EngineInterface): Promise<Turn> {
102  return normalize(DEFAULT_TURN, await read($, turn))
103}
104async function getView($: EngineInterface): Promise<View> {
105  return normalize(DEFAULT_VIEW, await read($, view))
106}
107async function getDeck($: EngineInterface): Promise<Deck> {
108  const [p, c, t, h, ts, a, ag, cx] = await Promise.all([
109    read($, prompt),
110    read($, cache),
111    read($, tools),
112    read($, hotFiles),
113    read($, tests),
114    read($, aborts),
115    read($, agentStats),
116    read($, context),
117  ])
118  return normalizeDeck({ prompt: p, cache: c, tools: t, hotFiles: h, tests: ts, aborts: a, agents: ag, context: cx })
119}
120
121// One log line per load says the deck stopped updating; repeating it every call would flood the log.
122let reported = false
123
124/** A deck slice's failure stays in the deck: the call that fed it still resolves. */
125async function safely($: EngineInterface, work: () => Promise<unknown>): Promise<void> {
126  try {
127    await work()
128  } catch (err) {
129    if (reported) return
130    reported = true
131    try {
132      await say($, 'sessiondeck', `deck update failed: ${String(err).slice(0, 80)}`, 'error')
133    } catch {}
134  }
135}
136
137// Each write is computed from a whole-deck read, so two calls settling together would
138// otherwise each write from the same read and one would be lost.
139let queue: Promise<unknown> = Promise.resolve()
140function serial<T>(work: () => Promise<T>): Promise<T> {
141  const run = queue.then(work)
142  queue = run.catch(() => undefined)
143  return run
144}
145
146function apply($: EngineInterface, e: DeckEvent, opts: DeckOptions = DEFAULT_OPTIONS): Promise<void> {
147  return serial(async () => {
148    const d = await getDeck($)
149    const n = reduceDeck(d, e, opts)
150    if (n.prompt !== d.prompt) await update($, prompt, () => n.prompt)
151    if (n.cache !== d.cache) await update($, cache, () => n.cache)
152    if (n.tools !== d.tools) await update($, tools, () => n.tools)
153    if (n.hotFiles !== d.hotFiles) await update($, hotFiles, () => n.hotFiles)
154    if (n.tests !== d.tests) await update($, tests, () => n.tests)
155    if (n.aborts !== d.aborts) await update($, aborts, () => n.aborts)
156    if (n.agents !== d.agents) await update($, agentStats, () => n.agents)
157    if (n.context !== d.context) await update($, context, () => n.context)
158  })
159}
160
161async function admit($: EngineInterface, cap: number) {
162  await serial(async () => {
163    const [cards, d] = await Promise.all([getCards($), getDeck($)])
164    if (admitCards(cards, d.agents.byType, cap).cards === cards) return
165    // Card writes outside serial (spawn, step, status) can land mid-update; update re-runs
166    // this on the fresh list, so the last run's folded matches the list it wrote.
167    let folded: Record<string, AgentTally> | null = null
168    await update($, agents, list => {
169      const fresh = listOf<unknown>(list).map(normalizeCard)
170      const kept = admitCards(fresh, d.agents.byType, cap)
171      folded = kept.cards === fresh ? null : kept.folded
172      return kept.cards
173    })
174    const byType = folded
175    if (byType) await update($, agentStats, () => ({ ...d.agents, byType }))
176  })
177}
178
179let sampler: Timer | null = null
180
181async function ensureSampler($: EngineInterface) {
182  if (sampler) return
183  const [d, cards] = await Promise.all([getDeck($), getCards($)])
184  if (sampler || !isSampling(d.prompt, cards)) return
185  sampler = $.clock.every(SAMPLE_MS, () => void safely($, () => tick($)))
186}
187
188async function tick($: EngineInterface) {
189  const [d, cards] = await Promise.all([getDeck($), getCards($)])
190  if (!isSampling(d.prompt, cards)) {
191    sampler?.cancel()
192    sampler = null
193    return
194  }
195  const { spawning: _, ...counts } = totals(tallyByType(cards, d.agents.byType))
196  await apply($, { kind: 'tick', at: await $.clock.now(), counts })
197}
198
199/** A stored shape older than this build's: drop what cannot be read, keep the rest. */
200async function migrate($: EngineInterface) {
201  const m = await read($, meta)
202  if ((m?.schemaVersion ?? 0) >= SCHEMA_VERSION) return
203  await update($, log, list => normalizeLog(list))
204  await update($, agents, list => listOf<unknown>(list).map(normalizeCard))
205  await update($, gate, g => normalizeGate(g))
206  await update($, meta, () => ({ schemaVersion: SCHEMA_VERSION }))
207}
208
209async function say($: EngineInterface, who: string, text: string, kind: LogLine['kind'] = 'info', agentId: string | null = null) {
210  const line: LogLine = { at: await $.clock.now(), who, text, kind, agentId }
211  await update($, log, list => [...normalizeLog(list), line].slice(-60))
212}
213
214/** Every agent this session, the cards the cap folded into agentStats included. */
215async function agentCounts($: EngineInterface): Promise<{ live: number; total: number }> {
216  const [cards, d] = await Promise.all([getCards($), getDeck($)])
217  const t = totals(tallyByType(cards, d.agents.byType))
218  return { live: t.spawning + t.running, total: t.spawning + t.running + t.done + t.error }
219}
220
221async function refreshStatus($: EngineInterface, cfg: Config) {
222  if (!cfg.statusLine) return $.ui.status(undefined)
223  const [u, g, a] = await Promise.all([getUsage($), getGate($), agentCounts($)])
224  const s = gateSummary(g)
225  const parts = [
226    u.pct !== null ? `ctx ${Math.round(u.pct)}%` : null,
227    a.total > 0 ? `agents ${a.live}/${a.total}` : null,
228    s.deny > 0 ? `denied ${s.deny}` : null,
229  ]
230  // Only fields with something to say; with none, no status entry at all.
231  const shown = parts.filter(Boolean)
232  $.ui.status(shown.length > 0 ? shown.join(' · ') : undefined)
233}
234
235async function whoIs($: EngineInterface, agentId: string | undefined) {
236  if (!agentId) return 'main'
237  const card = (await getCards($)).find(c => c.id === agentId)
238  return card ? shorten(cardTitle(card), LOG_WHO_MAX) : 'agent'
239}
240
241async function openPane($: EngineInterface) {
242  // columns apply when docked beside the transcript, rows when seated inline above the prompt.
243  return $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS, rows: 8 })
244}
245
246async function resetAll($: EngineInterface) {
247  // Serial with apply: a deck write in flight would otherwise land after the reset.
248  await serial(async () => {
249    await update($, main, m => ({ ...DEFAULT_MAIN, model: normalize(DEFAULT_MAIN, m).model, mode: normalize(DEFAULT_MAIN, m).mode }))
250    await update($, gate, () => DEFAULT_GATE)
251    await update($, agents, () => [])
252    await update($, loops, () => [])
253    await update($, log, () => [])
254    await update($, turn, () => DEFAULT_TURN)
255    await update($, receipt, () => null)
256    await update($, view, () => DEFAULT_VIEW)
257    await update($, prompt, () => DEFAULT_PROMPT)
258    await update($, cache, () => DEFAULT_CACHE)
259    await update($, tools, () => ({}))
260    await update($, hotFiles, () => ({}))
261    await update($, tests, () => DEFAULT_TESTS)
262    await update($, aborts, () => DEFAULT_ABORTS)
263    await update($, agentStats, () => DEFAULT_AGENTS)
264    await update($, context, () => DEFAULT_CONTEXT)
265    // The context gauge waits for the next measurement rather than showing the pre-clear fill.
266    await update($, usage, x => ({ ...normalize(DEFAULT_USAGE, x), pct: null, tokens: null }))
267  })
268}
269
270/** The session's cost read fresh, not from the last measurement: the receipt subtracts two of these. */
271async function costNow($: EngineInterface): Promise<number | null> {
272  const u = await $.session.usage().catch(() => null)
273  return u?.cost?.usd ?? null
274}
275
276async function noteMode($: EngineInterface, mode: string | undefined) {
277  if (mode) await update($, main, m => (normalize(DEFAULT_MAIN, m).mode === mode ? normalize(DEFAULT_MAIN, m) : { ...normalize(DEFAULT_MAIN, m), mode }))
278}
279
280// ---------------------------------------------------------------- hooks
281
282export const register: Register = (on, options) => {
283  const cfg = parseConfig(options)
284  const C = PALETTES[cfg.palette]
285  const deckOptions: DeckOptions = { testPattern: cfg.testPattern }
286  // tool.check carries no loop id; the tool.call around it does, keyed by the call's id.
287  const callLoop = new Map<string, string | null>()
288
289  on('session.start', async ($, e, next) => {
290    await $.command.register({
291      name: 'sessiondeck',
292      description: 'Sessiondeck, the live agent dashboard: open, close, reset, or set the layout',
293      argumentHint: '[open|close|reset|layout auto|compact|wide|mini]',
294    })
295    await migrate($)
296    if (cfg.isTestPatternFallback) await say($, 'sessiondeck', 'testPattern did not compile; using the default', 'error')
297    // A host without usage (headless, an SDK host, a session not yet bound) just starts without it.
298    const u = await $.session.usage().catch(() => null)
299    if (u) {
300      await update($, usage, x => ({
301        ...normalize(DEFAULT_USAGE, x),
302        pct: u.context.percent ?? null,
303        tokens: u.context.tokens ?? null,
304        window: u.context.window,
305        costUsd: u.cost?.usd ?? null,
306        limits: u.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
307      }))
308    }
309    if (cfg.openOnStart) void openPane($).catch(() => undefined)
310    await refreshStatus($, cfg)
311    return next(e)
312  })
313
314  on('session.end', async ($, e, next) => {
315    if (e.reason === 'clear') {
316      await resetAll($)
317      await refreshStatus($, cfg)
318    }
319    return next(e)
320  })
321
322  on('command.run', { command: 'sessiondeck' }, async ($, e) => {
323    const [verb = 'open', arg = ''] = e.args.trim().split(/\s+/)
324    if (verb === 'close') {
325      await $.ui.close({ id: PANE })
326      return { text: 'Sessiondeck closed.' }
327    }
328    if (verb === 'reset') {
329      await resetAll($)
330      await refreshStatus($, cfg)
331      return { text: 'Sessiondeck reset.' }
332    }
333    if (verb === 'layout') {
334      const layout: Layout | null = arg === 'compact' || arg === 'wide' || arg === 'auto' || arg === 'mini' ? arg : null
335      if (!layout) return { text: 'Usage: /sessiondeck layout auto|compact|wide|mini' }
336      await update($, view, v => ({ ...normalize(DEFAULT_VIEW, v), layout }))
337      const opened = await openPane($)
338      return { text: opened.isPlaced ? `Sessiondeck layout: ${layout}.` : `Layout set to ${layout}; the pane is not shown yet: ${opened.reason}` }
339    }
340    const opened = await openPane($)
341    if (!opened.isPlaced) return { text: `Sessiondeck is not shown yet: ${opened.reason}` }
342    return { text: 'Sessiondeck opened. Focus it with ctrl+x tab; 1-9 select agents, n/b walk past nine, x clears; f/s/o open the gate rows.' }
343  })
344
345  on('classic.UserPromptSubmit', async ($, e, next) => {
346    await noteMode($, e.permission_mode)
347    const r = await next(e)
348    await safely($, () => apply($, { kind: 'prompt', text: e.prompt }, deckOptions))
349    return r
350  })
351
352  on('turn.start', async ($, e, next) => {
353    const [now, cost, a] = await Promise.all([$.clock.now(), costNow($), agentCounts($)])
354    await update($, turn, () => ({ ...DEFAULT_TURN, startedAt: now, costAtStart: cost, agentsAtStart: a.total }))
355    await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: true }))
356    if (e.text) {
357      const p = promptLine(redact(e.text))
358      await say($, p.who, p.text)
359    }
360    const r = await next(e)
361    await safely($, async () => {
362      await apply($, { kind: 'turn.start', at: now, turnId: e.turnId, text: e.text }, deckOptions)
363      await ensureSampler($)
364    })
365    return r
366  })
367
368  on('turn.step', async function* ($, e, next) {
369    // The main loop's model is known when its request starts; a long first request shouldn't read "—".
370    if (!e.agentId) {
371      await update($, main, m => {
372        const x = normalize(DEFAULT_MAIN, m)
373        return { ...x, model: e.model, effort: String(e.effort ?? x.effort), steps: x.steps + 1 }
374      })
375      const result = yield* next(e)
376      await safely($, () => apply($, { kind: 'step', agentId: null, usage: result.usage }, deckOptions))
377      return result
378    }
379    const result = yield* next(e)
380    const id = e.agentId
381    const cards = await getCards($)
382    if (cards.some(c => c.id === id)) {
383      const step = { model: e.model, usage: result.usage, stopReason: result.stopReason }
384      await update($, agents, list => listOf<unknown>(list).map(normalizeCard).map(c => (c.id === id ? applyStep(c, step) : c)))
385      if (result.stopReason === 'max_tokens') await say($, await whoIs($, id), 'hit max_tokens', 'error', id)
386    } else {
387      const now = await $.clock.now()
388      await update($, loops, l => stepLoop(listOf<Loop>(l), id, now))
389    }
390    return result
391  })
392
393  on('session.measure', async ($, e, next) => {
394    await update($, usage, x => ({
395      ...normalize(DEFAULT_USAGE, x),
396      pct: e.context.percent ?? null,
397      tokens: e.context.tokens ?? null,
398      window: e.context.window,
399      costUsd: e.cost?.usd ?? null,
400      limits: e.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
401    }))
402    if (e.changed.includes('context')) await refreshStatus($, cfg)
403    const r = await next(e)
404    await safely($, async () => apply($, { kind: 'measure', percent: e.context.percent ?? null, at: await $.clock.now() }, deckOptions))
405    return r
406  })
407
408  on('session.compact', async ($, e, next) => {
409    const done = await next(e)
410    if (!e.agentId && e.trigger !== 'precompute') {
411      const now = await $.clock.now()
412      await update($, usage, x => {
413        const u = normalize(DEFAULT_USAGE, x)
414        return { ...u, compactions: u.compactions + 1, lastCompactAt: now }
415      })
416      await say($, 'main', `context compacted (${e.trigger})`)
417      await safely($, () => apply($, { kind: 'compact', at: now }, deckOptions))
418    }
419    return done
420  })
421
422  on('tool.check', async ($, e, next) => {
423    const verdict = await next(e)
424    if (e.tool_use_id) {
425      const check: Check = {
426        id: e.tool_use_id,
427        tool: e.tool,
428        bucket: bucketOf(e.tool),
429        verdict: verdict.decision === 'allow' ? 'rule' : verdict.decision,
430        inSubagent: Boolean(callLoop.get(e.tool_use_id)),
431        detail: shorten(describeInput(e.tool, e.input), 90),
432        at: await $.clock.now(),
433      }
434      await update($, gate, g => recordCheck(normalizeGate(g), check))
435      // The status line updates when the call settles; only a refusal ends here.
436      if (verdict.decision === 'deny') {
437        await say($, 'gate', `denied by rule · ${check.detail}`, 'error')
438        await refreshStatus($, cfg)
439      }
440    }
441    return verdict
442  })
443
444  on('tool.call', async ($, e, next) => {
445    callLoop.set(e.tool_use_id, e.agentId ?? null)
446    const calledAt = await $.clock.now()
447    const ran = await next(e).finally(() => callLoop.delete(e.tool_use_id))
448    const didRun = ran.deny === undefined
449    // Settle this call's pending ask, if it had one; skip the write (and the redraw) otherwise.
450    const g0 = await getGate($)
451    const isSettled = settleCheck(g0, e.tool_use_id, didRun) !== g0
452    if (isSettled) await update($, gate, g => settleCheck(normalizeGate(g), e.tool_use_id, didRun))
453    await safely($, async () => {
454      const settledAt = await $.clock.now()
455      await apply($, {
456        kind: 'tool',
457        tool: e.tool,
458        agentId: e.agentId ?? null,
459        outcome: !didRun ? 'denied' : ran.isError ? 'error' : 'ok',
460        // A call that waited on an ask measures the human, not the tool.
461        ms: isSettled ? null : settledAt - calledAt,
462        filePath: e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : e.tool === 'NotebookEdit' ? e.notebook_path : null,
463        command: e.tool === 'Bash' ? e.command : null,
464        at: settledAt,
465      }, deckOptions)
466    })
467    if (e.tool === 'Agent') {
468      if (isSettled) await refreshStatus($, cfg)
469      return ran
470    }
471    const hasFailed = didRun && ran.isError === true
472    const isEdit = !hasFailed && didRun && EDIT_TOOLS.has(e.tool)
473    const t0 = await getTurn($)
474    if (isEdit || hasFailed || (!e.agentId && t0.errorStreak > 0)) {
475      await update($, turn, t => afterCall(normalize(DEFAULT_TURN, t), { inSubagent: Boolean(e.agentId), hasFailed, isEdit }))
476    }
477    const text = shorten(describeInput(e.tool, e), 64)
478    if (e.agentId) {
479      const id = e.agentId
480      await update($, agents, list =>
481        listOf<unknown>(list)
482          .map(normalizeCard)
483          .map(c => (c.id === id ? noteTool(c, { tool: e.tool, text, isError: hasFailed || ran.deny !== undefined }) : c)),
484      )
485    }
486    // The log keeps what is worth a glance: refusals, errors and edits; the rest is on the cards.
487    if (ran.deny !== undefined) await say($, await whoIs($, e.agentId), `${text}  denied`, 'error', e.agentId ?? null)
488    else if (hasFailed) await say($, await whoIs($, e.agentId), `${text}  ✗`, 'error', e.agentId ?? null)
489    else if (isEdit) await say($, await whoIs($, e.agentId), text, 'info', e.agentId ?? null)
490    if (isSettled || !didRun) await refreshStatus($, cfg)
491    return ran
492  })
493
494  on('agent.spawn', async ($, e, next) => {
495    const started = await next(e)
496    if (!e.parentAgentId) await noteMode($, e.permissionMode)
497    if (started.deny !== undefined || !started.agentId) return started
498    const id = started.agentId
499    const card: AgentCard = {
500      ...normalizeCard({}),
501      id,
502      type: e.name ?? e.subagentType,
503      model: started.model,
504      description: e.description,
505      spawnedAt: await $.clock.now(),
506      parentAgentId: e.parentAgentId ?? null,
507    }
508    await update($, agents, list => [...listOf<unknown>(list).map(normalizeCard), card])
509    await safely($, async () => {
510      await admit($, cfg.maxCards)
511      await ensureSampler($)
512    })
513    await update($, loops, l => listOf<Loop>(l).filter(x => x.id !== id))
514    await say($, shorten(cardTitle(card), LOG_WHO_MAX), `spawned · ${card.type}`, 'info', id)
515    await refreshStatus($, cfg)
516    return started
517  })
518
519  on('turn.complete', async ($, e, next) => {
520    const done = await next(e)
521    const id = e.agentId
522    const now = await $.clock.now()
523    await safely($, () => apply($, { kind: 'turn.complete', agentId: id ?? null, turnId: e.turnId, isAborted: e.isAborted, at: now }, deckOptions))
524    if (!id) {
525      const [t, a, cost] = await Promise.all([getTurn($), agentCounts($), costNow($)])
526      const r = receiptOf(t, {
527        durationMs: e.durationMs,
528        agentsSince: a.total - t.agentsAtStart,
529        costNow: cost,
530        reason: e.reason,
531      })
532      await update($, receipt, () => r)
533      await update($, main, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: false }))
534      await refreshStatus($, cfg)
535      return done
536    }
537    const cards = await getCards($)
538    if (cards.some(c => c.id === id)) {
539      const status = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
540      await update($, agents, list =>
541        listOf<unknown>(list)
542          .map(normalizeCard)
543          .map(c => (c.id === id ? { ...c, status, endedAt: now, answer: shorten(e.answer, 400) } : c)),
544      )
545      const card = cards.find(c => c.id === id)
546      const took = card ? fmtDuration(now - card.spawnedAt) : ''
547      await say($, await whoIs($, id), status === 'done' ? `done · ${took}` : status, status === 'done' ? 'done' : 'error', id)
548      await safely($, () => admit($, cfg.maxCards))
549    } else {
550      await update($, loops, l => listOf<Loop>(l).map(x => (x.id === id ? { ...x, isDone: true, lastAt: now } : x)))
551    }
552    await refreshStatus($, cfg)
553    return done
554  })
555
556  on('turn.abort', async ($, e, next) => {
557    const r = await next(e)
558    await safely($, async () => apply($, { kind: 'abort', turnId: e.turnId, at: await $.clock.now() }, deckOptions))
559    return r
560  })
561
562  // ---------------------------------------------------------------- drawing
563
564  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
565    const [m, u, g, cards, lp, lines, t, r, v, now, deck] = await Promise.all([
566      getMain($),
567      getUsage($),
568      getGate($),
569      getCards($),
570      getLoops($),
571      getLog($),
572      getTurn($),
573      read($, receipt),
574      getView($),
575      $.clock.now(),
576      getDeck($),
577    ])
578    // Re-arms the sampler after a hot reload dropped it; starting a timer writes no state.
579    void safely($, () => ensureSampler($))
580    return draw(
581      {
582        surface: e.surface,
583        bodyColumns: e.props.bodyColumns,
584        bodyRows: e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 40,
585        placement: e.props.placement,
586        viewedAgentId: e.props.view?.agentId ?? null,
587      },
588      $.ui.resolve(e),
589      { m, u, g, cards, lp, lines, t, r, v, now, deck },
590      cfg,
591      C,
592      {
593        toggleGate: async b => {
594          await update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), gateOpen: normalize(DEFAULT_VIEW, x).gateOpen === b ? null : b }))
595        },
596        toggleExpanded: async id => {
597          await update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), expanded: normalize(DEFAULT_VIEW, x).expanded === id ? null : id }))
598        },
599        select: async i => {
600          const card = gridOrder(await getCards($))[i]
601          if (card) await update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), cursor: i, expanded: card.id }))
602        },
603        move: async delta => {
604          const order = gridOrder(await getCards($))
605          const n = order.length
606          if (n === 0) return
607          await update($, view, x => {
608            const at = normalize(DEFAULT_VIEW, x).cursor
609            const cursor = at === null ? 0 : (at + delta + n) % n
610            return { ...normalize(DEFAULT_VIEW, x), cursor, expanded: order[cursor]?.id ?? null }
611          })
612        },
613        clear: async () => {
614          await update($, view, x => ({ ...normalize(DEFAULT_VIEW, x), cursor: null, expanded: null }))
615        },
616      },
617    )
618  })
619}
620
hooks/core.ts 816 lines
1// Pure data: defaults, reducers, formatting and layout math. Nothing here touches `$`, so every
2// behaviour is testable directly (the test kit cannot raise a subagent's tool call or a
3// permission check inside a call; these functions are what the hooks apply).
4import type {
5  AgentCard,
6  Agents,
7  AgentState,
8  AgentTally,
9  Aborts,
10  Bucket,
11  Cache,
12  Check,
13  Context,
14  ContextSample,
15  Gate,
16  HotFiles,
17  Layout,
18  LogLine,
19  Loop,
20  Main,
21  Prompt,
22  Receipt,
23  Tally,
24  Tests,
25  ToolNote,
26  Tools,
27  ToolStat,
28  Turn,
29  Usage,
30  View,
31} from '../types'
32
33export const SCHEMA_VERSION = 3
34
35// ---------------------------------------------------------------- defaults
36
37export const DEFAULT_MAIN: Main = { model: '', effort: '', mode: '', steps: 0, isRunning: false }
38export const DEFAULT_USAGE: Usage = {
39  pct: null,
40  tokens: null,
41  window: 0,
42  costUsd: null,
43  limits: [],
44  compactions: 0,
45  lastCompactAt: null,
46}
47const ZERO: Tally = { rule: 0, ask: 0, cleared: 0, deny: 0 }
48export const DEFAULT_GATE: Gate = { recent: [], totals: { file: ZERO, shell: ZERO, other: ZERO } }
49export const DEFAULT_TURN: Turn = { edits: 0, errorStreak: 0, errors: 0, startedAt: 0, costAtStart: null, agentsAtStart: 0 }
50export const DEFAULT_VIEW: View = { expanded: null, gateOpen: null, layout: null, cursor: null }
51
52const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
53
54/** A stored object merged over its defaults, so a value saved under an older shape still reads. */
55export const normalize = <T extends object>(def: T, stored: unknown): T =>
56  isObject(stored) ? ({ ...def, ...stored } as T) : def
57
58/** A stored list, or empty when what is stored is not a list. */
59export const listOf = <T>(stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : [])
60
61export const normalizeGate = (stored: unknown): Gate => {
62  const g = normalize(DEFAULT_GATE, stored)
63  const totals = normalize(DEFAULT_GATE.totals, g.totals)
64  return {
65    recent: listOf<Check>(g.recent),
66    totals: { file: normalize(ZERO, totals.file), shell: normalize(ZERO, totals.shell), other: normalize(ZERO, totals.other) },
67  }
68}
69
70export const normalizeCard = (stored: unknown): AgentCard =>
71  normalize<AgentCard>(
72    {
73      id: '',
74      type: 'agent',
75      model: '',
76      description: '',
77      status: 'running',
78      spawnedAt: 0,
79      endedAt: null,
80      ctx: 0,
81      out: 0,
82      steps: 0,
83      lastStop: null,
84      tools: [],
85      answer: '',
86      parentAgentId: null,
87    },
88    stored,
89  )
90
91export const normalizeLog = (stored: unknown): LogLine[] =>
92  listOf<Record<string, unknown>>(stored).map(l => ({
93    at: typeof l.at === 'number' ? l.at : 0,
94    who: String(l.who ?? ''),
95    text: String(l.text ?? ''),
96    agentId: typeof l.agentId === 'string' ? l.agentId : null,
97    kind: l.kind === 'error' || l.kind === 'done' ? l.kind : 'info',
98  }))
99
100// ---------------------------------------------------------------- config
101
102export type Panel = 'main' | 'prompt' | 'gate' | 'agents' | 'tools' | 'turn' | 'loops' | 'receipt' | 'log'
103const PANELS: readonly Panel[] = ['main', 'prompt', 'gate', 'agents', 'tools', 'turn', 'loops', 'receipt', 'log']
104
105export type Config = {
106  panels: Panel[]
107  motion: boolean
108  maxCards: number
109  layout: Layout
110  palette: Palette
111  openOnStart: boolean
112  statusLine: boolean
113  testPattern: RegExp
114  isTestPatternFallback: boolean
115  historyMinutes: number
116  charts: boolean
117}
118
119export const GATE_LABEL = 'GATE'
120
121// Flags stay 'i': a user pattern cannot add g or y, which would make .test stateful inside the pure reducer.
122export const safeRegExp = (source: string, fallback: string): { re: RegExp; isFallback: boolean } => {
123  try {
124    return { re: new RegExp(source || fallback, 'i'), isFallback: false }
125  } catch {
126    return { re: new RegExp(fallback, 'i'), isFallback: true }
127  }
128}
129
130/** The plugin's `/config` values, read leniently: anything malformed falls back to the default. */
131export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => {
132  const str = (k: string, d: string) => (typeof o[k] === 'string' && o[k] !== '' ? (o[k] as string) : d)
133  const bool = (k: string, d: boolean) => (typeof o[k] === 'boolean' ? (o[k] as boolean) : d)
134  const panels = str('panels', PANELS.join(','))
135    .split(',')
136    .map(s => s.trim())
137    .filter((p): p is Panel => (PANELS as readonly string[]).includes(p))
138  const layout = str('layout', 'auto')
139  const max = typeof o.maxCards === 'number' && Number.isFinite(o.maxCards) ? Math.round(o.maxCards) : MAX_CARDS
140  const pattern = safeRegExp(str('testPattern', DEFAULT_TEST_PATTERN), DEFAULT_TEST_PATTERN)
141  const minutes = typeof o.historyMinutes === 'number' && Number.isFinite(o.historyMinutes) ? Math.round(o.historyMinutes) : 30
142  return {
143    panels: panels.length > 0 ? [...new Set(panels)] : [...PANELS],
144    motion: str('motion', 'while-active') !== 'off',
145    maxCards: Math.min(200, Math.max(1, max)),
146    layout: layout === 'compact' || layout === 'wide' || layout === 'mini' ? layout : 'auto',
147    palette: oneOf(str('palette', 'theme'), ['theme', 'pastel', 'dracula'], 'theme'),
148    openOnStart: bool('openOnStart', true),
149    statusLine: bool('statusLine', true),
150    testPattern: pattern.re,
151    isTestPatternFallback: pattern.isFallback,
152    historyMinutes: Math.min(30, Math.max(1, minutes)),
153    charts: bool('charts', true),
154  }
155}
156
157// ---------------------------------------------------------------- palette
158
159export type Palette = 'theme' | 'pastel' | 'dracula'
160export type Colors = Record<'main' | 'agent' | 'gate' | 'cleared' | 'amber' | 'warn' | 'dim' | 'faint' | 'text', string> & {
161  /** Painted behind the whole deck; absent, the host's own background shows through. */
162  bg?: string
163}
164
165/**
166 * `theme` names the person's own theme colours (they follow light, dark and colour-blind themes);
167 * `pastel` is fixed hex tuned for dark terminals.
168 */
169export const PALETTES: Record<Palette, Colors> = {
170  theme: {
171    main: 'claude',
172    agent: 'suggestion',
173    gate: 'success',
174    cleared: 'permission',
175    amber: 'warning',
176    warn: 'error',
177    dim: 'inactive',
178    faint: 'subtle',
179    text: 'text',
180  },
181  pastel: {
182    main: '#7dd3fc',
183    agent: '#93c5fd',
184    gate: '#86efac',
185    cleared: '#5eead4',
186    amber: '#fcd34d',
187    warn: '#fca5a5',
188    dim: '#6b7280',
189    faint: '#3f4654',
190    text: '#e5e7eb',
191    bg: '#111827',
192  },
193  dracula: {
194    main: '#bd93f9',
195    agent: '#8be9fd',
196    gate: '#50fa7b',
197    cleared: '#ff79c6',
198    amber: '#ffb86c',
199    warn: '#ff5555',
200    dim: '#6272a4',
201    faint: '#44475a',
202    text: '#f8f8f2',
203    bg: '#282a36',
204  },
205}
206
207/** SVG cannot name theme keys: mid-tone colours that read on light and dark backgrounds. */
208export const SVG_COLORS = { running: '#3b82f6', done: '#16a34a', failed: '#dc2626', other: '#8b5cf6', label: '#6b7280' }
209
210// ---------------------------------------------------------------- formatting
211
212/** `claude-opus-5-5` → `Opus 5.5`; anything else is shown as given. */
213/**
214 * A model id as people say it, from any provider's spelling: `claude-opus-5-5[1m]` → `Opus 5.5 1M`,
215 * `us.anthropic.claude-sonnet-4-5-20250929-v1:0` → `Sonnet 4.5`, `claude-3-5-haiku-20241022` →
216 * `Haiku 3.5`. Anything else is shown as given, cut to 22 characters.
217 */
218export const prettyModel = (id: string) => {
219  if (!id) return '—'
220  const cap = (f: string) => f.charAt(0).toUpperCase() + f.slice(1)
221  const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : ''
222  const now = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?![\d])/i.exec(id)
223  if (now?.[1] && !/^\d/.test(now[1])) return `${cap(now[1].toLowerCase())} ${now[2]}${now[3] ? `.${now[3]}` : ''}${big}`
224  const old = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id)
225  if (old?.[3]) return `${cap(old[3].toLowerCase())} ${old[1]}${old[2] ? `.${old[2]}` : ''}${big}`
226  return shorten(id, 22)
227}
228
229/** Longest name a log line keeps; the log's name column grows to the longest one shown, up to this. */
230export const LOG_WHO_MAX = 26
231
232export const shorten = (s: string, n: number) => {
233  const one = s.replace(/\s+/g, ' ').trim()
234  return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one
235}
236
237export const kTokens = (n: number) =>
238  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
239
240export const fmtDuration = (ms: number) => {
241  const s = Math.max(0, Math.round(ms / 1000))
242  if (s < 60) return `${s}s`
243  const m = Math.floor(s / 60)
244  return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
245}
246
247/** A running clock as the live cards draw it: m:ss, or XhYY past an hour. */
248export const fmtTimer = (ms: number) => {
249  const s = Math.max(0, Math.floor(ms / 1000))
250  const m = Math.floor(s / 60)
251  return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
252}
253
254export const fmtClock = (ms: number) => (ms > 0 ? new Date(ms).toTimeString().slice(0, 8) : '--:--:--')
255
256/** `1 error`, `2 errors`. */
257export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
258
259export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
260
261/** A gauge of `width` cells: ▰ filled, ▱ empty. */
262export const gauge = (pct: number, width: number) => {
263  const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
264  return { on: '▰'.repeat(full), off: '▱'.repeat(width - full) }
265}
266
267/** A rate-limit window's short name: `five_hour` → `5h`, `seven_day_opus` → `7d opus`. */
268export const limitLabel = (kind: string) =>
269  kind
270    .replace(/five[_ -]?hours?/i, '5h')
271    .replace(/seven[_ -]?days?/i, '7d')
272    .replace(/[_-]+/g, ' ')
273    .trim()
274
275// ---------------------------------------------------------------- redaction
276
277const SECRETS: [RegExp, string][] = [
278  [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?\S+/gi, '$1$2•••'],
279  [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'],
280  [/\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'],
281  [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1•••'],
282  [/(--(?:token|password|api-key|secret)[= ])\S+/gi, '$1•••'],
283  [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)\S+/g, '$1•••'],
284  [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'],
285]
286
287/** What the gate drill-down may store: credentials masked before anything is written to state. */
288export const redact = (s: string) => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s)
289
290// ---------------------------------------------------------------- tools and gate
291
292const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit', 'Glob', 'Grep'])
293const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
294export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
295
296export const bucketOf = (tool: string): Bucket =>
297  FILE_TOOLS.has(tool) ? 'file' : SHELL_TOOLS.has(tool) ? 'shell' : 'other'
298
299/** One line saying what a call was about: its command, path or pattern; redacted. */
300export const describeInput = (tool: string, input: unknown) => {
301  const i = isObject(input) ? input : {}
302  const path = typeof i.file_path === 'string' ? i.file_path.split(/[\\/]/).slice(-2).join('/') : ''
303  const what =
304    typeof i.command === 'string'
305      ? i.command
306      : path || (typeof i.pattern === 'string' ? i.pattern : typeof i.url === 'string' ? i.url : typeof i.description === 'string' ? i.description : '')
307  return redact(what ? `${tool} → ${what}` : tool)
308}
309
310/** Keeps the last `max` checks, but never drops a pending ask: its settle must still find it. */
311export const trimRecent = (list: Check[], max: number): Check[] => {
312  let extra = list.length - max
313  if (extra <= 0) return list
314  const out: Check[] = []
315  for (const c of list) {
316    if (extra > 0 && c.verdict !== 'ask') {
317      extra -= 1
318      continue
319    }
320    out.push(c)
321  }
322  return out.slice(-max * 2)
323}
324
325export const recordCheck = (g: Gate, c: Check): Gate => {
326  const t = g.totals[c.bucket]
327  return {
328    recent: trimRecent([...g.recent, c], 80),
329    totals: { ...g.totals, [c.bucket]: { ...t, [c.verdict]: t[c.verdict] + 1 } },
330  }
331}
332
333/** An `ask` settled by the call that followed it: it ran (cleared) or was refused (deny). */
334export const settleCheck = (g: Gate, id: string, didRun: boolean): Gate => {
335  const c = g.recent.find(r => r.id === id && r.verdict === 'ask')
336  if (!c) return g
337  const verdict = didRun ? 'cleared' : 'deny'
338  const t = g.totals[c.bucket]
339  return {
340    recent: g.recent.map(r => (r === c ? { ...r, verdict } : r)),
341    totals: { ...g.totals, [c.bucket]: { ...t, ask: Math.max(0, t.ask - 1), [verdict]: t[verdict] + 1 } },
342  }
343}
344
345export const gateSummary = (g: Gate) => {
346  const all = (['file', 'shell', 'other'] as const).reduce(
347    (s, k) => ({
348      rule: s.rule + g.totals[k].rule,
349      ask: s.ask + g.totals[k].ask,
350      cleared: s.cleared + g.totals[k].cleared,
351      deny: s.deny + g.totals[k].deny,
352    }),
353    { ...ZERO },
354  )
355  return { ...all, total: all.rule + all.ask + all.cleared + all.deny }
356}
357
358// ---------------------------------------------------------------- turn
359
360/**
361 * The turn after one tool call. Errors count in the main loop only (a subagent's failure is its
362 * own); edits count from every loop, so delegated work still counts.
363 */
364export const afterCall = (t: Turn, c: { inSubagent: boolean; hasFailed: boolean; isEdit: boolean }): Turn => ({
365  ...t,
366  errorStreak: c.inSubagent ? t.errorStreak : c.hasFailed ? t.errorStreak + 1 : 0,
367  errors: t.errors + (!c.inSubagent && c.hasFailed ? 1 : 0),
368  edits: t.edits + (c.isEdit ? 1 : 0),
369})
370
371export const receiptOf = (t: Turn, o: { durationMs: number; agentsSince: number; costNow: number | null; reason: string }): Receipt => ({
372  durationMs: o.durationMs,
373  agents: o.agentsSince,
374  edits: t.edits,
375  errors: t.errors,
376  costDelta: o.costNow !== null && t.costAtStart !== null && o.costNow - t.costAtStart >= 0.005 ? o.costNow - t.costAtStart : null,
377  reason: o.reason,
378  startedAt: t.startedAt,
379})
380
381// ---------------------------------------------------------------- agents and loops
382
383export type StepUsage = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | null
384
385/** A card after one of its model requests: its context is the latest step's whole input; output adds up. */
386export const applyStep = (c: AgentCard, s: { model: string; usage: StepUsage; stopReason: string | null }): AgentCard => {
387  const u = s.usage ?? {}
388  const ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
389  return {
390    ...c,
391    model: c.model || s.model,
392    steps: c.steps + 1,
393    ctx: ctx > 0 ? ctx : c.ctx,
394    out: c.out + (u.output_tokens ?? 0),
395    lastStop: s.stopReason,
396  }
397}
398
399export const noteTool = (c: AgentCard, n: ToolNote): AgentCard => ({ ...c, tools: [...c.tools, n].slice(-3) })
400
401export const stepLoop = (loops: Loop[], id: string, at: number): Loop[] => {
402  const found = loops.find(l => l.id === id)
403  const next = found
404    ? loops.map(l => (l === found ? { ...l, steps: l.steps + 1, lastAt: at } : l))
405    : [...loops, { id, steps: 1, firstAt: at, lastAt: at, isDone: false }]
406  return next.slice(-60)
407}
408
409export const LOOP_ACTIVE_MS = 15_000
410export const isLoopActive = (l: Loop, now: number) => !l.isDone && now - l.lastAt < LOOP_ACTIVE_MS
411
412/** Swimlane geometry: each agent's bar on one shared axis from the first spawn to now. */
413export const lanes = (cards: AgentCard[], now: number, width: number) => {
414  const start = Math.min(...cards.map(c => c.spawnedAt).filter(n => n > 0), now)
415  const span = Math.max(1, now - start)
416  return cards.map(c => {
417    const from = Math.floor(((Math.max(c.spawnedAt, start) - start) / span) * width)
418    const to = Math.max(from + 1, Math.ceil((((c.endedAt ?? now) - start) / span) * width))
419    return { id: c.id, before: Math.min(from, width), bar: Math.min(to, width) - Math.min(from, width), after: width - Math.min(to, width) }
420  })
421}
422
423// ---------------------------------------------------------------- layout
424
425/** How many log lines fit: what the other panels leave, never fewer than 4 nor more than 8. */
426export const logRows = (bodyRows: number, used: number) => Math.max(4, Math.min(8, bodyRows - used - 3))
427
428/** Legend items that fit on one row of `width` cells, in order; the rest are dropped. */
429export const fitLegend = <T extends { label: string }>(items: T[], width: number) => {
430  const out: T[] = []
431  let used = 0
432  for (const it of items) {
433    const w = it.label.length + 4
434    if (used + w > width) break
435    out.push(it)
436    used += w
437  }
438  return out
439}
440
441export type TitlePart = { swatch?: { glyph: string; ch: string }; text: string; short: string }
442export type FittedTitle = { sep: string; parts: TitlePart[] }
443
444export const titleText = (f: FittedTitle) => f.parts.map(p => (p.swatch?.glyph ?? '') + p.text).join(f.sep)
445
446/** Every part long or every part short, so a narrow title never loses its last count to truncation. */
447export const fitTitle = (parts: TitlePart[], width: number): FittedTitle => {
448  const long = { sep: ' · ', parts }
449  if (titleText(long).length <= width) return long
450  const spaced = { sep: ' ', parts: parts.map(p => ({ ...p, text: p.short })) }
451  if (titleText(spaced).length <= width) return spaced
452  return { sep: ' ', parts: parts.map(p => ({ ...p, text: p.swatch ? p.short.trimStart() : p.short })) }
453}
454
455/** A card's title row: the task in the agent's own words, the type only when there is none. */
456export const cardTitle = (c: AgentCard) => c.description || c.type
457
458/** A title split over two rows at a word boundary: `first` cells on row one, `rest` on row two. */
459export const titleLines = (title: string, first: number, rest: number): [string, string] => {
460  const t = title.replace(/\s+/g, ' ').trim()
461  if (t.length <= first) return [t, '']
462  const cut = t.lastIndexOf(' ', first)
463  const at = cut > 0 ? cut : first
464  return [t.slice(0, at).trim(), shorten(t.slice(at), rest)]
465}
466
467/**
468 * What a turn's opening text was, for the log: the person's words, or for a turn the engine
469 * opened with a tagged message (a subagent's hand-back, a task notification), that message's kind.
470 */
471const ENGINE_TAG = /^\s*<([a-z][\w-]*)/i
472
473export const promptLine = (text: string): { who: string; text: string } => {
474  const tag = ENGINE_TAG.exec(text)?.[1]
475  if (!tag) return { who: 'you', text: shorten(text, 70) }
476  const from = /\bfrom="([^"]+)"/.exec(text)?.[1]
477  return { who: 'engine', text: shorten(`${tag.replace(/[-_]/g, ' ')}${from ? ` from ${from.slice(0, 8)}` : ''}`, 70) }
478}
479
480export const elapsedOf = (c: AgentCard, now: number) => (c.endedAt ?? now) - c.spawnedAt
481
482// ---------------------------------------------------------------- deck
483
484export const PROMPT_CHARS = 48
485export const LATENCY_WINDOW = 20
486export const ABORT_CAP = 20
487export const SAMPLE_CAP = 180
488export const SAMPLE_MS = 10_000
489export const HOT_FILE_WARN = 4
490export const TOOLS_SHOWN = 8
491export const MAX_CARDS = 64
492export const DEFAULT_TEST_PATTERN = '\\b(vitest|jest|pytest|go test|cargo test|npm test|pnpm test|bun test|mocha)\\b'
493export const HOT_FILE_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
494
495export type Deck = {
496  prompt: Prompt
497  cache: Cache
498  tools: Tools
499  hotFiles: HotFiles
500  tests: Tests
501  aborts: Aborts
502  agents: Agents
503  context: Context
504}
505
506export type DeckOptions = { testPattern: RegExp }
507
508export type DeckEvent =
509  | { kind: 'prompt'; text: string }
510  | { kind: 'turn.start'; at: number; turnId: string; text: string }
511  | { kind: 'step'; agentId: string | null; usage: StepUsage }
512  | {
513      kind: 'tool'
514      tool: string
515      agentId: string | null
516      outcome: 'ok' | 'error' | 'denied'
517      ms: number | null
518      filePath: string | null
519      command: string | null
520      at: number
521    }
522  | { kind: 'turn.complete'; agentId: string | null; turnId: string; isAborted: boolean; at: number }
523  | { kind: 'abort'; turnId: string; at: number }
524  | { kind: 'measure'; percent: number | null; at: number }
525  | { kind: 'compact'; at: number }
526  | { kind: 'tick'; at: number; counts: Omit<AgentTally, 'spawning'> }
527  | { kind: 'reset' }
528
529type ToolEvent = Extract<DeckEvent, { kind: 'tool' }>
530
531export const DEFAULT_PROMPT: Prompt = { text: '', startedAt: null, status: 'idle' }
532export const DEFAULT_CACHE: Cache = { read: 0, input: 0, creation: 0 }
533export const DEFAULT_TESTS: Tests = { light: 'off', pass: 0, fail: 0, lastAt: null }
534export const DEFAULT_ABORTS: Aborts = { count: 0, at: [], turnIds: [] }
535export const DEFAULT_AGENTS: Agents = { byType: {}, history: [] }
536export const DEFAULT_CONTEXT: Context = { history: [], compactPending: false, lastPercent: null }
537export const DEFAULT_DECK: Deck = {
538  prompt: DEFAULT_PROMPT,
539  cache: DEFAULT_CACHE,
540  tools: {},
541  hotFiles: {},
542  tests: DEFAULT_TESTS,
543  aborts: DEFAULT_ABORTS,
544  agents: DEFAULT_AGENTS,
545  context: DEFAULT_CONTEXT,
546}
547export const DEFAULT_OPTIONS: DeckOptions = { testPattern: new RegExp(DEFAULT_TEST_PATTERN, 'i') }
548
549const DEFAULT_TOOL_STAT: ToolStat = { calls: 0, errors: 0, denies: 0, recentMs: [], recentOk: [], lastOk: true }
550const ZERO_TALLY: AgentTally = { spawning: 0, running: 0, done: 0, error: 0 }
551
552const recordOf = <T>(stored: unknown, each: (v: unknown) => T): Record<string, T> =>
553  isObject(stored) ? Object.fromEntries(Object.entries(stored).map(([k, v]) => [k, each(v)])) : {}
554
555const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
556const numberOr = (v: unknown, def: number): number => (isNumber(v) ? v : def)
557const numberOrNull = (v: unknown): number | null => (isNumber(v) ? v : null)
558const oneOf = <T extends string>(v: unknown, all: readonly T[], def: T): T => all.find(x => x === v) ?? def
559const fieldsOf = (stored: unknown): Record<string, unknown> => (isObject(stored) ? stored : {})
560const timed = (stored: unknown): Record<string, unknown>[] =>
561  listOf<unknown>(stored).filter((v): v is Record<string, unknown> => isObject(v) && isNumber(v.at))
562
563const PROMPT_STATUSES = ['idle', 'running', 'done', 'aborted'] as const
564const TEST_LIGHTS = ['off', 'pass', 'fail'] as const
565
566const normalizeToolStat = (stored: unknown): ToolStat => {
567  const s = fieldsOf(stored)
568  return {
569    calls: numberOr(s.calls, 0),
570    errors: numberOr(s.errors, 0),
571    denies: numberOr(s.denies, 0),
572    recentMs: listOf<unknown>(s.recentMs).filter(isNumber),
573    recentOk: listOf<unknown>(s.recentOk).filter((v): v is boolean => typeof v === 'boolean'),
574    lastOk: typeof s.lastOk === 'boolean' ? s.lastOk : true,
575  }
576}
577
578const normalizeTally = (stored: unknown): AgentTally => {
579  const t = fieldsOf(stored)
580  return { spawning: numberOr(t.spawning, 0), running: numberOr(t.running, 0), done: numberOr(t.done, 0), error: numberOr(t.error, 0) }
581}
582
583/** Every field coerced to its type, so no corrupt stored value can throw in a reducer or a render. */
584export const normalizeDeck = (stored: Partial<Record<keyof Deck, unknown>>): Deck => {
585  const p = fieldsOf(stored.prompt)
586  const c = fieldsOf(stored.cache)
587  const t = fieldsOf(stored.tests)
588  const a = fieldsOf(stored.aborts)
589  const ag = fieldsOf(stored.agents)
590  const cx = fieldsOf(stored.context)
591  return {
592    prompt: {
593      text: typeof p.text === 'string' ? p.text : '',
594      startedAt: numberOrNull(p.startedAt),
595      status: oneOf(p.status, PROMPT_STATUSES, 'idle'),
596    },
597    cache: { read: numberOr(c.read, 0), input: numberOr(c.input, 0), creation: numberOr(c.creation, 0) },
598    tools: recordOf(stored.tools, normalizeToolStat),
599    hotFiles: recordOf(stored.hotFiles, v => numberOr(v, 0)),
600    tests: { light: oneOf(t.light, TEST_LIGHTS, 'off'), pass: numberOr(t.pass, 0), fail: numberOr(t.fail, 0), lastAt: numberOrNull(t.lastAt) },
601    aborts: {
602      count: numberOr(a.count, 0),
603      at: listOf<unknown>(a.at).filter(isNumber),
604      turnIds: listOf<unknown>(a.turnIds).filter((v): v is string => typeof v === 'string'),
605    },
606    agents: {
607      byType: recordOf(ag.byType, normalizeTally),
608      history: timed(ag.history).map(h => ({ at: numberOr(h.at, 0), running: numberOr(h.running, 0), done: numberOr(h.done, 0), error: numberOr(h.error, 0) })),
609    },
610    context: {
611      history: timed(cx.history).map(h => ({
612        at: numberOr(h.at, 0),
613        percent: numberOr(h.percent, 0),
614        cacheHit: numberOr(h.cacheHit, 0),
615        compacted: h.compacted === true,
616      })),
617      compactPending: cx.compactPending === true,
618      lastPercent: numberOrNull(cx.lastPercent),
619    },
620  }
621}
622
623const capped = <T>(list: T[], item: T, cap: number): T[] => [...list, item].slice(-cap)
624
625const noteAbort = (a: Aborts, turnId: string, at: number): Aborts =>
626  a.turnIds.includes(turnId)
627    ? a
628    : { count: a.count + 1, at: capped(a.at, at, ABORT_CAP), turnIds: capped(a.turnIds, turnId, ABORT_CAP) }
629
630const recordTool = (s: ToolStat, e: ToolEvent): ToolStat => ({
631  calls: s.calls + 1,
632  errors: s.errors + (e.outcome === 'error' ? 1 : 0),
633  denies: s.denies + (e.outcome === 'denied' ? 1 : 0),
634  recentMs: e.ms !== null && e.outcome !== 'denied' ? capped(s.recentMs, e.ms, LATENCY_WINDOW) : s.recentMs,
635  recentOk: capped(s.recentOk, e.outcome === 'ok', LATENCY_WINDOW),
636  lastOk: e.outcome === 'ok',
637})
638
639const recordEdit = (h: HotFiles, e: ToolEvent): HotFiles =>
640  HOT_FILE_TOOLS.has(e.tool) && e.outcome !== 'denied' && e.filePath !== null
641    ? { ...h, [e.filePath]: (h[e.filePath] ?? 0) + 1 }
642    : h
643
644const recordTest = (t: Tests, e: ToolEvent, pattern: RegExp): Tests => {
645  if (e.tool !== 'Bash' || e.outcome === 'denied' || e.command === null || !pattern.test(e.command)) return t
646  return e.outcome === 'ok'
647    ? { light: 'pass', pass: t.pass + 1, fail: t.fail, lastAt: e.at }
648    : { light: 'fail', pass: t.pass, fail: t.fail + 1, lastAt: e.at }
649}
650
651/** A sample that coalesces into the previous one keeps that one's compaction mark. */
652const sampleContext = (c: Context, s: ContextSample, coalesce: boolean): Context => {
653  const last = c.history.at(-1)
654  const history =
655    coalesce && last && s.at - last.at < SAMPLE_MS
656      ? [...c.history.slice(0, -1), { ...s, compacted: s.compacted || last.compacted }]
657      : capped(c.history, s, SAMPLE_CAP)
658  return { history, compactPending: false, lastPercent: s.percent }
659}
660
661export const reduceDeck = (d: Deck, e: DeckEvent, opts: DeckOptions = DEFAULT_OPTIONS): Deck => {
662  switch (e.kind) {
663    case 'prompt':
664      return { ...d, prompt: { ...d.prompt, text: shorten(redact(e.text), PROMPT_CHARS) } }
665    case 'turn.start': {
666      // A plugin beneath can abort the turn inside its own start, before this event is applied.
667      const status = d.aborts.turnIds.includes(e.turnId) ? 'aborted' : 'running'
668      // A turn an engine message opened (a task notification, a hand-back) keeps the human's prompt.
669      const isHuman = e.text.trim() !== '' && !ENGINE_TAG.test(e.text)
670      const text = isHuman ? shorten(redact(e.text), PROMPT_CHARS) : d.prompt.text
671      return { ...d, prompt: { text, status, startedAt: e.at }, hotFiles: {} }
672    }
673    case 'step': {
674      if (e.agentId !== null) return d
675      const u = e.usage ?? {}
676      return {
677        ...d,
678        cache: {
679          read: d.cache.read + (u.cache_read_input_tokens ?? 0),
680          input: d.cache.input + (u.input_tokens ?? 0),
681          creation: d.cache.creation + (u.cache_creation_input_tokens ?? 0),
682        },
683      }
684    }
685    case 'tool':
686      return {
687        ...d,
688        tools: { ...d.tools, [e.tool]: recordTool(d.tools[e.tool] ?? DEFAULT_TOOL_STAT, e) },
689        hotFiles: recordEdit(d.hotFiles, e),
690        tests: recordTest(d.tests, e, opts.testPattern),
691      }
692    case 'turn.complete': {
693      if (e.agentId !== null) return d
694      const isAborted = e.isAborted || d.prompt.status === 'aborted'
695      return {
696        ...d,
697        prompt: { ...d.prompt, status: isAborted ? 'aborted' : 'done' },
698        aborts: isAborted ? noteAbort(d.aborts, e.turnId, e.at) : d.aborts,
699      }
700    }
701    case 'abort':
702      return { ...d, prompt: { ...d.prompt, status: 'aborted' }, aborts: noteAbort(d.aborts, e.turnId, e.at) }
703    case 'measure':
704      if (e.percent === null) return d
705      return {
706        ...d,
707        context: sampleContext(d.context, { at: e.at, percent: e.percent, cacheHit: cacheHit(d.cache), compacted: d.context.compactPending }, true),
708      }
709    case 'compact':
710      return d.context.compactPending ? d : { ...d, context: { ...d.context, compactPending: true } }
711    case 'tick': {
712      const { running, done, error } = e.counts
713      const p = d.context.lastPercent
714      return {
715        ...d,
716        agents: { ...d.agents, history: capped(d.agents.history, { at: e.at, running, done, error }, SAMPLE_CAP) },
717        context:
718          p === null
719            ? d.context
720            : sampleContext(d.context, { at: e.at, percent: p, cacheHit: cacheHit(d.cache), compacted: d.context.compactPending }, false),
721      }
722    }
723    case 'reset':
724      return DEFAULT_DECK
725    default:
726      return d
727  }
728}
729
730export const cacheHit = (c: Cache): number => {
731  const sum = c.read + c.input + c.creation
732  return sum === 0 ? 0 : c.read / sum
733}
734
735export const meanMs = (s: ToolStat): number | null =>
736  s.recentMs.length === 0 ? null : s.recentMs.reduce((a, b) => a + b, 0) / s.recentMs.length
737
738export const badDots = (s: ToolStat): number => Math.min(4, s.recentOk.filter(ok => !ok).length)
739
740export const topTools = (t: Tools, n: number = TOOLS_SHOWN): [string, ToolStat][] =>
741  Object.entries(t)
742    .sort(([a, x], [b, y]) => y.calls - x.calls || (a < b ? -1 : a > b ? 1 : 0))
743    .slice(0, n)
744
745export const toolName = (tool: string): string => (tool.startsWith('mcp__') ? (tool.split('__').at(-1) ?? tool) : tool)
746
747export const hotFile = (h: HotFiles): { path: string; edits: number } | null =>
748  Object.entries(h).reduce<{ path: string; edits: number } | null>(
749    (best, [path, edits]) => (!best || edits > best.edits || (edits === best.edits && path < best.path) ? { path, edits } : best),
750    null,
751  )
752
753export const stateOf = (c: AgentCard): AgentState => {
754  if (c.status === 'failed' || c.status === 'stopped') return 'error'
755  if (c.status === 'done') return 'done'
756  if (c.status !== 'running') return 'running'
757  if (c.steps === 0) return 'spawning'
758  return c.tools.at(-1)?.isError ? 'error' : 'running'
759}
760
761export const laneOf = (c: AgentCard): string => {
762  if (c.type !== '' && c.type !== 'teammate') return c.type
763  return c.description.trim().split(/\s+/)[0] || 'other'
764}
765
766const addTally = (t: AgentTally, s: AgentState): AgentTally => ({ ...t, [s]: t[s] + 1 })
767
768export const tallyByType = (cards: AgentCard[], folded: Record<string, AgentTally>): Record<string, AgentTally> =>
769  cards.reduce<Record<string, AgentTally>>((out, c) => {
770    const lane = laneOf(c)
771    return { ...out, [lane]: addTally(out[lane] ?? ZERO_TALLY, stateOf(c)) }
772  }, folded)
773
774export const totals = (byType: Record<string, AgentTally>): AgentTally =>
775  Object.values(byType).reduce(
776    (a, t) => ({ spawning: a.spawning + t.spawning, running: a.running + t.running, done: a.done + t.done, error: a.error + t.error }),
777    ZERO_TALLY,
778  )
779
780export const admitCards = (
781  cards: AgentCard[],
782  folded: Record<string, AgentTally>,
783  cap: number,
784): { cards: AgentCard[]; folded: Record<string, AgentTally> } => {
785  const extra = cards.length - cap
786  if (extra <= 0) return { cards, folded }
787  const dropped = new Set(
788    cards
789      .filter(c => {
790        const s = stateOf(c)
791        return s === 'done' || s === 'error'
792      })
793      .sort((a, b) => a.spawnedAt - b.spawnedAt)
794      .slice(0, extra),
795  )
796  if (dropped.size === 0) return { cards, folded }
797  return { cards: cards.filter(c => !dropped.has(c)), folded: tallyByType([...dropped], folded) }
798}
799
800export const gridOrder = (cards: AgentCard[]): AgentCard[] => {
801  const count = new Map<string, number>()
802  for (const c of cards) count.set(laneOf(c), (count.get(laneOf(c)) ?? 0) + 1)
803  return [...cards].sort((a, b) => {
804    const la = laneOf(a)
805    const lb = laneOf(b)
806    return (count.get(lb) ?? 0) - (count.get(la) ?? 0) || (la < lb ? -1 : la > lb ? 1 : 0) || a.spawnedAt - b.spawnedAt
807  })
808}
809
810export const isSampling = (p: Prompt, cards: AgentCard[]): boolean =>
811  p.status === 'running' ||
812  cards.some(c => {
813    const s = stateOf(c)
814    return s === 'spawning' || s === 'running'
815  })
816
hooks/draw.tsx 816 lines
1import type { Elements, RenderElement, RenderSurface } from 'claude-code'
2
3import type { AgentCard, Bucket, Check, Gate, Layout, LogLine, Loop, Main, Receipt, Turn, Usage, View } from '../types'
4import { bandPanels, bandTitles, chartColors, titleColumns, windowMinutes, type BandPanel, type ChartInput } from './charts'
5import {
6  LOG_WHO_MAX,
7  GATE_LABEL,
8  HOT_FILE_WARN,
9  SVG_COLORS,
10  badDots,
11  cacheHit,
12  cardTitle,
13  fitLegend,
14  fitTitle,
15  fmtClock,
16  fmtDuration,
17  fmtTimer,
18  fmtUsd,
19  gateSummary,
20  gauge,
21  gridOrder,
22  hotFile,
23  isLoopActive,
24  kTokens,
25  laneOf,
26  lanes,
27  limitLabel,
28  logRows,
29  meanMs,
30  plural,
31  prettyModel,
32  shorten,
33  stateOf,
34  tallyByType,
35  titleLines,
36  toolName,
37  topTools,
38  totals,
39} from './core'
40import type { Colors, Config, Deck, Panel } from './core'
41import { encodeCells, type Palette as RasterPalette } from './raster'
42import { CELL_PX, GAUGE_HEIGHT, gaugeSvg, gaugeWidth, gridSvg, hex } from './svg'
43
44export type PaneView = { surface: RenderSurface; bodyColumns: number; bodyRows: number; placement: 'dock' | 'inline'; viewedAgentId: string | null }
45export type DrawData = { m: Main; u: Usage; g: Gate; cards: AgentCard[]; lp: Loop[]; lines: LogLine[]; t: Turn; r: Receipt | null; v: View; now: number; deck: Deck }
46export type Actions = {
47  toggleGate: (b: Bucket) => Promise<void>
48  toggleExpanded: (id: string) => Promise<void>
49  select: (i: number) => Promise<void>
50  move: (delta: 1 | -1) => Promise<void>
51  clear: () => Promise<void>
52}
53
54/** The terminal draws the band as Raster, the desktop as Svg; inline above the prompt the pane has no rows to spare. */
55export const bandRows = (p: { surface: RenderSurface; placement: 'dock' | 'inline'; layout: Layout; columns: number; rows: number; charts: boolean }): 0 | 4 | 6 =>
56  (p.surface !== 'terminal' && p.surface !== 'desktop') || p.placement !== 'dock' || p.layout === 'mini' || p.columns < 60 || p.rows < 15 || !p.charts ? 0 : p.rows >= 30 ? 6 : 4
57
58const lastSegments = (path: string) => path.split('/').filter(Boolean).slice(-2).join('/')
59const sumTotals = (t: { rule: number; ask: number; cleared: number; deny: number }) => t.rule + t.ask + t.cleared + t.deny
60const chartHex = (colors: RasterPalette, ch: string) => hex(colors[ch] ?? 0)
61
62export const draw = (pv: PaneView, els: Elements[RenderSurface], d: DrawData, cfg: Config, C: Colors, act: Actions): RenderElement => {
63  const { Box, Text, Button } = els
64  const hasClient = 'Client' in els
65  const { m, u, g, cards, lp, lines, t, r, v, now, deck } = d
66  const W = Math.max(40, pv.bodyColumns)
67  const layout = v.layout ?? cfg.layout
68  const isWide = layout === 'wide' || (layout === 'auto' && W >= 110)
69  const colW = isWide ? Math.floor((W - 2) / 2) : W
70  const modelName = prettyModel(m.model)
71  const viewed = pv.viewedAgentId
72  const running = cards.filter(c => c.status === 'running')
73  const motion = cfg.motion && hasClient
74  const s = gateSummary(g)
75  const order = gridOrder(cards)
76  const tally = totals(tallyByType(cards, deck.agents.byType))
77  const hot = hotFile(deck.hotFiles)
78  const top = topTools(deck.tools)
79  const hasCache = deck.cache.read + deck.cache.input + deck.cache.creation > 0
80  const cachePct = Math.round(cacheHit(deck.cache) * 100)
81  const promptStatus =
82    s.ask > 0
83      ? `waiting on you: ${plural(s.ask, 'permission ask')}`
84      : deck.prompt.status === 'idle'
85        ? 'waiting on you'
86        : deck.prompt.status
87  const testsWord = deck.tests.light === 'pass' ? `PASS ${deck.tests.pass}` : deck.tests.light === 'fail' ? `FAIL ${deck.tests.fail}` : null
88  const Raster = 'Raster' in els ? els.Raster : null
89  const Svg = pv.surface !== 'terminal' && 'Svg' in els ? els.Svg : null
90  // The desktop paints Text in a proportional font over a grid measured in code-font cells (ADR 0005).
91  const isDesktop = pv.surface === 'desktop'
92  const DesktopSvg = isDesktop ? Svg : null
93  const chartPalette = chartColors(cfg.palette)
94  const nBand = Raster || DesktopSvg ? bandRows({ surface: pv.surface, placement: pv.placement, layout, columns: pv.bodyColumns, rows: pv.bodyRows, charts: cfg.charts }) : 0
95  const chartInput: ChartInput = { context: deck.context.history, history: deck.agents.history, cards, cursor: v.cursor, now, minutes: cfg.historyMinutes }
96  const band = nBand > 0 ? bandPanels(chartInput, pv.bodyColumns, nBand * 2) : null
97  const bandCost = band ? nBand + 3 : 0
98  // A panel with nothing to show yet takes no room: most sessions never spawn an agent.
99  const isEmpty: Record<Panel, boolean> = {
100    main: false,
101    prompt: deck.prompt.status === 'idle' && deck.prompt.text === '',
102    gate: g.recent.length === 0 && s.total === 0,
103    agents: cards.length === 0,
104    tools: top.length === 0,
105    turn: !testsWord && !hot && deck.aborts.count === 0 && s.total === 0,
106    loops: lp.length === 0,
107    receipt: !m.isRunning && !r,
108    log: false,
109  }
110  const panels = cfg.panels.filter(q => !isEmpty[q])
111  const decider = m.mode === 'auto' ? 'classifier' : 'you'
112
113  // A connector between panels: animated while its flow is live, a dim line otherwise.
114  const rail = (key: string, active: boolean, color: string, width: number, marks: number[] = [], isMerge = false) =>
115    motion ? (
116      <els.Client
117        key={key}
118        module="./rail.tsx"
119        width={width}
120        height={1}
121        props={{ active, width, color, dim: C.faint, marks, isMerge }}
122      />
123    ) : (
124      <Text color={C.faint}>{'─'.repeat(Math.max(1, width))}</Text>
125    )
126
127  // A start time of 0 is unknown (state saved before it was recorded): no clock, not decades.
128  const clock = (key: string, since: number, endAt: number | null, color: string) =>
129    since <= 0 ? (
130      <Text color={color}>—</Text>
131    ) : hasClient ? (
132      <els.Client key={key} module="./elapsed.tsx" props={{ since, now, endAt, color }} />
133    ) : (
134      <Text color={color}>{fmtTimer((endAt ?? now) - since)}</Text>
135    )
136
137  // ---- main
138  const effortN = { low: 1, medium: 2, high: 3, xhigh: 4, max: 4 }[m.effort] ?? 0
139  const effortGauge = { on: '▮'.repeat(effortN), off: '▯'.repeat(4 - effortN) }
140  const ctxGauge = u.pct !== null ? gauge(u.pct, 10) : null
141  const gaugeSvgOf = (Svg: NonNullable<typeof DesktopSvg>, g: { on: string; off: string }, isWarn: boolean, alt: string) => (
142    <Svg source={gaugeSvg(g, chartHex(chartPalette, isWarn ? 'e' : 'c'), chartHex(chartPalette, 's'))} alt={alt} width={gaugeWidth(g)} height={GAUGE_HEIGHT} />
143  )
144  // Each phrase is its own Text in a wrapping row, so a row too wide for the proportional font breaks between phrases.
145  const desktopMainRows = (Svg: NonNullable<typeof DesktopSvg>) => [
146    <Box flexWrap="wrap" columnGap={2}>
147      <Box columnGap={1} alignItems="center">
148        <Text dimColor>effort</Text>
149        {gaugeSvgOf(Svg, effortGauge, false, `effort ${m.effort || '—'}`)}
150        <Text color={C.main} bold>
151          {m.effort || '—'}
152        </Text>
153      </Box>
154      {m.mode ? <Text dimColor>{`mode ${m.mode}`}</Text> : null}
155      <Text dimColor>{`${m.steps} req`}</Text>
156    </Box>,
157    ctxGauge ? (
158      <Box flexWrap="wrap" columnGap={2}>
159        <Box columnGap={1} alignItems="center">
160          <Text dimColor>ctx</Text>
161          {gaugeSvgOf(Svg, ctxGauge, (u.pct ?? 0) >= 80, `context ${Math.round(u.pct ?? 0)}%`)}
162          <Text bold>{`${Math.round(u.pct ?? 0)}%`}</Text>
163        </Box>
164        {u.tokens !== null ? <Text dimColor>{`${kTokens(u.tokens)}/${kTokens(u.window)}`}</Text> : null}
165        {u.compactions > 0 ? <Text color={C.amber}>{`⟲${u.compactions}`}</Text> : null}
166        {hasCache ? <Text dimColor>{`cache hit ${cachePct}%`}</Text> : null}
167      </Box>
168    ) : null,
169    u.costUsd !== null || u.limits.length > 0 ? (
170      <Box flexWrap="wrap" columnGap={2}>
171        {u.costUsd !== null ? <Text color={C.text}>{fmtUsd(u.costUsd)}</Text> : null}
172        {u.limits.slice(0, 2).map(l => (
173          <Box columnGap={1} alignItems="center">
174            <Text dimColor>{limitLabel(l.kind)}</Text>
175            {gaugeSvgOf(Svg, gauge(l.pct, 5), l.pct >= 80, `${limitLabel(l.kind)} ${Math.round(l.pct)}%`)}
176            <Text dimColor>{`${Math.round(l.pct)}%`}</Text>
177          </Box>
178        ))}
179      </Box>
180    ) : null,
181  ]
182  const terminalMainRows = () => [
183    <Text wrap="truncate">
184      <Text dimColor>effort </Text>
185      <Text color={C.main}>{effortGauge.on + effortGauge.off} </Text>
186      <Text color={C.main} bold>
187        {m.effort || '—'}
188      </Text>
189      {m.mode ? <Text dimColor>{`   mode ${m.mode}`}</Text> : null}
190      <Text dimColor>{`   ${m.steps} req`}</Text>
191    </Text>,
192    ctxGauge ? (
193      <Text wrap="truncate">
194        <Text dimColor>ctx </Text>
195        <Text color={u.pct !== null && u.pct >= 80 ? C.warn : C.main}>{ctxGauge.on}</Text>
196        <Text color={C.faint}>{ctxGauge.off}</Text>
197        <Text bold>{` ${Math.round(u.pct ?? 0)}%`}</Text>
198        {u.tokens !== null ? <Text dimColor>{` ${kTokens(u.tokens)}/${kTokens(u.window)}`}</Text> : null}
199        {u.compactions > 0 ? <Text color={C.amber}>{`  ⟲${u.compactions}`}</Text> : null}
200        {hasCache ? <Text dimColor>{`  cache hit ${cachePct}%`}</Text> : null}
201      </Text>
202    ) : null,
203    u.costUsd !== null || u.limits.length > 0 ? (
204      <Text wrap="truncate">
205        {u.costUsd !== null ? <Text color={C.text}>{`${fmtUsd(u.costUsd)}   `}</Text> : null}
206        {u.limits.slice(0, 2).map(l => {
207          const lg = gauge(l.pct, 5)
208          return (
209            <Text wrap="truncate">
210              <Text dimColor>{`${limitLabel(l.kind)} `}</Text>
211              <Text color={l.pct >= 80 ? C.warn : C.main}>{lg.on}</Text>
212              <Text color={C.faint}>{lg.off}</Text>
213              <Text dimColor>{` ${Math.round(l.pct)}%  `}</Text>
214            </Text>
215          )
216        })}
217      </Text>
218    ) : null,
219  ]
220  const mainPanel = (w: number) => (
221    <Box flexDirection="column" borderStyle="round" borderColor={C.main} paddingX={1} width={w}>
222      <Box justifyContent="space-between">
223        <Text color={C.main} bold>
224          {modelName} · main
225        </Text>
226        <Text color={m.isRunning ? C.main : C.dim}>{m.isRunning ? '● working' : '○ idle'}</Text>
227      </Box>
228      {DesktopSvg ? desktopMainRows(DesktopSvg) : terminalMainRows()}
229    </Box>
230  )
231
232  // ---- prompt: the turn in flight, and whether it waits on you
233  const promptStart = deck.prompt.startedAt ?? 0
234  // Between an abort and the main turn.complete, the receipt still belongs to the turn before.
235  const isOwnReceipt = r !== null && promptStart > 0 && (r.startedAt ?? 0) >= promptStart
236  const promptEnd = deck.prompt.status === 'running' ? null : isOwnReceipt ? promptStart + r.durationMs : now
237  // The stored prompt is already short (48 characters), so the desktop wraps it whole instead of cutting it to the row.
238  const promptPanel = (w: number) =>
239    isDesktop ? (
240      <Box width={w} flexWrap="wrap" columnGap={1}>
241        <Text bold>PROMPT</Text>
242        <Text wrap="wrap">{`"${deck.prompt.text}"`}</Text>
243        <Box flexShrink={0}>{clock('prompt-clock', promptStart, promptEnd, C.dim)}</Box>
244        <Text color={s.ask > 0 ? C.amber : C.dim} wrap="wrap">{`· ${promptStatus}`}</Text>
245      </Box>
246    ) : (
247      <Box width={w}>
248        <Text wrap="truncate">
249          <Text bold>PROMPT  </Text>
250          <Text>{`"${shorten(deck.prompt.text, Math.max(8, w - promptStatus.length - 22))}" `}</Text>
251        </Text>
252        <Box flexShrink={0}>{clock('prompt-clock', promptStart, promptEnd, C.dim)}</Box>
253        <Text color={s.ask > 0 ? C.amber : C.dim} wrap="truncate">{` · ${promptStatus}`}</Text>
254      </Box>
255    )
256
257  // ---- gate
258  const verdictColor = (c: Check) =>
259    c.verdict === 'rule' ? C.gate : c.verdict === 'cleared' ? C.cleared : c.verdict === 'ask' ? C.amber : C.warn
260  const gatePanel = (w: number) => {
261    const strip = g.recent.slice(-Math.max(8, w - 4))
262    const open = v.gateOpen
263    return (
264      <Box flexDirection="column" borderStyle="round" borderColor={C.gate} paddingX={1} width={w}>
265        <Box justifyContent="space-between">
266          <Text color={C.gate} bold>
267            {GATE_LABEL} · permissions
268          </Text>
269          <Text dimColor>{`${s.total} checks`}</Text>
270        </Box>
271        <Box>
272          {strip.length === 0 ? <Text color={C.faint}>no checks yet</Text> : null}
273          {strip.map(c => (
274            <Text color={verdictColor(c)} dimColor={c.inSubagent}>
275              {c.verdict === 'deny' ? '✗' : '■'}
276            </Text>
277          ))}
278        </Box>
279        <Text wrap="truncate">
280          <Text color={C.gate}>■</Text>
281          <Text dimColor>{` ${s.rule} allowed  `}</Text>
282          <Text color={C.cleared}>■</Text>
283          <Text dimColor>{` ${s.cleared} ${decider}  `}</Text>
284          {s.ask > 0 ? <Text color={C.amber}>{`■ ${s.ask} pending  `}</Text> : null}
285          <Text color={s.deny > 0 ? C.warn : C.dim}>{`✗ ${s.deny} denied`}</Text>
286          {w >= 80 && g.recent.some(c => c.inSubagent) ? <Text color={C.faint}>{'  dim: in subagents'}</Text> : null}
287        </Text>
288        <Box columnGap={2}>
289          {(['file', 'shell', 'other'] as const).map((b: Bucket) => {
290            const tl = g.totals[b]
291            const n = tl.rule + tl.ask + tl.cleared + tl.deny
292            return (
293              <Button
294                key={`gate-${b}`}
295                plain
296                hotkey={b[0]}
297                label={`${b} ${n}${open === b ? ' ▾' : ''}`}
298                dimColor={n === 0}
299                onPress={() => act.toggleGate(b)}
300              />
301            )
302          })}
303        </Box>
304        {open
305          ? g.recent
306              .filter(c => c.bucket === open)
307              .slice(-5)
308              .map(c => (
309                <Text wrap="truncate">
310                  <Text color={verdictColor(c)}>{c.verdict === 'deny' ? '✗ ' : '■ '}</Text>
311                  <Text color={C.dim}>{`${(c.verdict === 'rule' ? 'allowed' : c.verdict === 'cleared' ? decider : c.verdict === 'ask' ? 'pending' : 'denied').padEnd(10)} `}</Text>
312                  <Text dimColor={c.inSubagent}>{shorten(c.detail, Math.max(10, w - 18))}</Text>
313                </Text>
314              ))
315          : null}
316      </Box>
317    )
318  }
319
320  // ---- agents: cards up to the limit, swimlanes beyond it
321  const statusColor = (c: AgentCard) => (c.status === 'failed' ? C.warn : c.status === 'done' ? C.gate : C.agent)
322  const glyph = (c: AgentCard) => (c.status === 'running' ? '◐' : c.status === 'done' ? '✓' : c.status === 'failed' ? '✗' : '■')
323  const expandOnPress = (id: string) => () => act.toggleExpanded(id)
324
325  // Cards need 20 columns each; more agents than fit side by side switch to lanes.
326  const fitCards = (w: number) => Math.max(1, Math.floor((w + 1) / 21))
327
328  // Nine numbered entries share one row; below 12 columns each, a label is its number and a state glyph.
329  const STATE_GLYPH = { spawning: '·', running: '●', done: '✓', error: '✗' } as const
330  const stateColor = { spawning: C.dim, running: C.agent, done: C.gate, error: C.warn } as const
331  const numbered = (w: number) => {
332    const first = order.slice(0, 9)
333    const per = Math.floor((w - (first.length - 1)) / first.length)
334    return (
335      <Box columnGap={1} width={w} overflow="hidden">
336        {first.map((c, i) => {
337          const state = stateOf(c)
338          const dim = v.cursor !== null && v.cursor !== i
339          const onPress = () => act.select(i)
340          return per >= 12 ? (
341            <Button key={`agent-${i}`} plain hotkey={String(i + 1)} label={shorten(`${i + 1} ${laneOf(c)} ${kTokens(c.ctx)} ${state}`, per)} dimColor={dim} onPress={onPress} />
342          ) : (
343            <Box>
344              <Button key={`agent-${i}`} plain hotkey={String(i + 1)} label={String(i + 1)} dimColor={dim} onPress={onPress} />
345              <Text color={stateColor[state]}>{STATE_GLYPH[state]}</Text>
346            </Box>
347          )
348        })}
349      </Box>
350    )
351  }
352  const cursorCard = v.cursor !== null ? order[v.cursor] : undefined
353  const hasNav = cards.length > 9
354  const hasClear = v.cursor !== null || v.expanded !== null
355  const dropped = band?.dropped ?? 0
356  const hasCursorRow = cards.length > 0 && (hasNav || hasClear || cursorCard !== undefined)
357  const cursorRow = (w: number) => (
358    <Box columnGap={2} width={w}>
359      {hasNav ? <Button key="cursor-next" plain hotkey="n" label="n next" onPress={() => act.move(1)} /> : null}
360      {hasNav ? <Button key="cursor-back" plain hotkey="b" label="b back" onPress={() => act.move(-1)} /> : null}
361      {hasClear ? <Button key="cursor-clear" plain hotkey="x" label="x clear" onPress={() => act.clear()} /> : null}
362      {cursorCard ? (
363        <Text color={C.amber} wrap="truncate">
364          {`cursor ▸ #${cursorCard.id.slice(0, 6)} ${cursorCard.type} ${kTokens(cursorCard.ctx)} ${toolName(cursorCard.tools.at(-1)?.tool ?? '')}`.trimEnd()}
365        </Text>
366      ) : null}
367    </Box>
368  )
369
370  const agentsPanel = (w: number) => {
371    const fit = fitCards(w)
372    const useLanes = cards.length > fit
373    const header = (
374      <Box flexDirection="column" width={w}>
375        <Box justifyContent="space-between" width={w}>
376          <Text bold>{`agents · ${tally.spawning + tally.running} running · ${tally.spawning + tally.running + tally.done + tally.error} total`}</Text>
377          {cards.length > 0 ? <Text color={C.faint}>1-{Math.min(cards.length, 9)} select</Text> : null}
378        </Box>
379        {cards.length > 0 ? numbered(w) : null}
380        {hasCursorRow ? cursorRow(w) : null}
381      </Box>
382    )
383    if (cards.length === 0) {
384      return (
385        <Box flexDirection="column" width={w}>
386          {header}
387          <Text color={C.faint}>no subagents yet</Text>
388        </Box>
389      )
390    }
391    if (useLanes) {
392      const shown = cards.slice(-6)
393      const barW = Math.max(8, w - 28)
394      const geo = lanes(shown, now, barW)
395      return (
396        <Box flexDirection="column" width={w}>
397          {header}
398          {cards.length > shown.length ? <Text color={C.faint}>{`+${cards.length - shown.length} earlier`}</Text> : null}
399          {shown.map((c, i) => {
400            const gm = geo[i]
401            const isViewed = viewed === c.id
402            return (
403              <Box>
404                <Text color={statusColor(c)} bold={isViewed}>{`${isViewed ? '▶' : glyph(c)} `}</Text>
405                <Box width={17}>
406                  <Button key={`card-${c.id}`} plain label={shorten(cardTitle(c), 14)} onPress={expandOnPress(c.id)} />
407                </Box>
408                <Text color={C.faint}>{' ' + '·'.repeat(gm?.before ?? 0)}</Text>
409                <Text color={statusColor(c)}>{'━'.repeat(gm?.bar ?? 1)}</Text>
410                <Text color={C.faint}>{'·'.repeat(gm?.after ?? 0) + ' '}</Text>
411                {clock(`lane-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}
412              </Box>
413            )
414          })}
415        </Box>
416      )
417    }
418    const shown = cards.slice(-fit)
419    const cardW = Math.max(20, Math.floor((w - (shown.length - 1)) / shown.length))
420    const centers = shown.map((_, i) => i * (cardW + 1) + Math.floor(cardW / 2))
421    return (
422      <Box flexDirection="column" width={w}>
423        {header}
424        {rail('fan-out', running.length > 0, C.agent, w, centers)}
425        <Box columnGap={1}>
426          {shown.map((c, i) => {
427            const isViewed = viewed === c.id
428            const sameModel = !c.model || prettyModel(c.model) === modelName
429            return (
430              <Box
431                flexDirection="column"
432                borderStyle={isViewed ? 'double' : 'round'}
433                borderColor={c.lastStop === 'max_tokens' ? C.warn : C.agent}
434                borderDimColor={c.status !== 'running' && !isViewed}
435                width={cardW}
436                paddingX={1}
437              >
438                <Button key={`card-${c.id}`} plain label={titleLines(cardTitle(c), cardW - 7, cardW - 4)[0]} onPress={expandOnPress(c.id)} />
439                <Text bold wrap="truncate">
440                  {titleLines(cardTitle(c), cardW - 7, cardW - 4)[1]}
441                </Text>
442                <Text color={C.dim} wrap="truncate">
443                  {sameModel ? c.type : `${c.type} · ${prettyModel(c.model)}`}
444                </Text>
445                <Text dimColor wrap="truncate">
446                  {c.steps > 0 ? `ctx ${kTokens(c.ctx)} · out ${kTokens(c.out)} · ${c.steps} st` : 'starting…'}
447                </Text>
448                <Box>
449                  <Text color={c.lastStop === 'max_tokens' ? C.warn : statusColor(c)}>
450                    {cardW >= 26 ? `${glyph(c)} ${c.lastStop === 'max_tokens' ? 'max_tokens' : c.status} ` : `${glyph(c)} `}
451                  </Text>
452                  <Box flexShrink={0}>{clock(`card-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}</Box>
453                </Box>
454              </Box>
455            )
456          })}
457        </Box>
458        {rail('merge', running.length > 0, C.agent, w, centers, true)}
459      </Box>
460    )
461  }
462
463  const expandedCard = cards.find(c => c.id === v.expanded)
464  const expandedPanel = (w: number) =>
465    expandedCard ? (
466      <Box flexDirection="column" borderStyle="single" borderColor={C.agent} paddingX={1} width={w}>
467        <Text bold wrap="wrap">
468          {expandedCard.description || expandedCard.type}
469        </Text>
470        <Text dimColor wrap="truncate">{`${expandedCard.type} · ${prettyModel(expandedCard.model)} · ${expandedCard.status} · ${expandedCard.steps} steps`}</Text>
471        {expandedCard.tools.length === 0 ? <Text color={C.faint}>no tool calls yet</Text> : null}
472        {expandedCard.tools.map(n => (
473          <Text color={n.isError ? C.warn : C.text} wrap="truncate">
474            {`${n.isError ? '✗' : '·'} ${n.text}`}
475          </Text>
476        ))}
477        {expandedCard.answer ? (
478          <Text dimColor wrap="wrap">
479            {`» ${shorten(expandedCard.answer, 240)}`}
480          </Text>
481        ) : null}
482      </Box>
483    ) : null
484
485  // ---- other loops (workflow agents, forks): ids that match no card
486  const loopsPanel = (w: number) => {
487    if (lp.length === 0) return null
488    const active = lp.filter(l => isLoopActive(l, now)).length
489    const dots = lp.slice(-Math.max(4, w - 38))
490    return (
491      <Box width={w}>
492        <Text bold>other loops </Text>
493        <Text dimColor>{`${lp.length} seen · ${active} active  `}</Text>
494        {dots.map(l => (
495          <Text color={isLoopActive(l, now) ? C.agent : l.isDone ? C.dim : C.faint}>{isLoopActive(l, now) ? '●' : l.isDone ? '✓' : '○'}</Text>
496        ))}
497      </Box>
498    )
499  }
500
501  // ---- receipt: the turn now, or the last one
502  const receiptPanel = (w: number) => {
503    return (
504      <Box flexDirection="column" borderStyle="round" borderColor={C.main} borderDimColor={!m.isRunning} paddingX={1} width={w}>
505        {m.isRunning ? (
506          <Box>
507            <Text color={C.main} wrap="truncate">{`◐ back to ${modelName.toLowerCase()} · turn `}</Text>
508            <Box flexShrink={0}>{clock('turn-clock', t.startedAt, null, C.main)}</Box>
509            {w >= 60 ? <Text dimColor wrap="truncate">{` · ${plural(t.edits, 'edit')} · ${plural(t.errors, 'error')}`}</Text> : null}
510          </Box>
511        ) : r ? (
512          <Text wrap="truncate">
513            <Text color={r.reason === 'answer' ? C.gate : C.warn}>{r.reason === 'answer' ? '✓ ' : '✗ '}</Text>
514            <Text>{`last turn ${fmtDuration(r.durationMs)} · ${plural(r.agents, 'agent')} · ${plural(r.edits, 'edit')} · ${plural(r.errors, 'error')}`}</Text>
515            {r.costDelta !== null ? <Text color={C.main}>{` · +${fmtUsd(r.costDelta)}`}</Text> : null}
516          </Text>
517        ) : (
518          <Text color={C.faint}>no turn finished yet</Text>
519        )}
520      </Box>
521    )
522  }
523
524  // ---- tools: the busiest tools, their mean latency and recent failures
525  const toolUnits = top.map(([name, st]) => {
526    const mean = meanMs(st)
527    const dots = badDots(st)
528    return `${toolName(name)} ${mean === null ? '—' : `${(mean / 1000).toFixed(1)}s`}${dots > 0 ? ` ${'•'.repeat(dots)}` : ''}`
529  })
530  const toolsPanel = (w: number) =>
531    isDesktop ? (
532      <Box width={w} flexWrap="wrap" columnGap={2}>
533        <Text>TOOLS</Text>
534        {toolUnits.map(t => (
535          <Text>{t}</Text>
536        ))}
537      </Box>
538    ) : (
539      <Box width={w}>
540        <Text wrap="truncate">{`TOOLS  ${toolUnits.join('  ')}`}</Text>
541      </Box>
542    )
543
544  // ---- turn: tests, the file under churn, aborts, and the gate's tallies
545  const testsColor = deck.tests.light === 'pass' ? C.gate : C.warn
546  const hotColor = hot && hot.edits >= HOT_FILE_WARN ? C.amber : C.text
547  const hotText = hot ? `hot file ${lastSegments(hot.path)} ×${hot.edits}${hot.edits >= HOT_FILE_WARN ? ' ⚠' : ''}` : null
548  const gateText = `gate file ${sumTotals(g.totals.file)} · shell ${sumTotals(g.totals.shell)} · ask ${s.ask}`
549  const turnPanel = (w: number) =>
550    isDesktop ? (
551      <Box width={w} flexWrap="wrap" columnGap={3}>
552        <Text bold>TURN</Text>
553        {testsWord ? <Text color={testsColor}>{`tests ■ ${testsWord}`}</Text> : <Text dimColor>tests —</Text>}
554        {hotText ? <Text color={hotColor}>{hotText}</Text> : null}
555        <Text dimColor>{`aborts ${deck.aborts.count}`}</Text>
556        <Text dimColor>{gateText}</Text>
557      </Box>
558    ) : (
559      <Box width={w}>
560        <Text wrap="truncate">
561          <Text bold>TURN  </Text>
562          {testsWord ? <Text color={testsColor}>{`tests ■ ${testsWord}`}</Text> : <Text dimColor>tests —</Text>}
563          {hotText ? <Text color={hotColor}>{`   ${hotText}`}</Text> : null}
564          <Text dimColor>{`   aborts ${deck.aborts.count}`}</Text>
565          <Text dimColor>{`   ${gateText}`}</Text>
566        </Text>
567      </Box>
568    )
569
570  // ---- log: whatever rows the other panels leave, 4 to 8
571  const oneLiners = panels.filter(q => q === 'prompt' || q === 'tools' || q === 'turn').length
572  const agentRows = cards.length > 0 ? 1 + (hasCursorRow ? 1 : 0) : 0
573  const used =
574    2 + 5 + 6 + (v.gateOpen ? 5 : 0) + (cards.length > fitCards(colW) ? 3 + Math.min(6, cards.length) : 8) + (expandedCard ? 8 : 0) + (lp.length ? 1 : 0) + 3 + bandCost + oneLiners + agentRows
575  const nLog = logRows(pv.bodyRows, used)
576  const shownLines = (viewed ? lines.filter(l => l.agentId === viewed) : lines).slice(-nLog)
577  const colorOf = (l: LogLine) =>
578    l.kind === 'error' ? C.warn : l.who === 'main' ? C.main : l.who === 'gate' ? C.gate : l.who === 'you' ? C.text : C.agent
579  // The name column fits the longest name on screen, capped so the message keeps room, with a two-space gap after it.
580  const whoW = Math.min(LOG_WHO_MAX, Math.max(8, ...shownLines.map(l => l.who.length))) + 2
581  const logPanel = (w: number) => (
582    <Box flexDirection="column" borderStyle="round" borderColor={C.faint} paddingX={1} width={w}>
583      <Text dimColor>{viewed ? 'session log · this agent' : 'session log'}</Text>
584      {shownLines.length === 0 ? <Text color={C.faint}>nothing yet</Text> : null}
585      {shownLines.map(l => (
586        <Box>
587          <Box width={10} flexShrink={0}>
588            <Text color={C.faint}>{fmtClock(l.at)}</Text>
589          </Box>
590          <Box width={whoW} paddingRight={2} flexShrink={0}>
591            <Text color={colorOf(l)} bold wrap="truncate">
592              {l.who}
593            </Text>
594          </Box>
595          <Text color={l.kind === 'error' ? C.warn : C.text} wrap={isDesktop ? 'wrap' : 'truncate'}>
596            {l.text}
597          </Text>
598        </Box>
599      ))}
600    </Box>
601  )
602
603  const panelOf: Record<Panel, (w: number) => RenderElement | null> = {
604    main: mainPanel,
605    prompt: promptPanel,
606    gate: gatePanel,
607    agents: agentsPanel,
608    tools: toolsPanel,
609    turn: turnPanel,
610    loops: loopsPanel,
611    receipt: receiptPanel,
612    log: logPanel,
613  }
614
615  // One-line panels are transparent to the flow: a connector links the bordered panels around them.
616  const isOneLine = (q: Panel) => q === 'prompt' || q === 'tools' || q === 'turn'
617  const isUnlinked = (q: Panel | undefined) => q === undefined || q === 'agents' || q === 'loops'
618  const column = (ps: Panel[], w: number) => (
619    <Box flexDirection="column" width={w}>
620      {ps.map((q, i) => {
621        const prev = ps.slice(0, i).filter(x => !isOneLine(x)).at(-1)
622        const link = q === 'log' || isOneLine(q) || isUnlinked(q) || isUnlinked(prev) ? null : rail(`link-${q}`, m.isRunning, C.main, w)
623        return (
624          <Box flexDirection="column">
625            {link}
626            {panelOf[q](w)}
627            {q === 'agents' ? expandedPanel(w) : null}
628          </Box>
629        )
630      })}
631    </Box>
632  )
633
634  // Desktop draws the agents' time axis as SVG under the panels.
635  const svgLanes =
636    Svg && cards.length > 0
637      ? (() => {
638          const rowH = 18
639          const pxW = 520
640          const geo = lanes(cards.slice(-10), now, 100)
641          const rects = cards
642            .slice(-10)
643            .map((c, i) => {
644              const gm = geo[i]
645              const x = 150 + ((gm?.before ?? 0) / 100) * (pxW - 160)
646              const wpx = Math.max(3, ((gm?.bar ?? 1) / 100) * (pxW - 160))
647              const fill = c.status === 'running' ? SVG_COLORS.running : c.status === 'done' ? SVG_COLORS.done : c.status === 'failed' ? SVG_COLORS.failed : SVG_COLORS.other
648              const label = shorten(cardTitle(c), 22).replace(/[<&>]/g, '')
649              return `<text x="4" y="${i * rowH + 13}" font-size="11" fill="${SVG_COLORS.label}">${label}</text><rect x="${x}" y="${i * rowH + 4}" width="${wpx}" height="10" rx="3" fill="${fill}"/>`
650            })
651            .join('')
652          const svgH = Math.min(10, cards.length) * rowH + 4
653          return (
654            <Svg
655              source={`<svg xmlns="http://www.w3.org/2000/svg" width="${pxW}" height="${svgH}" viewBox="0 0 ${pxW} ${svgH}">${rects}</svg>`}
656              alt={`${cards.length} agents on a time axis`}
657              width={pxW}
658              height={svgH}
659            />
660          )
661        })()
662      : null
663
664  // Inline above the prompt (the terminal's main screen), the pane is a summary of at most 8 rows.
665  const isMini = layout === 'mini' || (layout === 'auto' && pv.placement === 'inline')
666  if (isMini) {
667    const live = [...cards.filter(c => c.status === 'running'), ...cards.filter(c => c.status !== 'running').reverse()].slice(0, 3)
668    const counts = ` ${s.rule} allowed · ${s.cleared} ${decider}${s.ask > 0 ? ` · ${s.ask} pending` : ''} · ${s.deny} denied`
669    const strip = g.recent.slice(-Math.max(4, W - GATE_LABEL.length - 1 - counts.length))
670    const mg = u.pct !== null ? gauge(u.pct, 6) : null
671    const hasSignals = deck.prompt.status !== 'idle' || testsWord !== null || deck.aborts.count > 0 || hot !== null
672    return (
673      <Box flexDirection="column" width={W}>
674        <Text wrap="truncate">
675          <Text color={C.main} bold>
676            {modelName}
677          </Text>
678          <Text color={m.isRunning ? C.main : C.dim}>{m.isRunning ? ' ● working' : ' ○ idle'}</Text>
679          {mg ? <Text dimColor> · ctx </Text> : null}
680          {mg ? <Text color={(u.pct ?? 0) >= 80 ? C.warn : C.main}>{mg.on}</Text> : null}
681          {mg ? <Text color={C.faint}>{mg.off}</Text> : null}
682          {mg ? <Text>{` ${Math.round(u.pct ?? 0)}%`}</Text> : null}
683          {u.compactions > 0 ? <Text color={C.amber}>{` ⟲${u.compactions}`}</Text> : null}
684          {hasCache ? <Text dimColor>{` · cache ${cachePct}%`}</Text> : null}
685          {u.costUsd !== null ? <Text dimColor>{` · ${fmtUsd(u.costUsd)}`}</Text> : null}
686        </Text>
687        {strip.length > 0 ? (
688          <Box>
689            <Text dimColor>{`${GATE_LABEL.toLowerCase()} `}</Text>
690            {strip.map(c => (
691              <Text color={verdictColor(c)} dimColor={c.inSubagent}>
692                {c.verdict === 'deny' ? '✗' : '■'}
693              </Text>
694            ))}
695            <Text color={s.deny > 0 ? C.warn : s.ask > 0 ? C.amber : C.dim} wrap="truncate">
696              {counts}
697            </Text>
698          </Box>
699        ) : null}
700        {live.map(c => (
701          <Box>
702            <Text color={statusColor(c)}>{`${glyph(c)} `}</Text>
703            <Box width={Math.max(10, W - 30)}>
704              <Text wrap="truncate">{cardTitle(c)}</Text>
705            </Box>
706            <Text dimColor>{c.steps > 0 ? ` ctx ${kTokens(c.ctx)} ` : ' '}</Text>
707            {clock(`mini-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}
708          </Box>
709        ))}
710        {cards.length > live.length ? (
711          <Text color={C.faint} wrap="truncate">{`+${cards.length - live.length} more agents · /sessiondeck layout compact for all`}</Text>
712        ) : null}
713        {hasSignals ? (
714        <Text dimColor wrap="truncate">
715          {`"${shorten(deck.prompt.text, 24)}" ${promptStatus} · tests ${testsWord ?? '—'} · aborts ${deck.aborts.count}${hot ? ` · hot ${lastSegments(hot.path)} ×${hot.edits}` : ''}${lp.length > 0 ? ` · loops ${lp.length}` : ''}`}
716        </Text>
717        ) : null}
718        {!m.isRunning && r ? (
719          <Text dimColor wrap="truncate">
720            {`last turn ${fmtDuration(r.durationMs)} · ${plural(r.agents, 'agent')} · ${plural(r.edits, 'edit')} · ${plural(r.errors, 'error')}${r.costDelta !== null ? ` · +${fmtUsd(r.costDelta)}` : ''}`}
721          </Text>
722        ) : null}
723      </Box>
724    )
725  }
726
727  const legend = fitLegend(
728    [
729      { label: 'main', color: C.main },
730      { label: 'agents', color: C.agent },
731      { label: GATE_LABEL.toLowerCase(), color: C.gate },
732    ],
733    W,
734  )
735
736  const sinceWindow = now - windowMinutes(chartInput) * 60_000
737  const inWindow = <T extends { at: number }>(samples: T[]) => samples.filter(x => x.at >= sinceWindow)
738  const ctxPct = u.pct ?? deck.context.history.at(-1)?.percent ?? null
739  const bandTitle = bandTitles({
740    contextPct: ctxPct,
741    cachePct: hasCache ? cachePct : null,
742    compacted: inWindow(deck.context.history).some(x => x.compacted),
743    running: tally.spawning + tally.running,
744    done: tally.done,
745    error: tally.error,
746    dropped,
747    minutes: windowMinutes(chartInput),
748    peak: Math.max(0, ...inWindow(deck.agents.history).map(x => x.running + x.done + x.error)),
749  })
750  const bandPanel = (p: BandPanel) => {
751    const title = fitTitle(bandTitle[p.key], titleColumns(p))
752    return (
753      <Box flexDirection="column" borderStyle="round" borderColor={C.faint} width={p.width}>
754        <Box paddingLeft={1} width={p.width - 2}>
755          <Text wrap="truncate">
756            {title.parts.map((part, i) => (
757              <Text>
758                {i > 0 ? <Text color={C.faint}>{title.sep}</Text> : null}
759                {part.swatch ? <Text color={chartHex(chartPalette, part.swatch.ch)}>{part.swatch.glyph}</Text> : null}
760                {part.text}
761              </Text>
762            ))}
763          </Text>
764        </Box>
765        {Raster ? <Raster key={`band-${p.key}`} columns={p.width - 2} rows={nBand} cells={encodeCells(p.grid, chartPalette).cells} /> : null}
766        {DesktopSvg ? (
767          <Box key={`band-${p.key}`} width={p.width - 2}>
768            <DesktopSvg
769              source={gridSvg(p.grid, chartPalette)}
770              alt={title.parts.map(part => part.text.trim()).filter(Boolean).join(', ')}
771              width={(p.width - 2) * CELL_PX}
772              height={p.grid.length * CELL_PX}
773            />
774          </Box>
775        ) : null}
776      </Box>
777    )
778  }
779
780  const body = isWide ? (
781    <Box flexDirection="column">
782      <Box columnGap={2}>
783        {column(panels.filter(q => q === 'main' || q === 'prompt' || q === 'gate'), colW)}
784        {column(panels.filter(q => q === 'agents' || q === 'tools' || q === 'turn' || q === 'loops' || q === 'receipt'), colW)}
785      </Box>
786      {panels.includes('log') ? logPanel(W) : null}
787    </Box>
788  ) : (
789    column(panels, W)
790  )
791
792  return (
793    <Box flexDirection="column" width={W} backgroundColor={C.bg}>
794      {band ? <Box>{band.panels.map(bandPanel)}</Box> : null}
795      <Box justifyContent="center">
796        <Text bold wrap="truncate">
797          <Text>SESSIONDECK</Text>
798          <Text color={C.dim}> · </Text>
799          <Text color={C.main}>{modelName.toUpperCase()}</Text>
800          <Text>{m.isRunning ? ' WORKS' : ' IDLE'}</Text>
801        </Text>
802      </Box>
803      <Box justifyContent="center" columnGap={2}>
804        {legend.map(l => (
805          <Text>
806            <Text color={l.color}>■</Text>
807            <Text dimColor>{` ${l.label}`}</Text>
808          </Text>
809        ))}
810      </Box>
811      {body}
812      {svgLanes}
813    </Box>
814  )
815}
816
hooks/charts.ts 228 lines
1import type { AgentCard, AgentSample, ContextSample } from '../types'
2import { gridOrder, laneOf, stateOf, type Palette as ThemePalette, type TitlePart } from './core'
3import type { Palette as RasterPalette } from './raster'
4
5export type Grid = string[]
6export type ChartInput = { context: ContextSample[]; history: AgentSample[]; cards: AgentCard[]; cursor: number | null; now: number; minutes: number }
7
8export const CHART_COLORS: RasterPalette = {
9  '.': null,
10  c: 0x7dd3fc,
11  t: 0xfcd34d,
12  h: 0x3f4654,
13  s: 0x6b7280,
14  r: 0x3b82f6,
15  d: 0x16a34a,
16  e: 0xdc2626,
17  u: 0xf59e0b,
18  A: 0x93c5fd,
19  B: 0xc4b5fd,
20  C: 0x86efac,
21  D: 0xfca5a5,
22  E: 0x5eead4,
23  F: 0xe5e7eb,
24}
25
26const DRACULA_COLORS: RasterPalette = {
27  '.': null,
28  c: 0x8be9fd,
29  t: 0xf1fa8c,
30  h: 0x44475a,
31  s: 0x6272a4,
32  r: 0xbd93f9,
33  d: 0x50fa7b,
34  e: 0xff5555,
35  u: 0xffb86c,
36  A: 0x8be9fd,
37  B: 0xbd93f9,
38  C: 0x50fa7b,
39  D: 0xff79c6,
40  E: 0xf1fa8c,
41  F: 0xf8f8f2,
42}
43
44export const chartColors = (palette: ThemePalette): RasterPalette => (palette === 'dracula' ? DRACULA_COLORS : CHART_COLORS)
45
46const LANE_BARS = 'ABCDEF'
47const STATE_CHAR = { spawning: 's', running: 'r', done: 'd', error: 'e' } as const
48
49export const blank = (width: number, height: number): Grid => Array.from({ length: Math.max(0, height) }, () => '.'.repeat(Math.max(0, width)))
50
51const put = (grid: Grid, x: number, y: number, ch: string) => {
52  const row = grid[y]
53  if (row === undefined || x < 0 || x >= row.length || !Number.isInteger(x)) return
54  grid[y] = row.slice(0, x) + ch + row.slice(x + 1)
55}
56
57const fillUp = (grid: Grid, x: number, from: number, to: number, ch: string) => {
58  const height = grid.length
59  for (let p = from; p < to; p++) put(grid, x, height - 1 - p, ch)
60}
61
62// Both charts span the session so far, at least a minute and at most `minutes`, so a young
63// session fills the width and the two x axes agree.
64const windowMs = (i: ChartInput): number => {
65  const full = i.minutes * 60_000
66  const ats = [...i.context, ...i.history].map(s => s.at)
67  if (ats.length === 0) return full
68  return Math.max(60_000, Math.min(full, i.now - Math.min(...ats)))
69}
70
71export const windowMinutes = (i: ChartInput): number => Math.max(1, Math.round(windowMs(i) / 60_000))
72
73// Column x shows the latest sample before its end, so a sample holds until the next one replaces it.
74const byColumn = <T extends { at: number }>(samples: T[], i: ChartInput, width: number): (T | null)[] => {
75  const window = windowMs(i)
76  const span = window / width
77  const start = i.now - window
78  return Array.from({ length: width }, (_, x) => {
79    const end = start + (x + 1) * span
80    let best: T | null = null
81    for (const s of samples) if (s.at < end && (best === null || s.at >= best.at)) best = s
82    return best
83  })
84}
85
86export const contextChart = (i: ChartInput, width: number, height: number): Grid => {
87  const grid = blank(width, height)
88  byColumn(i.context, i, width).forEach((s, x) => {
89    if (s === null) return
90    if (s.compacted) return fillUp(grid, x, 0, height, 't')
91    fillUp(grid, x, 0, Math.round((s.percent / 100) * height), 'c')
92    put(grid, x, height - 1 - Math.round(s.cacheHit * (height - 1)), 'h')
93  })
94  return grid
95}
96
97export const historyChart = (i: ChartInput, width: number, height: number): Grid => {
98  const grid = blank(width, height)
99  const cols = byColumn(i.history, i, width)
100  const y = Math.max(1, ...cols.map(s => (s === null ? 0 : s.running + s.done + s.error)))
101  cols.forEach((s, x) => {
102    if (s === null) return
103    const r = Math.round((s.running / y) * height)
104    const d = Math.round(((s.running + s.done) / y) * height)
105    const e = Math.round(((s.running + s.done + s.error) / y) * height)
106    fillUp(grid, x, 0, r, 'r')
107    fillUp(grid, x, r, d, 'd')
108    fillUp(grid, x, d, e, 'e')
109  })
110  return grid
111}
112
113type Placed = { card: AgentCard; index: number; x: number; y: number }
114type Bar = { lane: number; y: number; to: number }
115type Layout = { size: 4 | 2 | 1; placed: Placed[]; bars: Bar[]; dropped: number }
116
117// Each lane starts a new cell row behind a 1 px bar; cell rows that fall below the area are dropped.
118const layout = (cards: AgentCard[], width: number, height: number, size: 4 | 2 | 1): Layout => {
119  const gap = size === 1 ? 0 : 1
120  const step = size + gap
121  const perRow = Math.floor((width - 2 + gap) / step)
122  const rows = Math.floor((height + gap) / step)
123  const ordered = gridOrder(cards)
124  const placed: Placed[] = []
125  const bars: Bar[] = []
126  let row = 0
127  let lane = -1
128  let prev: string | null = null
129  let col = 0
130  ordered.forEach((card, index) => {
131    const name = laneOf(card)
132    if (name !== prev) {
133      if (prev !== null) row++
134      prev = name
135      lane++
136      col = 0
137    } else if (col === perRow) {
138      row++
139      col = 0
140    }
141    if (perRow > 0 && row < rows) {
142      const y = row * step
143      placed.push({ card, index, x: 2 + col * step, y })
144      const last = bars.at(-1)
145      if (last !== undefined && last.lane === lane) last.to = y + size
146      else bars.push({ lane, y, to: y + size })
147    }
148    col++
149  })
150  return { size, placed, bars, dropped: ordered.length - placed.length }
151}
152
153const SIZES = [4, 2, 1] as const
154
155export const cellSize = (cards: AgentCard[], width: number, height: number): 4 | 2 | 1 =>
156  SIZES.find(size => layout(cards, width, height, size).dropped === 0) ?? 1
157
158export const agentGrid = (i: ChartInput, width: number, height: number): { grid: Grid; dropped: number } => {
159  const grid = blank(width, height)
160  const { size, placed, bars, dropped } = layout(i.cards, width, height, cellSize(i.cards, width, height))
161  for (const b of bars) for (let y = b.y; y < b.to; y++) put(grid, 0, y, LANE_BARS.charAt(b.lane % LANE_BARS.length))
162  for (const p of placed) {
163    const isCursor = p.index === i.cursor
164    const fill = isCursor && size < 4 ? 'u' : STATE_CHAR[stateOf(p.card)]
165    for (let dy = 0; dy < size; dy++)
166      for (let dx = 0; dx < size; dx++) {
167        const isEdge = dx === 0 || dy === 0 || dx === size - 1 || dy === size - 1
168        put(grid, p.x + dx, p.y + dy, isCursor && isEdge ? 'u' : fill)
169      }
170  }
171  return { grid, dropped }
172}
173
174export type BandPanel = { key: 'context' | 'agents' | 'history'; width: number; grid: Grid }
175
176// The title row loses the two border columns and its one column of left padding.
177export const titleColumns = (p: BandPanel) => p.width - 3
178
179export type TitleFacts = {
180  contextPct: number | null
181  cachePct: number | null
182  compacted: boolean
183  running: number
184  done: number
185  error: number
186  dropped: number
187  minutes: number
188  peak: number
189}
190
191const pct = (n: number | null) => (n === null ? '—' : `${Math.round(n)}`)
192
193// The short context form drops `%` so context, cache and the compaction mark all fit at 100% in the 60-column floor.
194export const bandTitles = (f: TitleFacts): Record<BandPanel['key'], TitlePart[]> => ({
195  context: [
196    { swatch: { glyph: '■', ch: 'c' }, text: ` context ${f.contextPct === null ? '—' : `${pct(f.contextPct)}%`}`, short: ` ctx ${pct(f.contextPct)}` },
197    { swatch: { glyph: '■', ch: 'h' }, text: ` cache ${f.cachePct === null ? '—' : `${pct(f.cachePct)}%`}`, short: ` cache ${pct(f.cachePct)}` },
198    ...(f.compacted ? [{ swatch: { glyph: '▌', ch: 't' }, text: 'compact', short: '' }] : []),
199  ],
200  agents: [
201    { swatch: { glyph: '■', ch: 'r' }, text: ` ${f.running} run`, short: ` ${f.running}` },
202    { swatch: { glyph: '■', ch: 'd' }, text: ` ${f.done} done`, short: ` ${f.done}` },
203    { swatch: { glyph: '■', ch: 'e' }, text: ` ${f.error} err`, short: ` ${f.error}` },
204    ...(f.dropped > 0 ? [{ text: `+${f.dropped} off-grid`, short: `+${f.dropped}` }] : []),
205  ],
206  history: [
207    { text: `history ${f.minutes}m`, short: `${f.minutes}m` },
208    { text: `peak ${f.peak}`, short: `↑${f.peak}` },
209  ],
210})
211
212// Each panel's grid is its width less the two border columns.
213export const bandPanels = (i: ChartInput, width: number, height: number): { panels: BandPanel[]; dropped: number } => {
214  if (i.cards.length === 0) return { panels: [{ key: 'context', width, grid: contextChart(i, width - 2, height) }], dropped: 0 }
215  const c = Math.floor(0.4 * width)
216  const g = Math.floor(0.3 * width)
217  const h = width - c - g
218  const agents = agentGrid(i, g - 2, height)
219  return {
220    panels: [
221      { key: 'context', width: c, grid: contextChart(i, c - 2, height) },
222      { key: 'agents', width: g, grid: agents.grid },
223      { key: 'history', width: h, grid: historyChart(i, h - 2, height) },
224    ],
225    dropped: agents.dropped,
226  }
227}
228
hooks/raster.ts 49 lines
1// The runtime ships these (RasterProps documents toBase64); lib es2023 has no types for them.
2declare global {
3  interface Uint8Array { toBase64(): string }
4  interface Uint8ArrayConstructor { fromBase64(base64: string): Uint8Array }
5}
6
7export type Palette = Record<string, number | null>
8
9export const DEFAULT_COLOR = 0x01000000
10
11const UPPER = 0x2580
12const LOWER = 0x2584
13const SPACE = 0x20
14
15type Color = number | null
16
17const cell = (top: Color, bottom: Color): [number, number, number] => {
18  if (top === null) return bottom === null ? [SPACE, DEFAULT_COLOR, DEFAULT_COLOR] : [LOWER, bottom, DEFAULT_COLOR]
19  return [UPPER, top, bottom ?? DEFAULT_COLOR]
20}
21
22const colorAt = (row: string | undefined, x: number, palette: Palette): Color =>
23  palette[row?.[x] ?? '.'] ?? null
24
25export const encodeCells = (grid: readonly string[], palette: Palette): { columns: number; rows: number; cells: string } => {
26  const columns = grid[0]?.length ?? 0
27  const rows = Math.ceil(grid.length / 2)
28  const words = new Uint32Array(columns * rows * 3)
29  for (let y = 0; y < rows; y++) {
30    for (let x = 0; x < columns; x++) {
31      const top = colorAt(grid[2 * y], x, palette)
32      const bottom = colorAt(grid[2 * y + 1], x, palette)
33      words.set(cell(top, bottom), (y * columns + x) * 3)
34    }
35  }
36  return { columns, rows, cells: new Uint8Array(words.buffer).toBase64() }
37}
38
39export const decodeCells = (cells: string): Uint32Array => {
40  const bytes = Uint8Array.fromBase64(cells)
41  return new Uint32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4)
42}
43
44export const upscale2x = (grid: readonly string[]): string[] =>
45  grid.flatMap(row => {
46    const wide = [...row].map(c => c + c).join('')
47    return [wide, wide]
48  })
49
hooks/svg.ts 49 lines
1import type { Grid } from './charts'
2import type { Palette } from './raster'
3
4// A grid pixel is half a terminal cell, about square; 7 px keeps a cell inside the desktop's code-font advance.
5export const CELL_PX = 7
6
7const SEG_W = 6
8const SEG_GAP = 2
9const SEG_H = 8
10
11export const hex = (n: number) => `#${n.toString(16).padStart(6, '0')}`
12
13/** The band's palette grid as SVG: one rect per run of a color along a row, in grid units. */
14export const gridSvg = (grid: Grid, palette: Palette): string => {
15  const columns = grid[0]?.length ?? 0
16  const rows = grid.length
17  const rects: string[] = []
18  grid.forEach((row, y) => {
19    let x = 0
20    while (x < row.length) {
21      const ch = row.charAt(x)
22      let end = x + 1
23      while (end < row.length && row.charAt(end) === ch) end++
24      const color = palette[ch] ?? null
25      if (color !== null) rects.push(`<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${hex(color)}"/>`)
26      x = end
27    }
28  })
29  return `<svg xmlns="http://www.w3.org/2000/svg" width="${columns * CELL_PX}" height="${rows * CELL_PX}" viewBox="0 0 ${columns} ${rows}" preserveAspectRatio="none" shape-rendering="crispEdges">${rects.join('')}</svg>`
30}
31
32type Gauge = { on: string; off: string }
33
34const segments = (g: Gauge) => [...g.on].length + [...g.off].length
35export const gaugeWidth = (g: Gauge) => Math.max(0, segments(g) * (SEG_W + SEG_GAP) - SEG_GAP)
36export const GAUGE_HEIGHT = SEG_H + 2
37
38/** A text gauge (`▰▰▱`) as SVG segments: filled in `on`, outlined in `off`. */
39export const gaugeSvg = (g: Gauge, on: string, off: string): string => {
40  const full = [...g.on].length
41  const segs = Array.from({ length: segments(g) }, (_, i) => {
42    const x = i * (SEG_W + SEG_GAP)
43    return i < full
44      ? `<rect x="${x}" y="1" width="${SEG_W}" height="${SEG_H}" rx="1.5" fill="${on}"/>`
45      : `<rect x="${x + 0.5}" y="1.5" width="${SEG_W - 1}" height="${SEG_H - 1}" rx="1.5" fill="none" stroke="${off}"/>`
46  })
47  return `<svg xmlns="http://www.w3.org/2000/svg" width="${gaugeWidth(g)}" height="${GAUGE_HEIGHT}" viewBox="0 0 ${gaugeWidth(g)} ${GAUGE_HEIGHT}">${segs.join('')}</svg>`
48}
49
hooks/rail.tsx 75 lines
1// A connector drawn on the surface's own frame clock: packets travel along it while `active`,
2// and it rests as a plain dim line otherwise. Only this region redraws; the pane does not.
3import type { ClientModule } from 'claude-code'
4
5type Props = {
6  active: boolean
7  width: number
8  color: string
9  dim: string
10  /** Cells where a branch drops (┬); the rest of the line is ─. */
11  marks: number[]
12  /** Draw ┴ instead of ┬ at the marks: a merge into what is below. */
13  isMerge: boolean
14}
15
16type Ref = { phase: number; active: boolean }
17type State = { ref: Ref }
18
19const STEP_MS = 110
20
21const Rail: ClientModule<Props, State> = (props, surface) => {
22  const { Box, Text } = surface.elements
23  const ref = surface.state?.ref ?? { phase: 0, active: props.active }
24  ref.active = props.active
25  if (surface.state === undefined) {
26    surface.setState({ ref })
27    surface.every(STEP_MS, () => {
28      if (ref.active) {
29        ref.phase += 1
30        surface.setState({ ref })
31      }
32    })
33  }
34
35  const width = Math.max(1, surface.columns || props.width)
36  const cells = Array.from({ length: width }, () => '─')
37  for (const m of props.marks) if (m >= 0 && m < width) cells[m] = props.isMerge ? '┴' : '┬'
38
39  if (!props.active) {
40    return (
41      <Box>
42        <Text color={props.dim}>{cells.join('')}</Text>
43      </Box>
44    )
45  }
46
47  // Two packets per 24 cells, a bright head and a trailing dot, moving left to right.
48  const lit = new Map<number, string>()
49  for (let base = 0; base < width + 24; base += 24) {
50    const head = (base + ref.phase) % (width + 24)
51    if (head < width) lit.set(head, '●')
52    if (head - 1 >= 0 && head - 1 < width) lit.set(head - 1, '•')
53  }
54  // Runs, not cells: consecutive cells of one kind share a Text, a handful of nodes per frame.
55  const runs: { text: string; isLit: boolean }[] = []
56  cells.forEach((ch, i) => {
57    const isLit = lit.has(i)
58    const last = runs[runs.length - 1]
59    const glyph = lit.get(i) ?? ch
60    if (last && last.isLit === isLit) last.text += glyph
61    else runs.push({ text: glyph, isLit })
62  })
63  return (
64    <Box>
65      {runs.map(r => (
66        <Text color={r.isLit ? props.color : props.dim} bold={r.isLit}>
67          {r.text}
68        </Text>
69      ))}
70    </Box>
71  )
72}
73
74export default Rail
75
hooks/elapsed.tsx 38 lines
1// A running agent's clock, ticking on the surface's frame clock so the pane need not redraw.
2// `since` and `now` come from the hooks module's $.clock; between redraws it adds its own ticks.
3import type { ClientModule } from 'claude-code'
4
5type Props = { since: number; now: number; endAt: number | null; color: string }
6type Ref = { base: number; ticks: number; lastNow: number; isRunning: boolean }
7type State = { ref: Ref }
8
9const fmt = (ms: number) => {
10  const s = Math.max(0, Math.floor(ms / 1000))
11  const m = Math.floor(s / 60)
12  return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
13}
14
15const Elapsed: ClientModule<Props, State> = (props, surface) => {
16  const { Text } = surface.elements
17  const ref = surface.state?.ref ?? { base: 0, ticks: 0, lastNow: -1, isRunning: true }
18  if (props.now !== ref.lastNow) {
19    // A fresh reading from the hooks module: restart the local count from it.
20    ref.lastNow = props.now
21    ref.base = (props.endAt ?? props.now) - props.since
22    ref.ticks = 0
23  }
24  ref.isRunning = props.endAt === null
25  if (surface.state === undefined) {
26    surface.setState({ ref })
27    surface.every(1000, () => {
28      if (ref.isRunning) {
29        ref.ticks += 1
30        surface.setState({ ref })
31      }
32    })
33  }
34  return <Text color={props.color}>{fmt(ref.base + ref.ticks * 1000)}</Text>
35}
36
37export default Elapsed
38
types/index.d.ts 143 lines
1export type Bucket = 'file' | 'shell' | 'other'
2
3/** rule: settings allowed it. ask: put to the decider, outcome pending. cleared: asked, then ran. deny: refused. */
4export type Verdict = 'rule' | 'ask' | 'cleared' | 'deny'
5
6export type Main = { model: string; effort: string; mode: string; steps: number; isRunning: boolean }
7
8export type Usage = {
9  pct: number | null
10  tokens: number | null
11  window: number
12  costUsd: number | null
13  limits: { kind: string; pct: number }[]
14  compactions: number
15  lastCompactAt: number | null
16}
17
18export type Check = {
19  id: string
20  tool: string
21  bucket: Bucket
22  verdict: Verdict
23  inSubagent: boolean
24  detail: string
25  at: number
26}
27
28export type Tally = { rule: number; ask: number; cleared: number; deny: number }
29
30export type Gate = { recent: Check[]; totals: Record<Bucket, Tally> }
31
32export type ToolNote = { tool: string; text: string; isError: boolean }
33
34export type AgentCard = {
35  id: string
36  type: string
37  model: string
38  description: string
39  status: string
40  spawnedAt: number
41  endedAt: number | null
42  /** The agent's context now: input + cache read + cache write of its latest step. */
43  ctx: number
44  /** Output tokens summed over its steps. */
45  out: number
46  steps: number
47  lastStop: string | null
48  tools: ToolNote[]
49  answer: string
50  parentAgentId: string | null
51}
52
53/** A model loop whose id matches no card: a workflow agent, a compaction or a memory fork. */
54export type Loop = { id: string; steps: number; firstAt: number; lastAt: number; isDone: boolean }
55
56export type LogLine = {
57  at: number
58  who: string
59  text: string
60  agentId: string | null
61  kind: 'info' | 'error' | 'done'
62}
63
64export type Turn = {
65  edits: number
66  errorStreak: number
67  errors: number
68  startedAt: number
69  costAtStart: number | null
70  /** Agents this session when the turn started, so the receipt counts the turn's own. */
71  agentsAtStart: number
72}
73
74export type Receipt = {
75  durationMs: number
76  agents: number
77  edits: number
78  errors: number
79  costDelta: number | null
80  reason: string
81  /** Absent on a receipt saved before this field existed. */
82  startedAt?: number
83}
84
85export type Layout = 'auto' | 'compact' | 'wide' | 'mini'
86
87export type View = { expanded: string | null; gateOpen: Bucket | null; layout: Layout | null; cursor: number | null }
88
89export type PromptStatus = 'idle' | 'running' | 'done' | 'aborted'
90
91export type Prompt = { text: string; startedAt: number | null; status: PromptStatus }
92
93export type Cache = { read: number; input: number; creation: number }
94
95export type ToolStat = { calls: number; errors: number; denies: number; recentMs: number[]; recentOk: boolean[]; lastOk: boolean }
96
97export type Tools = Record<string, ToolStat>
98
99/** File path to edits this turn. */
100export type HotFiles = Record<string, number>
101
102export type Tests = { light: 'off' | 'pass' | 'fail'; pass: number; fail: number; lastAt: number | null }
103
104export type Aborts = { count: number; at: number[]; turnIds: string[] }
105
106export type AgentState = 'spawning' | 'running' | 'done' | 'error'
107
108export type AgentTally = { spawning: number; running: number; done: number; error: number }
109
110export type AgentSample = { at: number; running: number; done: number; error: number }
111
112/** `byType` holds only the tallies of cards the cap dropped. */
113export type Agents = { byType: Record<string, AgentTally>; history: AgentSample[] }
114
115export type ContextSample = { at: number; percent: number; cacheHit: number; compacted: boolean }
116
117export type Context = { history: ContextSample[]; compactPending: boolean; lastPercent: number | null }
118
119declare module 'claude-code' {
120  interface PluginState {
121    'sessiondeck': {
122      meta: { schemaVersion: number }
123      main: Main
124      usage: Usage
125      gate: Gate
126      agents: AgentCard[]
127      loops: Loop[]
128      log: LogLine[]
129      turn: Turn
130      receipt: Receipt | null
131      view: View
132      prompt: Prompt
133      cache: Cache
134      tools: Tools
135      hotFiles: HotFiles
136      tests: Tests
137      aborts: Aborts
138      agentStats: Agents
139      context: Context
140    }
141  }
142}
143