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…

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.

Seven signals Flightdeck does not show:
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:
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.
| Panel | Shows | From |
|---|---|---|
main | Model, 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() |
prompt | The prompt in flight, its status and elapsed time. | classic.UserPromptSubmit, turn.start, turn.complete, turn.abort |
gate | One cell per permission check, totals, and a drill-down per tool family with credentials masked. | tool.check, settled by the tool.call around it |
agents | Cards 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 |
tools | The busiest tools, their mean latency, and a dot per recent error or deny. | tool.call |
turn | Test light, hot file, aborts, and the gate's file and shell tallies. | tool.call, turn.abort |
loops | Model loops that match no card: workflow agents, compactions, memory forks. | turn.step ids no card claims |
receipt | The running turn or the last one: duration, agents, edits, errors, cost added. | turn.start, turn.complete |
log | Prompts, spawns, completions, edits, errors and denials. | All of the above |
Panels with nothing to show take no room.
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>.
| Command | Effect | |||
|---|---|---|---|---|
/sessiondeck | Open the pane. | |||
/sessiondeck close | Close the pane. | |||
/sessiondeck reset | Clear 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:
| Key | Effect |
|---|---|
1 to 9 | Expand that agent card and outline it in the grid: its full task, last 3 tool calls and the start of its answer. |
n | Move the cursor to the next agent. |
b | Move the cursor back. |
x | Clear the expansion and the cursor. |
f s o | Open 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.
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 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.
testPattern. It passes when the call succeeds and fails otherwise. The pane counts runs, not individual test cases.ctx, not cost: the API has no per-agent cost.In /config, or under pluginConfigs["sessiondeck"].options in settings.json:
| Key | Default | Meaning | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
panels | main,prompt,gate,agents,tools,turn,loops,receipt,log | Panels in display order. Leave one out to hide it. | ||||||||
motion | while-active | Animate connectors while work flows, or off. | ||||||||
maxCards | 64 | Agent cards kept for the detail view (1-200). Finished cards past this fold into the per-type counts. | ||||||||
layout | auto | auto, compact, wide or mini. | ||||||||
palette | theme | theme follows your Claude Code theme. pastel and dracula use fixed colours. | ||||||||
openOnStart | true | Open the pane when a session starts. | ||||||||
statusLine | true | Show 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. |
historyMinutes | 30 | The most minutes the history charts show (1-30). | ||||||||
charts | true | Draw 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.
The pane doesn't appear.
/reload-plugins, then /sessiondeck.openOnStart can't seat the pane. /sessiondeck opens it at any width.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.
| File | Holds |
|---|---|
hooks/register.tsx | Event hooks, state atoms, the 10 s sampler, commands and the pane registration. |
hooks/draw.tsx | Every panel, the layouts and the band, for every surface. |
hooks/core.ts | Every reducer, formatter and layout rule as pure functions. |
hooks/charts.ts | Pure builders for the context chart, agent grid and agent history. |
hooks/raster.ts, hooks/svg.ts | Grid encoders: half-block raster for the terminal, SVG for the desktop. |
hooks/rail.tsx, hooks/elapsed.tsx | Surface modules: animated connectors and live clocks that redraw only themselves. |
types/index.d.ts | The 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.
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.
Forked from claude-flightdeck 0.3.2 by Stephen Casella (MIT). The Raster encoder is Battledeck's raster.ts.
hooks/register.tsx 620 lines1import { 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}
620hooks/core.ts 816 lines1// 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 })
816hooks/draw.tsx 816 lines1import 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}
816hooks/charts.ts 228 lines1import 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}
228hooks/raster.ts 49 lines1// 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 })
49hooks/svg.ts 49 lines1import 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}
49hooks/rail.tsx 75 lines1// 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
75hooks/elapsed.tsx 38 lines1// 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
38types/index.d.ts 143 lines1export 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