Side pane that shows running subagents and workflows as a live tree

A side pane for Claude Code that shows your subagents and workflows as a live tree. You see who is running, what each agent is doing now, how long it took and how many tokens it used. When a run ends, it moves to History, where you can replay it as a timeline or a trace.

Type this at the Claude Code prompt:
/plugin install agent-tree --marketplace SeanoChang/agent-tree
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). The pane is active at once; no restart is needed.
Open the pane with /agent-tree. It also opens by itself when a subagent starts and the terminal is at least 144 columns wide.
Needs: a Claude Code build that runs function-hook mods (tested on 2.1.292). Optional: rsvg-convert (brew install librsvg) to save PNG pictures.
| View | What you get |
|---|---|
| Tree | Each run with its agents nested under it: status, the current step, model, tokens and time |
| Timeline | One bar per agent. The critical path (the chain of agents that set the end time) is heavy, and dots mark idle time |
| Trace | One lane per agent, with every tool call, edit and hand-back in order. Search it with /, jump between problems with n p |
| Board | Agents grouped by status |
| History | The last 150 runs across sessions, per repo, with a short report for each |
More:
m on two runs, or = to compare a run with its last run of the same work. You see time, idle time, tokens by model, failures, tool calls and the files each run changed.x saves the open Timeline or Trace as SVG (and PNG) in ~/Downloads/agent-tree/.
Click the pane (or press ctrl+x tab) to give it the keyboard. ? lists every key.
Arrows move inside one zone at a time, like a TV remote. In the content, ↑ ↓ move and ← → fold. To switch a tab or a view, press ↑ on the top row to reach the bar, then ← →. Direct keys work from anywhere: 1 2 for Live and History, v for the next view, ? for help.
Run /plugin, pick agent-tree, and set:
| Option | Values | Default |
|---|---|---|
theme | minimal (plain glyphs, any terminal), kitty, fish, dog (colours and emoji) | minimal |
ai | off (no model calls), cheap (Haiku writes run reports and titles), full (also Sonnet on demand: e explains a run, g finds patterns in History) | cheap |
The cheap and full modes make model calls on your account. Set ai to off if you do not want that.
The mod listens to Claude Code's hook events (agent spawn, tool calls, turn end) and keeps live runs in session state and History in the plugin store. It reads subagent transcripts only when you open a trace. See docs/ARCHITECTURE.md for the data flow and the key map.
git clone https://github.com/SeanoChang/agent-tree && cd agent-tree
claude --plugin-dir . # run it from the folder
claude plugin validate .
claude plugin test .
Issues and ideas are welcome: open an issue or a discussion. A screenshot of the pane helps most.
MIT. See LICENSE.
hooks/register.tsx 1185 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register, Timer } from 'claude-code'
3
4import type { AgentNode, EditRecord, NodeStatus, PatternsDoc, Report, TraceRow, TraceView, TraceZoom, ViewProps } from '../types'
5import { themeNameOf, themeOf, type ThemeName } from './theme'
6import {
7 changesLine, cleanTitle, describeTool, diffCounts, editRecords, editedPath, fileRows, firstPrompt, firstSentence, handbackOf, fitProps,
8 lineDiff, parseJournal, pipeline, scopeNodes, transcriptEdits, folderKey, runKey,
9} from './list'
10import { REPORT_SYSTEM, kTokens, parseReport, partInput, runInput } from './report'
11import { ZOOMS, buildTrace, finishParse, newParse, onlyLane, parseLines, traceHits, traceMarks, traceWindow, type TraceEvent } from './trace'
12import {
13 AI_MODES, EXPLAIN_SYSTEM, PATTERNS_SYSTEM, STORY_SYSTEM, TIER_NAME, aiAllows, explainInput, parseBrief, parsePatterns,
14 parseStory, patternsInput, storyInput, type AiMode, type Tier,
15} from './ai'
16import { criticalPath, idleGaps, insights, timelineRows } from './lens'
17import { DESK, pictureSvg, timelineHeight, timelineSvg, traceSvg, type TimeBar } from './desktop/svg'
18// phase 6: the desktop's own native view, drawn from the same data
19import { drawDesktop } from './desktop/pane'
20import { DESK_DEFAULT, deskUi } from './desktop/state'
21
22const PANE = 'agent-tree'
23// Bumped when the view module's props or state change shape: a new key mounts a
24// fresh instance, where the old one would carry its state into the new code.
25const VIEW_KEY = 'tree-v7'
26const TITLE = 'Agents'
27const HISTORY = 'history'
28const HISTORY_RUNS = 150
29const nodes = atom({ plugin: 'agent-tree', key: 'nodes' } as const, [])
30const edits = atom({ plugin: 'agent-tree', key: 'edits' } as const, [])
31// The last wheel move over the pane, which the view applies once per seq.
32const wheel = atom({ plugin: 'agent-tree', key: 'wheel' } as const, { seq: 0, by: 0 })
33// The look chosen in /config, read from the options at each load.
34let themeName: ThemeName = 'minimal'
35// What the AI layer may do, from /config: off, cheap (Haiku) or full (Haiku, and Sonnet on demand).
36let aiMode: AiMode = 'cheap'
37// A new seq redraws the pane after an AI block was written where the view does not subscribe (the store).
38const aiSeq = atom({ plugin: 'agent-tree', key: 'ai' } as const, { seq: 0 })
39// Runs whose brief Sonnet is writing, whether patterns are being written, agents whose story is.
40const explaining = new Set<string>()
41let patternsBusy = false
42const storying = new Set<string>()
43const AI_SPEND = 'aiSpend'
44const PATTERNS = 'patterns'
45const WEEK = 7 * 86_400_000
46const EDITS = 'edits'
47const EDITS_KEPT = 400
48const EDIT_TEXT = 6000
49// The file the Changes view has open: only its edits travel to the view.
50let diffPath: string | null = null
51// where this session keeps subagent transcripts, learned from a Stop event or a workflow's folder
52let subagentsDir: string | undefined
53const backfilled = new Set<string>()
54
55// ── the trace the view has open ─────────────────────────────────────────────
56// What the view asked for: which run, how close, and where it reads.
57// `lane` cuts the rows to one agent's lane; `query` is a search the rows are matched against.
58let traceReq: { run: string; zoom: TraceZoom; offset: number; lane?: string; query?: string } | null = null
59// The run's trace as last built (all its rows), and the window the view gets.
60let traceBuilt: { key: string; at: number; lanes: TraceView['lanes']; rows: TraceRow[]; missing: number; storyBy?: string } | undefined
61let traceSnap: TraceView | undefined
62let tracer: Timer | undefined
63// A new seq redraws the pane after the trace was read again.
64const traceSeq = atom({ plugin: 'agent-tree', key: 'trace' } as const, { seq: 0 })
65// phase 6: what the desktop view has open (its tab, run, item, view, filters)
66const desk = atom({ plugin: 'agent-tree', key: 'desk' } as const, DESK_DEFAULT)
67// Each transcript's events, kept while its file does not change; a running agent's
68// file is read again at most every few seconds.
69const transcripts = new Map<string, { size: number; mtimeMs: number; checkedAt: number; events: TraceEvent[] }>()
70// Where each agent's transcript is: a path, or null once looked for and not found.
71const transcriptAt = new Map<string, string | null>()
72// $.fs.read takes files up to 4 MiB; bigger ones are read in 3 MiB pieces with dd.
73const READ_CAP = 3 * 1024 * 1024
74const DD_BLOCK = 65_536
75const DD_BLOCKS = READ_CAP / DD_BLOCK
76const TRACE_WINDOW = 60
77const TRACE_TEXT = 120
78
79// Agent tool calls seen in any loop. A spawn whose tool_use_id is not here
80// came from somewhere else (a workflow run), so it is filed under that run.
81const agentCalls = new Set<string>()
82// Each Agent call's `isolation`, read when its spawn arrives.
83const isolationOf = new Map<string, 'worktree' | 'remote'>()
84const digesting = new Set<string>()
85let reconciler: Timer | undefined
86let poller: Timer | undefined
87const titling = new Set<string>()
88// The main loop's current turn, from the last prompt it took: its subagents form one group.
89let turn: string | undefined
90const TITLE_CONCURRENCY = 4
91
92const AGENT_TOOLS = new Set(['Agent', 'Task'])
93const excerpt = (s: string | undefined, n = 400) => (s ? s.slice(0, n) : undefined)
94const scriptField = (script: string | undefined, field: string) =>
95 new RegExp(`${field}:\\s*['"]([^'"]+)['"]`).exec(script ?? '')?.[1]
96
97const runningWorkflow = (list: AgentNode[]) =>
98 [...list].reverse().find(n => n.kind === 'workflow' && n.status === 'running')
99
100const patch = ($: Engine, id: string, fn: (n: AgentNode) => AgentNode) =>
101 update($, nodes, list => list.map(n => (n.id === id ? fn(n) : n)))
102
103const finish = (status: NodeStatus, at: number) => (n: AgentNode): AgentNode =>
104 n.status === 'running' ? { ...n, status, endedAt: at } : n
105
106const statusOf = (word: string | undefined): NodeStatus =>
107 word === 'failed' ? 'failed' : word === 'killed' ? 'killed' : 'done'
108
109async function add($: Engine, node: AgentNode) {
110 await update($, nodes, list => [...list.filter(n => n.id !== node.id), node].slice(-400))
111 ensureTimers($)
112 // Unasked opens wait for a wide terminal; a refusal here must not fail the spawn.
113 void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
114}
115
116function ensureTimers($: Engine) {
117 reconciler ??= $.clock.every(5000, () => void reconcile($))
118 poller ??= $.clock.every(2000, () => void pollJournals($))
119}
120
121// $.agent.list() is the engine's own record of subagent status. It catches
122// ends no event reported (a killed background agent). Workflow agents are not listed.
123async function reconcile($: Engine) {
124 const at = await $.clock.now()
125 const listed = new Map((await $.agent.list()).map(a => [a.id, a]))
126 await update($, nodes, list =>
127 list.map(n => {
128 const info = listed.get(n.id)
129 if (!info || n.status !== 'running' || info.status === 'running') return n
130 return { ...n, status: statusOf(info.status === 'completed' ? 'done' : info.status), endedAt: at }
131 }),
132 )
133 await settleGroups($)
134 await archive($)
135 if (!(await read($, nodes)).some(n => n.status === 'running')) {
136 reconciler?.cancel()
137 reconciler = undefined
138 }
139}
140
141// A workflow's transcript folder says exactly which agents are its own, under
142// which script label and phase, on what type and model, and when each ended.
143async function pollJournals($: Engine) {
144 const wfs = (await read($, nodes)).filter(n => n.kind === 'workflow' && n.status === 'running' && n.transcriptDir)
145 if (wfs.length === 0) {
146 poller?.cancel()
147 poller = undefined
148 return
149 }
150 for (const wf of wfs) await syncJournal($, wf)
151 void titleAgents($)
152}
153
154async function syncJournal($: Engine, wf: AgentNode) {
155 const dir = wf.transcriptDir
156 if (!dir) return
157 const text = await $.fs.read(`${dir}/journal.jsonl`).catch(() => '')
158 const entries = parseJournal(text)
159 if (entries.length === 0) return
160 const at = await $.clock.now()
161 const list = await read($, nodes)
162 const known = new Map(list.map(n => [n.id, n]))
163 const fresh: AgentNode[] = []
164 for (const j of entries) {
165 const n = known.get(j.agentId)
166 if (n && n.tag !== undefined && n.type !== undefined && n.prompt !== undefined && (!j.isDone || n.status !== 'running')) continue
167 const meta = n?.type === undefined
168 ? await $.fs.read(`${dir}/agent-${j.agentId}.meta.json`).then(t => JSON.parse(t) as { agentType?: string; model?: string }).catch(() => undefined)
169 : undefined
170 const prompt = n?.prompt ?? firstPrompt(await $.fs.read(`${dir}/agent-${j.agentId}.jsonl`).catch(() => ''))
171 fresh.push({
172 ...(n ?? { id: j.agentId, kind: 'agent', label: j.label ?? `agent ${j.agentId.slice(0, 6)}`, status: 'running', startedAt: at, tools: 0 }),
173 parentId: wf.id,
174 tag: j.label ?? n?.tag,
175 phase: j.phase ?? n?.phase,
176 type: n?.type ?? meta?.agentType,
177 model: n?.model ?? meta?.model,
178 prompt: excerpt(prompt),
179 ...(j.isDone && n?.status === 'running' ? { status: 'done' as const, endedAt: at } : {}),
180 ...(j.isDone && !n ? { status: 'done' as const, endedAt: at } : {}),
181 })
182 }
183 const phases = [...new Set(entries.map(j => j.phase).filter((p): p is string => !!p))]
184 await update($, nodes, all => {
185 const byId = new Map(fresh.map(f => [f.id, f]))
186 const merged = all.map(n => byId.get(n.id) ?? (n.id === wf.id ? { ...n, phases } : n))
187 const added = fresh.filter(f => !all.some(n => n.id === f.id))
188 return [...merged, ...added].slice(-400)
189 })
190}
191
192// A group runs while any member runs. When the last one ends, the group ends with
193// it, gets one digest of all their reports and one toast, and moves to History.
194async function settleGroups($: Engine) {
195 const list = await read($, nodes)
196 for (const g of list.filter(n => n.kind === 'group' && n.status === 'running')) {
197 const kids = list.filter(k => k.parentId === g.id)
198 if (kids.length === 0 || kids.some(k => k.status === 'running')) continue
199 const status: NodeStatus = kids.some(k => k.status === 'failed') ? 'failed' : kids.every(k => k.status === 'killed') ? 'killed' : 'done'
200 const endedAt = Math.max(...kids.map(k => k.endedAt ?? g.startedAt))
201 const tokens = kids.reduce((a, k) => a + (k.tokens ?? 0), 0)
202 await patch($, g.id, x => ({ ...x, status, endedAt, tokens: tokens || undefined }))
203 void digest($, g.id)
204 await announce($, g.id)
205 }
206}
207
208// Subagents the main loop spawns in one turn are one piece of work. Once a turn has
209// two, they move into a group; later ones join it. One atomic update, so spawns that
210// land together (parallel Agent calls) all end up inside.
211async function regroup($: Engine, t: string): Promise<string | undefined> {
212 const id = `grp:${t}`
213 let isGrouped = false
214 await update($, nodes, all => {
215 const strays = all.filter(n => n.kind === 'agent' && !n.parentId && n.turn === t)
216 const has = all.some(n => n.id === id)
217 isGrouped = has || strays.length >= 2
218 if (!isGrouped || strays.length === 0) return all
219 const moved = all.map(n => (strays.includes(n) ? { ...n, parentId: id } : n))
220 if (has) return moved.map(n => (n.id === id && n.status !== 'running' ? { ...n, status: 'running' as const, endedAt: undefined, digested: false } : n))
221 const group: AgentNode = {
222 id, kind: 'group', label: strays[0]!.label, type: 'group', turn: t, where: strays[0]!.where,
223 status: 'running', startedAt: Math.min(...strays.map(f => f.startedAt)), tools: 0, titled: true,
224 }
225 return [...moved, group]
226 })
227 return isGrouped ? id : undefined
228}
229
230// A group's name says what its members work on together, from their task descriptions.
231// Named again when members join while Haiku is naming it.
232async function titleGroup($: Engine, id: string) {
233 if (titling.has(id)) return
234 const list = await read($, nodes)
235 const g = list.find(n => n.id === id)
236 const kids = list.filter(k => k.parentId === id)
237 if (!g || kids.length === 0 || g.titledFor === kids.length) return
238 titling.add(id)
239 const r = await ask($, 'haiku', {
240 maxTokens: 30,
241 system: 'You name a batch of AI agents that work toward one goal, so a person can scan a list of runs. Reply with the title only: 3 to 7 words, starting with a verb, naming the shared goal, no quotes, no final period.',
242 prompt: `Agents and their tasks:\n${kids.map(k => `- ${k.label}: ${(k.prompt ?? '').slice(0, 300)}`).join('\n')}`,
243 })
244 titling.delete(id)
245 const title = r?.isAnswered && r.text ? cleanTitle(r.text) : undefined
246 await patch($, id, x => ({ ...x, label: title ?? x.label, titledFor: kids.length, prompt: kids.map(k => k.label).join('; ') }))
247 void titleGroup($, id)
248}
249
250// Workflow agents carry script labels ("read:types"); a person wants "Read the type fields".
251async function titleAgents($: Engine) {
252 const list = await read($, nodes)
253 const wfIds = new Set(list.filter(n => n.kind === 'workflow').map(n => n.id))
254 const todo = list.filter(n => n.kind === 'agent' && n.parentId && wfIds.has(n.parentId) && !n.titled && n.prompt && !titling.has(n.id))
255 for (const n of todo.slice(0, Math.max(0, TITLE_CONCURRENCY - titling.size))) {
256 titling.add(n.id)
257 void ask($, 'haiku', {
258 maxTokens: 30,
259 system: 'You name tasks given to AI agents so a person can scan a list of them. Reply with the title only: 3 to 7 words, starting with a verb, no quotes, no final period.',
260 prompt: `Task:\n${(n.prompt ?? '').slice(0, 800)}`,
261 }).then(async r => {
262 const title = r?.isAnswered && r.text ? cleanTitle(r.text) : undefined
263 await patch($, n.id, x => ({ ...x, titled: true, label: title ?? x.label }))
264 }).catch(() => patch($, n.id, x => ({ ...x, titled: true }))).finally(() => {
265 titling.delete(n.id)
266 void titleAgents($)
267 })
268 }
269}
270
271async function endWorkflow($: Engine, taskId: string, word: string | undefined, result?: string) {
272 const wf = (await read($, nodes)).find(n => n.kind === 'workflow' && n.status === 'running' && n.taskId === taskId)
273 if (!wf) return
274 await syncJournal($, wf)
275 const at = await $.clock.now()
276 const status = statusOf(word)
277 // An agent the journal never saw end did not finish: it was cut with the run.
278 await update($, nodes, all =>
279 all.map(n => (n.id === wf.id ? { ...finish(status, at)(n), result: excerpt(result) ?? n.result } : n.parentId === wf.id ? finish('killed', at)(n) : n)),
280 )
281 void digest($, wf.id)
282 await announce($, wf.id)
283 await archive($)
284}
285
286// A finished top-level run gets a toast: what it was, how it ended, what it changed.
287async function announce($: Engine, id: string) {
288 const list = await read($, nodes)
289 const n = list.find(x => x.id === id)
290 if (!n || n.parentId || n.status === 'running') return
291 const rows = fileRows(scopeNodes(list, { kind: 'node', id: n.id }))
292 const head = n.status === 'failed' ? '✗ Failed' : n.status === 'killed' ? '■ Stopped' : '✓ Finished'
293 $.ui.toast(`${head}: ${n.label} · ${changesLine(rows)}`)
294}
295
296// Every model call goes through here: the /config setting decides who may write,
297// and what each call spent is kept for the History header.
298type Completion = { isAnswered: boolean; text?: string; usage?: { input_tokens: number; output_tokens: number } }
299async function ask($: Engine, tier: Tier, req: { system: string; prompt: string; maxTokens: number }): Promise<Completion | undefined> {
300 if (!aiAllows(aiMode, tier)) return undefined
301 const r = (await $.model.complete({ model: tier, ...req }).catch(() => undefined)) as Completion | undefined
302 const used = tokensOf(r)
303 // kept off the caller's path, one write at a time, so two calls never lose each other's spend
304 if (used) spendChain = spendChain.then(() => noteSpend($, used)).catch(() => undefined)
305 return r
306}
307let spendChain: Promise<void> = Promise.resolve()
308const tokensOf = (r: Completion | undefined) => (r?.usage ? r.usage.input_tokens + r.usage.output_tokens : 0)
309
310async function noteSpend($: Engine, tokens: number) {
311 const at = await $.clock.now()
312 const prev = ((await $.store.get(AI_SPEND)) as { at: number; tokens: number }[] | undefined) ?? []
313 await $.store.set(AI_SPEND, [...prev.filter(x => at - x.at < WEEK), { at, tokens }].slice(-2000))
314}
315
316async function aiWeek($: Engine): Promise<number> {
317 const at = await $.clock.now()
318 const list = ((await $.store.get(AI_SPEND)) as { at: number; tokens: number }[] | undefined) ?? []
319 return list.filter(x => at - x.at < WEEK).reduce((a, x) => a + x.tokens, 0)
320}
321
322const bumpAi = ($: Engine) => update($, aiSeq, s => ({ seq: (s?.seq ?? 0) + 1 }))
323
324// A node's change lands where the node is: the live list, History, or both.
325async function patchAnywhere($: Engine, id: string, fn: (n: AgentNode) => AgentNode) {
326 if ((await read($, nodes)).some(n => n.id === id)) await patch($, id, fn)
327 const history = ((await $.store.get(HISTORY)) as AgentNode[] | undefined) ?? []
328 if (history.some(n => n.id === id)) await $.store.set(HISTORY, history.map(n => (n.id === id ? fn(n) : n)))
329 await bumpAi($)
330}
331
332// Live nodes over History: a finished run can sit in both for a moment, and the live copy wins.
333async function allNodes($: Engine): Promise<AgentNode[]> {
334 const byId = new Map<string, AgentNode>()
335 for (const n of [...(((await $.store.get(HISTORY)) as AgentNode[] | undefined) ?? []), ...(await read($, nodes))]) byId.set(n.id, n)
336 return [...byId.values()]
337}
338
339// ── story steps: Haiku turns an agent's tool log into a few steps when it ends ──
340async function writeStory($: Engine, id: string) {
341 if (!aiAllows(aiMode, 'haiku') || storying.has(id)) return
342 const all = await read($, nodes)
343 const n = all.find(x => x.id === id)
344 if (!n || n.kind !== 'agent' || n.status === 'running' || n.story || n.storyTried) return
345 storying.add(id)
346 try {
347 await writeStoryOf($, n, all)
348 } catch {
349 // no transcript to read, or the read failed: the story is not asked again
350 await patch($, id, x => ({ ...x, storyTried: true })).catch(() => undefined)
351 } finally {
352 storying.delete(id)
353 }
354}
355
356async function writeStoryOf($: Engine, n: AgentNode, all: AgentNode[]) {
357 const id = n.id
358 await locateTranscripts($, [n], all)
359 const path = transcriptAt.get(id)
360 const at = await $.clock.now()
361 const events = path ? await transcriptEvents($, id, path, false, at) : undefined
362 if (!events?.length) { await patch($, id, x => ({ ...x, storyTried: true })); return }
363 const r = await ask($, 'haiku', { maxTokens: 400, system: STORY_SYSTEM, prompt: storyInput(n, events) })
364 const lines = r?.isAnswered && r.text ? parseStory(r.text) : undefined
365 const tokens = tokensOf(r)
366 await patch($, id, x => ({ ...x, storyTried: true, ...(lines ? { story: { steps: lines, model: TIER_NAME.haiku, ...(tokens ? { tokens } : {}), at } } : {}) }))
367 // an open trace reads the new steps at its next build
368 traceBuilt = undefined
369 await archive($)
370}
371
372// ── Explain this run: Sonnet, on demand, cached on the run ──────────────────
373// The caller has put the run in `explaining`; it leaves when the brief is stored.
374async function explainRun($: Engine, runId: string) {
375 try {
376 const all = await allNodes($)
377 const run = all.find(n => n.id === runId)
378 if (!run) return
379 const now = await $.clock.now()
380 const seen = insights(scopeNodes(all, { kind: 'node', id: run.id }), all, now).map(i => i.text)
381 const r = await ask($, 'sonnet', { maxTokens: 900, system: EXPLAIN_SYSTEM, prompt: explainInput(run, all, seen) })
382 const brief = r?.isAnswered && r.text ? parseBrief(r.text) : undefined
383 const tokens = tokensOf(r)
384 if (brief) await patchAnywhere($, runId, x => ({ ...x, explain: { ...brief, model: TIER_NAME.sonnet, ...(tokens ? { tokens } : {}), at: now } }))
385 } finally {
386 explaining.delete(runId)
387 await bumpAi($).catch(() => undefined)
388 }
389}
390
391// ── patterns across runs: Sonnet, on demand or weekly, one set per repository ──
392async function patternsKey($: Engine): Promise<string> {
393 const repo = await sessionRepo($)
394 return repo ? folderKey(repo) : 'all'
395}
396
397async function patternsDoc($: Engine): Promise<PatternsDoc | undefined> {
398 const docs = ((await $.store.get(PATTERNS)) as Record<string, PatternsDoc> | undefined) ?? {}
399 return docs[await patternsKey($)]
400}
401
402// The caller has set `patternsBusy`; it is cleared when the cards are stored.
403async function writePatterns($: Engine) {
404 try {
405 const key = await patternsKey($)
406 const history = ((await $.store.get(HISTORY)) as AgentNode[] | undefined) ?? []
407 const ids = new Set(history.map(n => n.id))
408 const runs = history.filter(n => (!n.parentId || !ids.has(n.parentId)) && (key === 'all' || runKey(n) === key))
409 .sort((a, b) => b.startedAt - a.startedAt)
410 if (runs.length < 2) return
411 const r = await ask($, 'sonnet', { maxTokens: 1200, system: PATTERNS_SYSTEM, prompt: patternsInput(runs, history) })
412 const cards = r?.isAnswered && r.text ? parsePatterns(r.text, runs) : undefined
413 const tokens = tokensOf(r)
414 if (cards) {
415 const docs = ((await $.store.get(PATTERNS)) as Record<string, PatternsDoc> | undefined) ?? {}
416 await $.store.set(PATTERNS, { ...docs, [key]: { cards, model: TIER_NAME.sonnet, ...(tokens ? { tokens } : {}), at: await $.clock.now() } })
417 }
418 } finally {
419 patternsBusy = false
420 await bumpAi($).catch(() => undefined)
421 }
422}
423
424// The report: what finished work achieved, in short plain sentences a person
425// scans. One Haiku call when it finishes, so the words are there before anyone
426// looks. Written bottom-up: an agent from its own report, a workflow phase from
427// its agents' reports, a cluster or a workflow from its part reports.
428// a reply that is not the asked-for JSON still says something: its first sentence is the result
429function reportFrom(r: Completion | undefined, at: number): Report | undefined {
430 if (!r?.isAnswered || !r.text) return undefined
431 const fallback = firstSentence(r.text, 300)
432 const parsed = parseReport(r.text) ?? (fallback ? { result: fallback } : undefined)
433 if (!parsed) return undefined
434 const tokens = r.usage ? r.usage.input_tokens + r.usage.output_tokens : 0
435 return { ...parsed, model: TIER_NAME.haiku, ...(tokens ? { tokens } : {}), at }
436}
437
438async function digest($: Engine, id: string) {
439 if (digesting.has(id)) return
440 const list = await read($, nodes)
441 const n = list.find(x => x.id === id)
442 if (!n || n.digested || n.status === 'running') return
443 const kids = list.filter(k => k.parentId === id)
444 // a cluster's report reads its members' reports: wait until each finished member has one;
445 // a workflow waits for the agent reports still being written, then writes what it has
446 if (n.kind === 'group' && kids.some(k => k.status !== 'running' && !k.digested && k.result)) return
447 if (n.kind === 'workflow' && kids.some(k => digesting.has(k.id))) return
448 digesting.add(id)
449 if (n.kind === 'workflow') await writeParts($, id, true)
450 const now = await read($, nodes)
451 const node = now.find(x => x.id === id) ?? n
452 const hasInput = node.kind === 'agent' ? !!node.result?.trim() : kids.length > 0 || !!node.result?.trim()
453 const r = hasInput ? await ask($, 'haiku', { maxTokens: 400, system: REPORT_SYSTEM, prompt: runInput(node, now) }) : undefined
454 const report = reportFrom(r, await $.clock.now())
455 await patch($, id, x => ({
456 ...x, digested: true,
457 ...(report ? { report, ...(report.result ? { summary: report.result } : {}), ...(report.done ? { points: report.done } : {}) } : {}),
458 }))
459 // released only once the report is stored, so a second trigger cannot write it twice
460 digesting.delete(id)
461 await archive($)
462 // an agent's report may be what its cluster, its workflow or its phase was waiting for
463 const parent = node.parentId ? (await read($, nodes)).find(x => x.id === node.parentId) : undefined
464 if (!parent || (parent.kind !== 'group' && parent.kind !== 'workflow')) return
465 if (parent.status !== 'running') { if (!parent.digested) void digest($, parent.id) }
466 else if (parent.kind === 'workflow') void writeParts($, parent.id, false)
467}
468
469// A workflow's phases that have finished get a part report each, read from their
470// agents' reports. While the run goes on, a phase waits until each of its agents
471// has its own short report; when the run ends (`force`), it is written
472// from what there is. A phase that gained agents since its report is written again.
473async function writeParts($: Engine, wfId: string, force: boolean) {
474 const list = await read($, nodes)
475 const wf = list.find(n => n.id === wfId)
476 if (!wf) return
477 for (const p of pipeline(wf, list)) {
478 if (p.running > 0 || p.total === 0) continue
479 const agents = list.filter(k => k.parentId === wfId && (k.phase ?? 'Agents') === p.phase)
480 if (!force && agents.some(k => !k.digested)) continue
481 await partDigest($, wfId, p.phase)
482 }
483}
484
485async function partDigest($: Engine, wfId: string, phase: string) {
486 const key = `${wfId}::${phase}`
487 if (digesting.has(key)) return
488 // taken before the first await, so a second trigger cannot slip in while this one reads
489 digesting.add(key)
490 try {
491 const list = await read($, nodes)
492 const wf = list.find(n => n.id === wfId)
493 const total = list.filter(k => k.parentId === wfId && (k.phase ?? 'Agents') === phase).length
494 // read now, not when the caller looked: another trigger may have written it meanwhile
495 if (!wf || wf.partReports?.[phase]?.count === total) return
496 const r = await ask($, 'haiku', { maxTokens: 300, system: REPORT_SYSTEM, prompt: partInput(wf, phase, list) })
497 const report = reportFrom(r, await $.clock.now())
498 await patch($, wfId, x => ({ ...x, partReports: { ...x.partReports, [phase]: { ...(report ?? {}), count: total } } }))
499 } finally {
500 // released only once the report is stored
501 digesting.delete(key)
502 }
503}
504
505type Change = NonNullable<AgentNode['changes']>
506const addChange = (prev: AgentNode['changes'], e: { path: string; added: number; removed: number }): Change => {
507 const c = prev?.[e.path] ?? { edits: 0, added: 0, removed: 0 }
508 return { ...prev, [e.path]: { edits: c.edits + 1, added: c.added + e.added, removed: c.removed + e.removed } }
509}
510
511const slim = (n: AgentNode): AgentNode => ({ ...n, prompt: excerpt(n.prompt, 300), result: excerpt(n.result, 600), activity: undefined, act: undefined })
512
513// The session's root folder: where it started, or where /cd moved it. A shell
514// cd does not move it, and neither does the folder the mod's commands run in.
515async function sessionRepo($: Engine): Promise<string | undefined> {
516 return (await $.session.root().catch(() => '')) || undefined
517}
518
519// Runs saved before the repo was the root folder get its key from where their
520// transcripts are kept: ~/.claude/projects/<root folder key>/..., once.
521async function backfillRepos($: Engine) {
522 const history = ((await $.store.get(HISTORY)) as AgentNode[] | undefined) ?? []
523 const byId = new Map(history.map(n => [n.id, n]))
524 const topOf = (n: AgentNode): AgentNode => (n.parentId && byId.has(n.parentId) ? topOf(byId.get(n.parentId)!) : n)
525 const tops = history.filter(n => topOf(n) === n && !n.repoTried)
526 if (tops.length === 0) return
527 const agentsUnder = new Map(tops.map(t => [t.id, history.filter(n => n.kind === 'agent' && topOf(n) === t).map(n => n.id)]))
528 const ids = [...new Set([...agentsUnder.values()].flat())]
529 const home = await $.env.get('HOME')
530 const names = ids.flatMap((id, i) => [...(i ? ['-o'] : []), '-name', `agent-${id}.jsonl`])
531 const found = ids.length
532 ? await $.process.run(['find', `${home}/.claude/projects`, '-maxdepth', '6', '(', ...names, ')'], { timeoutMs: 20000 }).catch(() => undefined)
533 : undefined
534 const keyOf = new Map((found?.stdout ?? '').split('\n').filter(Boolean).map(p => [p.replace(/^.*agent-|\.jsonl$/g, ''), p.split('/projects/')[1]?.split('/')[0]]))
535 const keys = new Map(tops.map(t => [t.id, (agentsUnder.get(t.id) ?? []).map(id => keyOf.get(id)).find(Boolean)]))
536 await $.store.set(HISTORY, history.map(n => {
537 if (!keys.has(n.id)) return n
538 const key = keys.get(n.id)
539 // a key from the transcript's folder wins over a repo recorded from the wrong folder
540 const { repo: _repo, ...rest } = n
541 return key ? { ...rest, repoKey: key, repoTried: true } : { ...n, repoTried: true }
542 }))
543}
544
545// The repo as a view prop: left out when there is none, since props hold no undefined.
546async function repoProp($: Engine): Promise<{ repo?: string }> {
547 const repo = await sessionRepo($)
548 return repo ? { repo } : {}
549}
550
551// Runs kept before reports were read from SubagentHandback calls get theirs
552// from their transcripts, once.
553async function backfillReports($: Engine) {
554 const history = ((await $.store.get(HISTORY)) as AgentNode[] | undefined) ?? []
555 const todo = history.filter(n => n.kind === 'agent' && !n.result && !n.reportTried)
556 if (todo.length === 0) return
557 const home = await $.env.get('HOME')
558 const names = todo.flatMap((n, i) => [...(i ? ['-o'] : []), '-name', `agent-${n.id}.jsonl`])
559 const found = await $.process.run(['find', `${home}/.claude/projects`, '-maxdepth', '6', '(', ...names, ')'], { timeoutMs: 20000 }).catch(() => undefined)
560 const pathOf = new Map((found?.stdout ?? '').split('\n').filter(Boolean).map(p => [p.replace(/^.*agent-|\.jsonl$/g, ''), p]))
561 const reports = new Map<string, string>()
562 for (const n of todo) {
563 const path = pathOf.get(n.id)
564 const report = path ? handbackOf(await $.fs.read(path).catch(() => '')) : undefined
565 if (report) reports.set(n.id, report)
566 }
567 const tried = new Set(todo.map(n => n.id))
568 await $.store.set(HISTORY, history.map(n => (tried.has(n.id) ? { ...n, reportTried: true, ...(reports.has(n.id) ? { result: excerpt(reports.get(n.id), 600) } : {}) } : n)))
569}
570
571// A finished top-level run, with everything under it, is kept across sessions.
572async function archive($: Engine) {
573 const list = await read($, nodes)
574 const ids = new Set(list.map(n => n.id))
575 const tops = list.filter(n => !n.parentId || !ids.has(n.parentId))
576 const subtree = (id: string): AgentNode[] => list.filter(n => n.parentId === id).flatMap(k => [k, ...subtree(k.id)])
577 const done = tops.map(t => [t, ...subtree(t.id)]).filter(run => run.every(n => n.status !== 'running'))
578 if (done.length === 0) return
579 const prev = ((await $.store.get(HISTORY)) as AgentNode[] | undefined) ?? []
580 const repo = await sessionRepo($)
581 const fresh = done.flat().map(slim).map(n => (repo && !n.repo ? { ...n, repo, repoTried: true } : n))
582 const freshIds = new Set(fresh.map(n => n.id))
583 const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
584 if (fresh.every(f => prev.some(p => p.id === f.id && p.status === f.status && p.label === f.label && p.summary === f.summary && p.parentId === f.parentId
585 && same(p.report, f.report) && same(p.partReports, f.partReports) && same(p.story, f.story) && same(p.explain, f.explain)))) return
586 const merged = [...prev.filter(p => !freshIds.has(p.id)), ...fresh]
587 const runTops = merged.filter(n => !n.parentId || !merged.some(m => m.id === n.parentId))
588 const keep = new Set(runTops.slice(-HISTORY_RUNS).map(n => n.id))
589 const rootOf = (n: AgentNode): string => {
590 const p = n.parentId && merged.find(m => m.id === n.parentId)
591 return p ? rootOf(p) : n.id
592 }
593 await $.store.set(HISTORY, merged.filter(n => keep.has(rootOf(n))))
594 // their edits go with them, so History can still show the diffs
595 const kept = new Set(merged.filter(n => keep.has(rootOf(n))).map(n => n.id))
596 const pastEdits = ((await $.store.get(EDITS)) as EditRecord[] | undefined) ?? []
597 const freshEdits = (await read($, edits)).filter(r => freshIds.has(r.agentId)).map(r => ({ ...r, old: r.old.slice(0, 2500), new: r.new.slice(0, 2500) }))
598 const known = new Set(pastEdits.map(r => r.id))
599 await $.store.set(EDITS, [...pastEdits, ...freshEdits.filter(r => !known.has(r.id))].filter(r => kept.has(r.agentId)).slice(-EDITS_KEPT))
600}
601
602export const register: Register = (on, options) => {
603 themeName = themeNameOf(options?.theme)
604 aiMode = AI_MODES.includes(options?.ai as AiMode) ? (options!.ai as AiMode) : 'cheap'
605 on('session.start', async ($, e, next) => {
606 await $.command.register({
607 name: 'agent-tree',
608 description: 'Show subagents and workflows in a side pane (`clear` empties the history)',
609 })
610 await archive($)
611 await backfillReports($)
612 await backfillRepos($)
613 if ((await read($, nodes)).some(n => n.status === 'running')) ensureTimers($)
614 // patterns written more than a week ago are written again, in the background, on Full only
615 const doc = aiAllows(aiMode, 'sonnet') ? await patternsDoc($) : undefined
616 if (doc && (await $.clock.now()) - doc.at > WEEK && !patternsBusy) {
617 patternsBusy = true
618 void writePatterns($).catch(() => undefined)
619 }
620
621 return next(e)
622 })
623
624 on('command.run', { command: 'agent-tree' }, async ($, e) => {
625 if (e.args.trim() === 'clear') {
626 await update($, nodes, list => list.filter(n => n.status === 'running'))
627 await $.store.set(HISTORY, [])
628 await $.store.set(EDITS, [])
629 await update($, edits, () => [])
630 return { text: 'Agent history cleared.' }
631 }
632 await $.ui.open({ id: PANE, title: TITLE })
633
634 return { text: 'Agents pane opened. Click it; ? shows the keys.' }
635 })
636
637 on('tool.call', { tool: 'Agent' }, (_$, e, next) => {
638 agentCalls.add(e.tool_use_id)
639 if (e.isolation) isolationOf.set(e.tool_use_id, e.isolation)
640 return next(e)
641 })
642
643 on('agent.spawn', async ($, e, next) => {
644 const startedAt = await $.clock.now()
645 const ran = await next(e)
646 if (ran.deny !== undefined || !ran.agentId) return ran
647
648 const list = await read($, nodes)
649 const fromWorkflow = !agentCalls.has(e.tool_use_id) ? runningWorkflow(list) : undefined
650 const fromMain = !e.parentAgentId && !fromWorkflow
651 const t = fromMain ? (turn ??= `t${startedAt}`) : undefined
652 await add($, {
653 id: ran.agentId,
654 kind: 'agent',
655 parentId: e.parentAgentId ?? fromWorkflow?.id,
656 turn: t,
657 label: e.name ?? e.description,
658 type: e.subagentType,
659 model: ran.model,
660 where: isolationOf.get(e.tool_use_id) ?? 'main',
661 status: 'running',
662 startedAt,
663 tools: 0,
664 act: 'think',
665 prompt: excerpt(e.prompt),
666 titled: !fromWorkflow,
667 })
668 const group = t ? await regroup($, t) : undefined
669 if (group) void titleGroup($, group)
670
671 return ran
672 })
673
674 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
675 const id = `wf:${e.tool_use_id}`
676 const name = e.name ?? scriptField(e.script, 'name') ?? 'workflow'
677 const description = scriptField(e.script, 'description')
678 await add($, {
679 id, kind: 'workflow', parentId: e.agentId, label: description ?? name, tag: name, type: 'workflow',
680 status: 'running', startedAt: await $.clock.now(), tools: 0, prompt: description,
681 })
682 const ran = await next(e)
683 if (ran.deny !== undefined || ran.isError) {
684 await patch($, id, finish('failed', await $.clock.now()))
685 } else {
686 const { taskId, workflowName, transcriptDir } = ran.result
687 await patch($, id, n => ({ ...n, taskId, transcriptDir, tag: workflowName ?? n.tag }))
688 ensureTimers($)
689 }
690
691 return ran
692 })
693
694 on('tool.call', async ($, e, next) => {
695 if (!e.agentId) return next(e)
696 const { agentId, tool } = e
697 const input = e as unknown as Record<string, unknown>
698 const { act, activity } = describeTool(tool, input)
699 const list = await read($, nodes)
700 const calledAt = await $.clock.now()
701 if (list.some(n => n.id === agentId)) {
702 await patch($, agentId, n => ({
703 ...n, tools: n.tools + 1, lastTool: tool, lastToolAt: calledAt, act, activity, status: 'running', endedAt: undefined,
704 work: act === 'think' ? n.work : { ...n.work, [act]: (n.work?.[act] ?? 0) + 1 },
705 }))
706 // An agent that hands its report back through a tool ends with no final text.
707 // SubagentHandback is not in the typed tool list, so the name is compared as a string.
708 if ((tool as string) === 'SubagentHandback' && typeof input.message === 'string') {
709 const report = input.message
710 await patch($, agentId, n => ({ ...n, result: excerpt(report) }))
711 }
712 ensureTimers($)
713 } else {
714 // A workflow agent seen before its journal line: filed under the running
715 // workflow now, and corrected by the journal on the next poll.
716 const wf = runningWorkflow(list)
717 if (!wf) return next(e)
718 await add($, {
719 id: agentId, kind: 'agent', parentId: wf.id, label: `agent ${agentId.slice(0, 6)}`,
720 status: 'running', startedAt: calledAt, tools: 1, lastTool: tool, lastToolAt: calledAt, act, activity,
721 work: act === 'think' ? {} : { [act]: 1 },
722 })
723 }
724
725 // An edit is recorded once it has landed: its before and after text, where it
726 // sits in the file, and a true line count of what it added and removed.
727 const path = editedPath(tool, input)
728 if (!path) return next(e)
729 const before = tool === 'Write' ? await $.fs.read(path).catch(() => undefined) : undefined
730 const ran = await next(e)
731 if (ran.deny !== undefined || ran.isError) return ran
732 const after = await $.fs.read(path).catch(() => undefined)
733 const at = await $.clock.now()
734 const recs = editRecords(tool, input, before, after).map((r, i) => ({
735 ...r, id: `${e.tool_use_id}:${i}`, agentId, at,
736 old: r.old.slice(0, EDIT_TEXT), new: r.new.slice(0, EDIT_TEXT),
737 }))
738 const stats = recs.map(r => diffCounts(lineDiff(r.old, r.new)))
739 await update($, edits, all => [...all, ...recs].slice(-EDITS_KEPT))
740 await patch($, agentId, n => {
741 let changes = n.changes
742 for (const s of stats) changes = addChange(changes, { path, ...s })
743 return { ...n, changes }
744 })
745 return ran
746 })
747
748 on('turn.complete', async ($, e, next) => {
749 const { agentId } = e
750 if (agentId) {
751 const at = await $.clock.now()
752 const u = e.usage
753 const tokens = u ? u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : undefined
754 const status: NodeStatus = e.isAborted ? 'killed' : 'done'
755 const list = await read($, nodes)
756 const wf = runningWorkflow(list)
757 if (list.some(n => n.id === agentId)) {
758 await patch($, agentId, n => ({
759 ...finish(status, at)(n),
760 model: n.model ?? u?.model,
761 result: excerpt(e.answer) ?? n.result,
762 tokens: (n.tokens ?? 0) + (tokens ?? 0),
763 }))
764 } else if (wf) {
765 // A workflow agent that never called a tool is first seen as it ends.
766 await add($, {
767 id: agentId, kind: 'agent', parentId: wf.id, label: `agent ${agentId.slice(0, 6)}`,
768 model: u?.model, status, startedAt: at - e.durationMs, endedAt: at, tools: 0,
769 result: excerpt(e.answer), tokens,
770 })
771 }
772 void digest($, agentId)
773 void writeStory($, agentId).catch(() => undefined)
774 await announce($, agentId)
775 await settleGroups($)
776 await archive($)
777 }
778
779 return next(e)
780 })
781
782 // A background task's end reaches the main loop as a queued prompt whose
783 // origin is `task-notification`, its text naming the task id and status.
784 on('prompt.submit', async ($, e, next) => {
785 // a prompt taken while idle opens a new turn; one delivered into a running turn
786 // (typed ahead, or a background task's notice) stays part of it
787 if (!e.turnId) turn = `t${await $.clock.now()}`
788 if (e.origin.kind === 'task-notification') {
789 const id = /<task-id>\s*([^<\s]+)/.exec(e.text)?.[1]
790 if (id) await endWorkflow($, id, /<status>\s*(\w+)/.exec(e.text)?.[1], /<result>([\s\S]*?)<\/result>/.exec(e.text)?.[1]?.trim())
791 }
792 return next(e)
793 })
794
795 // Backstop: at each stop the engine lists the background work still in flight.
796 // A running workflow missing from that list has ended.
797 on('classic.Stop', async ($, e, next) => {
798 if (e.transcript_path) subagentsDir = `${e.transcript_path.replace(/\.jsonl$/, '')}/subagents`
799 const inFlight = e.background_tasks
800 if (Array.isArray(inFlight)) {
801 const live = new Set(inFlight.map(t => t.id))
802 const gone = (await read($, nodes)).filter(n => n.kind === 'workflow' && n.status === 'running' && n.taskId && !live.has(n.taskId))
803 for (const wf of gone) if (wf.taskId) await endWorkflow($, wf.taskId, 'completed')
804 }
805 return next(e)
806 })
807
808 // The transcript's own Agent and Workflow rows shrink to one line; the pane is the full view.
809 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
810 const { tool, isRunning, isErrored } = e.props
811 if (!AGENT_TOOLS.has(tool) && tool !== 'Workflow') return next(e)
812 const { Box, Text } = $.ui.resolve(e)
813 const input = (e.props.input ?? {}) as { description?: string; subagent_type?: string; name?: string; script?: string }
814 const label = tool === 'Workflow'
815 ? scriptField(input.script, 'description') ?? input.name ?? scriptField(input.script, 'name') ?? 'workflow'
816 : input.description ?? 'subagent'
817 const kind = tool === 'Workflow' ? 'Workflow' : `${input.subagent_type ?? 'General-purpose'} agent`
818 const t = themeOf(themeName)
819 const st = t.status[isErrored ? 'failed' : isRunning ? 'running' : 'done']
820
821 return (
822 <Box flexDirection="row" gap={1}>
823 <Text color={st.color}>{st.icon}</Text>
824 {/* one glyph means status; only a workflow adds one for what it is */}
825 {tool === 'Workflow' ? <Text>{t.kind.workflow}</Text> : null}
826 <Text bold wrap="truncate-end">{label}</Text>
827 <Text dimColor>· {kind} · details in the Agents pane</Text>
828 </Box>
829 )
830 })
831
832 // The view asks for a file's diff when it opens one, and lets it go when it closes it.
833 // It asks for a run's trace the same way: which run, how close, where it reads.
834 on('ui.message', async ($, e, next) => {
835 const data = e.data as { type?: unknown; path?: unknown; run?: unknown; zoom?: unknown; offset?: unknown; regenerate?: unknown; lens?: unknown; focus?: unknown; lane?: unknown; query?: unknown } | null
836 if (e.requestId !== PANE) return next(e)
837 if (data?.type === 'diff') {
838 diffPath = typeof data.path === 'string' ? data.path : null
839 return { props: await viewProps($) }
840 }
841 if (data?.type === 'trace') {
842 await askTrace($, data)
843 return { props: await viewProps($) }
844 }
845 if (data?.type === 'export' && typeof data.run === 'string') {
846 const lens = data.lens === 'trace' ? 'trace' : 'timeline'
847 const zoom: TraceZoom = ZOOMS.includes(data.zoom as TraceZoom) ? (data.zoom as TraceZoom) : 'story'
848 void exportPicture($, data.run, lens, zoom, typeof data.focus === 'string' ? data.focus : undefined).catch(() => note($, 'Could not draw the picture.'))
849 return { props: await viewProps($) }
850 }
851 // Explain this run and patterns are Sonnet's, on Full only: the view says how to turn them on
852 if (data?.type === 'explain' && typeof data.run === 'string' && aiAllows(aiMode, 'sonnet') && !explaining.has(data.run)) {
853 explaining.add(data.run)
854 void explainRun($, data.run).catch(() => undefined)
855 return { props: await viewProps($) }
856 }
857 if (data?.type === 'patterns' && aiAllows(aiMode, 'sonnet') && !patternsBusy) {
858 patternsBusy = true
859 void writePatterns($).catch(() => undefined)
860 return { props: await viewProps($) }
861 }
862 return next(e)
863 })
864
865 // The pane's rows are the view's own, so the engine has nothing to scroll:
866 // each wheel tick is handed to the view, which moves its selection or text.
867 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
868 // a drawing taller than the pane (the desktop's) scrolls as the engine scrolls it
869 if (e.origin.kind !== 'person' || e.contentRows > e.bodyRows) return next(e)
870 await update($, wheel, w => ({ seq: (w?.seq ?? 0) + 1, by: e.by }))
871 return {}
872 })
873
874 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
875 const els = $.ui.resolve(e)
876 // phase 6: the desktop draws its own native view; presses write the desk state
877 if (e.surface === 'desktop') {
878 const columns = e.props.bodyColumns ?? e.viewport?.columns ?? 120
879 return drawDesktop(els, await viewProps($), deskUi(await read($, desk)), {
880 set: change => void update($, desk, s => ({ ...deskUi(s), ...change })),
881 openDiff: path => {
882 diffPath = path
883 void update($, desk, s => ({ ...deskUi(s) }))
884 },
885 // a trail that could not be read leaves the view as it was
886 askTrace: (run, zoom, offset) => void askTrace($, run ? { run, zoom, offset } : {})
887 .then(() => update($, desk, s => ({ ...deskUi(s) })))
888 .catch(() => undefined),
889 // the same Sonnet jobs as the terminal's e and g, on Full only
890 explain: run => {
891 if (!aiAllows(aiMode, 'sonnet') || explaining.has(run)) return
892 explaining.add(run)
893 void update($, desk, s => ({ ...deskUi(s) }))
894 void explainRun($, run).catch(() => undefined)
895 },
896 patterns: () => {
897 if (!aiAllows(aiMode, 'sonnet') || patternsBusy) return
898 patternsBusy = true
899 void update($, desk, s => ({ ...deskUi(s) }))
900 void writePatterns($).catch(() => undefined)
901 },
902 exportPic: (run, lens, zoom, focus) => void exportPicture($, run, lens, zoom, focus).catch(() => note($, 'Could not draw the picture.')),
903 }, columns)
904 }
905 // the pane body's own height: the viewport is the whole terminal, frame and all
906 const rows = Math.max(8, e.props.scroll?.bodyRows ?? (e.viewport?.rows ?? 24) - 4)
907 if (!('Client' in els)) return <els.Text dimColor>The Agents pane needs the terminal or desktop.</els.Text>
908 const { Client } = els
909
910 return <Client key={VIEW_KEY} module="./view.tsx" props={await viewProps($)} width="100%" height={rows} />
911 })
912}
913
914async function viewProps($: Engine): Promise<ViewProps> {
915 const history = ((await $.store.get(HISTORY)) as AgentNode[] | undefined) ?? []
916 // read so a trace read again, or an AI block written to the store, redraws the pane
917 await read($, traceSeq)
918 await read($, aiSeq)
919 const patterns = await patternsDoc($)
920 const ai = { ai: aiMode, aiWeek: await aiWeek($), explaining: [...explaining], patternsBusy, ...(patterns ? { patterns } : {}), ...(exported ? { exported } : {}) }
921 const trace = traceReq && traceSnap ? { trace: traceSnap } : {}
922 // the trace's window takes its share of the props bound first; runs fill the rest
923 const room = 90_000 - (trace.trace ? JSON.stringify(trace.trace).length : 0)
924 const base = { ...fitProps({ nodes: await read($, nodes), history, at: await $.clock.now() }, room - (patterns ? JSON.stringify(patterns).length : 0)), ...await repoProp($), theme: themeName, wheel: await read($, wheel), ...trace, ...ai }
925 if (!diffPath) return base
926 await backfill($, diffPath, [...history, ...base.nodes])
927 const pastEdits = ((await $.store.get(EDITS)) as EditRecord[] | undefined) ?? []
928 const seen = new Set<string>()
929 const forFile = [...pastEdits, ...(await read($, edits))]
930 .filter(r => r.path === diffPath && !seen.has(r.id) && seen.add(r.id))
931 .sort((a, b) => a.at - b.at)
932 return { ...base, diff: { path: diffPath, edits: forFile } }
933}
934
935// Agents that changed this file but left no edit records (runs from before edits
936// were kept) get theirs rebuilt from their transcripts, once, and stored.
937async function backfill($: Engine, path: string, all: AgentNode[]) {
938 const stored = ((await $.store.get(EDITS)) as EditRecord[] | undefined) ?? []
939 const live = await read($, edits)
940 const has = new Set([...stored, ...live].filter(r => r.path === path).map(r => r.agentId))
941 const byId = new Map(all.map(n => [n.id, n]))
942 const todo = [...byId.values()].filter(n => n.changes?.[path] && !has.has(n.id) && !backfilled.has(n.id))
943 if (todo.length === 0) return
944 const now = await $.fs.read(path).catch(() => undefined)
945 const found: EditRecord[] = []
946 for (const n of todo) {
947 backfilled.add(n.id)
948 const dir = n.parentId ? byId.get(n.parentId)?.transcriptDir : undefined
949 const sessionAgents = subagentsDir ?? dir?.replace(/\/workflows\/[^/]+$/, '')
950 // a trace may already have found it, in this session's folder or another's
951 const file = transcriptAt.get(n.id) ?? (dir ? `${dir}/agent-${n.id}.jsonl` : sessionAgents ? `${sessionAgents}/agent-${n.id}.jsonl` : undefined)
952 if (!file) continue
953 const text = await $.fs.read(file).catch(() => '')
954 found.push(...transcriptEdits(text, n.id, now).filter(r => r.path === path)
955 .map(r => ({ ...r, old: r.old.slice(0, 2500), new: r.new.slice(0, 2500) })))
956 }
957 if (found.length === 0) return
958 const known = new Set(stored.map(r => r.id))
959 await $.store.set(EDITS, [...stored, ...found.filter(r => !known.has(r.id))].slice(-EDITS_KEPT))
960}
961
962// ── the trace: read from transcripts when the view asks, never on a redraw ────
963
964// The view asks for a run's trace (or lets it go). A new run or zoom is read now;
965// a new place to read only moves the window. A live run is read again every 2 s.
966async function askTrace($: Engine, data: { run?: unknown; zoom?: unknown; offset?: unknown; lane?: unknown; query?: unknown }) {
967 const run = typeof data.run === 'string' ? data.run : null
968 if (!run) {
969 traceReq = null
970 traceSnap = undefined
971 traceBuilt = undefined
972 tracer?.cancel()
973 tracer = undefined
974 return
975 }
976 const zoom: TraceZoom = ZOOMS.includes(data.zoom as TraceZoom) ? (data.zoom as TraceZoom) : 'story'
977 const offset = typeof data.offset === 'number' && Number.isFinite(data.offset) ? Math.max(0, Math.floor(data.offset)) : 0
978 const isNew = !traceReq || traceReq.run !== run || traceReq.zoom !== zoom
979 const lane = typeof data.lane === 'string' && data.lane ? data.lane : undefined
980 const query = typeof data.query === 'string' && data.query.trim() ? data.query.slice(0, 80) : undefined
981 traceReq = { run, zoom, offset, ...(lane ? { lane } : {}), ...(query ? { query } : {}) }
982 const isLive = await buildRunTrace($, isNew)
983 if (isLive) tracer ??= $.clock.every(2000, () => void refreshTrace($))
984}
985
986async function refreshTrace($: Engine) {
987 if (!traceReq) { tracer?.cancel(); tracer = undefined; return }
988 const isLive = await buildRunTrace($, true)
989 await update($, traceSeq, s => ({ seq: (s?.seq ?? 0) + 1 }))
990 // a run that has ended is read once more, then left alone
991 if (!isLive) { tracer?.cancel(); tracer = undefined }
992}
993
994// Builds the asked run's trace (all rows when `force` or new), then its window.
995// Resolves whether the run is still going.
996async function buildRunTrace($: Engine, force: boolean): Promise<boolean> {
997 const req = traceReq
998 if (!req) return false
999 const now = await $.clock.now()
1000 const history = ((await $.store.get(HISTORY)) as AgentNode[] | undefined) ?? []
1001 // a finished run can sit in both lists for a moment: the live copy wins
1002 const byId = new Map<string, AgentNode>()
1003 for (const n of [...history, ...(await read($, nodes))]) byId.set(n.id, n)
1004 const all = [...byId.values()]
1005 const run = byId.get(req.run)
1006 if (!run) { traceSnap = undefined; return false }
1007 const scope = scopeNodes(all, { kind: 'node', id: run.id })
1008 const isLive = scope.some(n => n.status === 'running')
1009 const key = `${req.run}|${req.zoom}`
1010 if (force || !traceBuilt || traceBuilt.key !== key) traceBuilt = { key, at: now, ...await runTrace($, run, scope, all, req.zoom, now) }
1011 // one lane only, when asked: its rows and the arrows into and out of it
1012 const laneAt = req.lane ? traceBuilt.lanes.findIndex(L => L.id === req.lane) : -1
1013 const cut = laneAt >= 0 ? onlyLane(traceBuilt.rows, traceBuilt.lanes, laneAt) : { rows: traceBuilt.rows, lanes: traceBuilt.lanes }
1014 const win = traceWindow(cut.rows, req.offset, TRACE_WINDOW)
1015 traceSnap = {
1016 run: run.id, zoom: req.zoom, lanes: cut.lanes, total: cut.rows.length, offset: win.offset, rows: win.rows.map(trimRow), missing: traceBuilt.missing,
1017 ...(traceBuilt.storyBy ? { storyBy: traceBuilt.storyBy } : {}),
1018 ...(laneAt >= 0 ? { lane: req.lane } : {}),
1019 marks: traceMarks(cut.rows),
1020 ...(req.query ? { query: req.query, hits: traceHits(cut.rows, req.query) } : {}),
1021 }
1022 return isLive
1023}
1024
1025// A run's whole trace at a zoom, read from its agents' transcripts.
1026async function runTrace($: Engine, run: AgentNode, scope: AgentNode[], all: AgentNode[], zoom: TraceZoom, now: number) {
1027 const agents = scope.filter(n => n.kind === 'agent')
1028 await locateTranscripts($, agents, all)
1029 const events = new Map<string, TraceEvent[]>()
1030 for (const a of agents) {
1031 const path = transcriptAt.get(a.id)
1032 const got = path ? await transcriptEvents($, a.id, path, a.status === 'running', now) : undefined
1033 if (got) events.set(a.id, got)
1034 }
1035 const stories = new Map(agents.flatMap(a => (a.story?.steps.length ? [[a.id, a.story.steps] as [string, string[]]] : [])))
1036 const storyTokens = agents.reduce((sum, a) => sum + (a.story?.tokens ?? 0), 0)
1037 const storyBy = zoom === 'story' && stories.size ? `${TIER_NAME.haiku}${storyTokens ? ` · ${kTokens(storyTokens)} tokens` : ''}` : undefined
1038 return { ...buildTrace(run, scope, events, now, zoom, stories), ...(storyBy ? { storyBy } : {}) }
1039}
1040
1041// ── x: a run's timeline or trace, saved as a picture ──────────────────────────
1042// SVG always; PNG beside it when rsvg-convert is installed. ImageMagick is not used:
1043// a build without Freetype drops the text of an SVG.
1044const PICTURE_W = 1100
1045let exported: { seq: number; text: string } | undefined
1046
1047async function exportPicture($: Engine, runId: string, lens: 'timeline' | 'trace', zoom: TraceZoom, focus?: string) {
1048 const now = await $.clock.now()
1049 const history = ((await $.store.get(HISTORY)) as AgentNode[] | undefined) ?? []
1050 const byId = new Map<string, AgentNode>()
1051 for (const n of [...history, ...(await read($, nodes))]) byId.set(n.id, n)
1052 const all = [...byId.values()]
1053 const run = byId.get(runId)
1054 if (!run) return
1055 const t = DESK[themeName]
1056 const scope = scopeNodes(all, { kind: 'node', id: run.id })
1057 const agents = scope.filter(n => n.kind === 'agent')
1058 const end = Math.max(run.endedAt ?? now, ...agents.map(a => (a.status === 'running' ? now : a.endedAt ?? now)))
1059 const when = new Date(run.startedAt)
1060 const stamp = `${when.getFullYear()}-${String(when.getMonth() + 1).padStart(2, '0')}-${String(when.getDate()).padStart(2, '0')}-${String(when.getHours()).padStart(2, '0')}${String(when.getMinutes()).padStart(2, '0')}`
1061 const subtitle = `${when.toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })} · ${agents.length} agent${agents.length === 1 ? '' : 's'} · ${pictureDuration(end - run.startedAt)}`
1062 let svg: string
1063 if (lens === 'timeline') {
1064 const path = criticalPath(agents, now)
1065 const idle = idleGaps(agents, now)
1066 const onPath = new Set(path?.ids ?? [])
1067 const bars: TimeBar[] = timelineRows(run, all, [], 'all').map(r => (r.kind === 'label'
1068 ? { label: r.text, start: 0, end: 0, status: 'done' as const, isHeader: true }
1069 : { label: r.node.label, start: r.node.startedAt, end: r.node.status === 'running' ? now : r.node.endedAt ?? now, status: r.node.status, isCritical: onPath.has(r.node.id) }))
1070 const real = bars.filter(b => !b.isHeader)
1071 if (!real.length) return note($, 'Nothing to draw: this run has no agents.')
1072 const from = Math.min(...real.map(b => b.start))
1073 const to = Math.max(...real.map(b => b.end))
1074 const inner = timelineSvg(t, bars, from, to, PICTURE_W, { idle: idle.gaps })
1075 const legend = [...(path && path.ids.length ? [`outlined: critical path (${path.ids.length})`] : []), ...(idle.ms ? [`shaded: idle ${pictureDuration(idle.ms)}`] : [])]
1076 svg = pictureSvg(t, `${run.label} — timeline`, subtitle, legend, inner, PICTURE_W, timelineHeight(bars.length))
1077 } else {
1078 const built = await runTrace($, run, scope, all, zoom, now)
1079 if (!built.rows.length) return note($, 'Nothing to draw: no steps were recorded for this run.')
1080 const timed = built.rows.filter(r => r.at > 0)
1081 const from = Math.min(...timed.map(r => r.at))
1082 const to = Math.max(...timed.map(r => r.end ?? r.at))
1083 const marks = built.rows.map(r => ({ lane: r.lane, ...(r.to !== undefined ? { to: r.to } : {}), at: r.at || from, kind: r.kind, text: r.text }))
1084 const at = focus ? built.lanes.findIndex(L => L.id === focus) : -1
1085 const inner = traceSvg(t, built.lanes, marks, from, to, PICTURE_W, at >= 0 ? at : undefined)
1086 svg = pictureSvg(t, `${run.label} — trace`, `${subtitle} · ${zoom} zoom`, ['● call · ◆ edit · ✕ ended', 'arrow: started or handed back', 'dashed arrow: message'], inner, PICTURE_W, 24 + built.lanes.length * 40 + 8)
1087 }
1088 const home = await $.env.get('HOME')
1089 const base = `${home ?? '.'}/Downloads/agent-tree/${slug(run.label)}-${lens}-${stamp}`
1090 try {
1091 await $.fs.write(`${base}.svg`, svg)
1092 } catch {
1093 return note($, `Could not save the picture in ${home ?? '.'}/Downloads/agent-tree.`)
1094 }
1095 const png = await $.process.run(['rsvg-convert', '-z', '2', '-o', `${base}.png`, `${base}.svg`], { timeoutMs: 20000 }).catch(() => undefined)
1096 const short = (p: string) => (home ? p.replace(home, '~') : p)
1097 const isPng = png?.exitCode === 0
1098 // the footer gets the folder, which fits; the toast gets the whole path
1099 await note($, `Saved the ${lens} as ${isPng ? 'PNG and SVG' : 'SVG'} in ${short(`${home ?? '.'}/Downloads/agent-tree`)}`, `Saved ${short(`${base}.${isPng ? 'png' : 'svg'}`)}`)
1100}
1101
1102async function note($: Engine, text: string, toast = text) {
1103 exported = { seq: (exported?.seq ?? 0) + 1, text }
1104 $.ui.toast(toast)
1105 await bumpAi($)
1106}
1107
1108const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'run'
1109const pictureDuration = (ms: number) => {
1110 const sec = Math.max(0, Math.round(ms / 1000))
1111 return sec < 60 ? `${sec}s` : sec < 3600 ? `${Math.floor(sec / 60)}m ${String(sec % 60).padStart(2, '0')}s` : `${Math.floor(sec / 3600)}h ${String(Math.floor(sec / 60) % 60).padStart(2, '0')}m`
1112}
1113
1114const trimRow = (r: TraceRow): TraceRow => ({
1115 ...r,
1116 text: r.text.slice(0, TRACE_TEXT),
1117 ...(r.input ? { input: r.input.slice(0, TRACE_TEXT) } : {}),
1118 ...(r.output ? { output: r.output.slice(0, TRACE_TEXT) } : {}),
1119})
1120
1121// Where each agent's transcript is: its workflow's folder or this session's
1122// subagents folder, else (a run from another session) where find turns it up,
1123// all missing ones in one find. A finished agent with none is not looked for again.
1124async function locateTranscripts($: Engine, list: AgentNode[], all: AgentNode[]) {
1125 const todo: AgentNode[] = []
1126 for (const n of list) {
1127 if (transcriptAt.get(n.id)) continue
1128 if (transcriptAt.get(n.id) === null && n.status !== 'running') continue
1129 const parent = n.parentId ? all.find(p => p.id === n.parentId) : undefined
1130 const dirs = [parent?.transcriptDir, subagentsDir].filter((d): d is string => !!d)
1131 let hit: string | undefined
1132 for (const dir of dirs) {
1133 const path = `${dir}/agent-${n.id}.jsonl`
1134 if (await $.fs.exists(path).catch(() => false)) { hit = path; break }
1135 }
1136 if (hit) transcriptAt.set(n.id, hit)
1137 else todo.push(n)
1138 }
1139 if (todo.length === 0) return
1140 const home = await $.env.get('HOME')
1141 const names = todo.flatMap((n, i) => [...(i ? ['-o'] : []), '-name', `agent-${n.id}.jsonl`])
1142 const found = await $.process.run(['find', `${home}/.claude/projects`, '-maxdepth', '6', '(', ...names, ')'], { timeoutMs: 20000 }).catch(() => undefined)
1143 const pathOf = new Map((found?.stdout ?? '').split('\n').filter(Boolean).map(p => [p.replace(/^.*agent-|\.jsonl$/g, ''), p]))
1144 for (const n of todo) {
1145 const path = pathOf.get(n.id)
1146 // a running agent's file may not exist yet: it is looked for again
1147 if (path) transcriptAt.set(n.id, path)
1148 else if (n.status !== 'running') transcriptAt.set(n.id, null)
1149 }
1150}
1151
1152// An agent's events from its transcript: read whole when small, and in 3 MiB
1153// pieces with dd when not, so no single read passes $.fs.read's 4 MiB limit.
1154// Kept while the file is unchanged; a live file is looked at every 2 s (10 s when big).
1155async function transcriptEvents($: Engine, id: string, path: string, isLive: boolean, now: number): Promise<TraceEvent[] | undefined> {
1156 const kept = transcripts.get(path)
1157 if (kept && now - kept.checkedAt < (isLive ? 2000 : 60_000)) return kept.events
1158 const st = await $.fs.stat(path).catch(() => undefined)
1159 if (!st || st.kind !== 'file') { transcripts.delete(path); return undefined }
1160 if (kept && kept.size === st.size && kept.mtimeMs === st.mtimeMs) { kept.checkedAt = now; return kept.events }
1161 if (kept && st.size > READ_CAP && now - kept.checkedAt < 10_000) return kept.events
1162 const p = newParse(id)
1163 if (st.size <= READ_CAP) {
1164 const text = await $.fs.read(path).catch(() => undefined)
1165 if (text === undefined) return kept?.events
1166 parseLines(p, text)
1167 } else {
1168 let carry = ''
1169 for (let block = 0; block * DD_BLOCK < st.size; block += DD_BLOCKS) {
1170 const piece = await $.process.run(['dd', `if=${path}`, `bs=${DD_BLOCK}`, `skip=${block}`, `count=${DD_BLOCKS}`, 'status=none'], { timeoutMs: 20000 }).catch(() => undefined)
1171 if (!piece || piece.exitCode !== 0) break
1172 const text = carry + piece.stdout
1173 const cut = text.lastIndexOf('\n')
1174 parseLines(p, cut >= 0 ? text.slice(0, cut) : '')
1175 carry = cut >= 0 ? text.slice(cut + 1) : text
1176 // one line longer than a piece is a huge tool output: it is skipped
1177 if (carry.length > READ_CAP) carry = ''
1178 }
1179 parseLines(p, carry)
1180 }
1181 const events = finishParse(p)
1182 transcripts.set(path, { size: st.size, mtimeMs: st.mtimeMs, checkedAt: now, events })
1183 return events
1184}
1185hooks/theme.ts 198 lines1import type { AgentNode, NodeStatus } from '../types'
2
3// Four looks, chosen in /config: minimal works on every terminal (plain Unicode,
4// one accent); kitty, fish and dog are playful (emoji, which take two cells), each
5// telling one small story: cats out exploring, lines cast for a catch, dogs on walks.
6// Colour carries state before it decorates: ok, warn and bad mean status only.
7export type ThemeName = 'kitty' | 'fish' | 'dog' | 'minimal'
8export const THEME_NAMES: readonly ThemeName[] = ['minimal', 'kitty', 'fish', 'dog']
9
10export type Palette = {
11 ink: string; soft: string; muted: string; faint: string; rule: string; frame: string
12 accent: string; selBg: string; ok: string; warn: string; bad: string; add: string; del: string
13}
14
15export type Theme = {
16 name: ThemeName
17 c: Palette
18 status: Record<NodeStatus, { icon: string; word: string; color: string }>
19 // the running glyph's second frame, so a live row breathes
20 runningAlt: string
21 // One glyph per row means status. A second one appears only for what holds
22 // others (a workflow, a cluster) and for files; a plain agent gets none.
23 kind: { workflow: string; group: string; file: string }
24 bar: { full: string; empty: string }
25 // the mark on a warning: a quiet agent, a file two agents edited
26 warn: string
27 fold: { open: string; closed: string }
28 cursor: string
29 words: { live: string; history: string; emptyLive: string; emptyHistory: string; emptyHint: string }
30 // A trace's lanes: one hue per agent, kept apart from the status colours, which mean state.
31 lanes: string[]
32 // the mark a failed agent leaves in its trace lane (kitty's takes two cells)
33 traceFail: string
34 // the words a quiet gap reads in a trace
35 quiet: string
36}
37
38const MINIMAL: Theme = {
39 name: 'minimal',
40 c: {
41 ink: '#d4d7de', soft: '#b3b8c3', muted: '#8a909c', faint: '#5c626d', rule: '#3a3f48', frame: '#4a505b',
42 accent: '#7aa2f7', selBg: '#2a2f38', ok: '#9ece6a', warn: '#e0af68', bad: '#f7768e', add: '#9ece6a', del: '#f7768e',
43 },
44 status: {
45 running: { icon: '●', word: 'Running', color: '#e0af68' },
46 done: { icon: '✓', word: 'Done', color: '#9ece6a' },
47 failed: { icon: '✗', word: 'Failed', color: '#f7768e' },
48 killed: { icon: '■', word: 'Stopped', color: '#8a909c' },
49 },
50 runningAlt: '○',
51 kind: { workflow: '◆', group: '◇', file: '·' },
52 bar: { full: '▰', empty: '▱' },
53 warn: '⚠',
54 fold: { open: '▾', closed: '▸' },
55 cursor: '▌',
56 words: {
57 live: 'Running', history: 'Finished',
58 emptyLive: 'Nothing is running right now.', emptyHistory: 'No finished runs yet.',
59 emptyHint: 'Subagents and workflows appear here while they run, then move to History.',
60 },
61 lanes: ['#7aa2f7', '#bb9af7', '#7dcfff', '#73daca', '#ff9e64', '#c0caf5'],
62 traceFail: '✗',
63 quiet: 'quiet',
64}
65
66const KITTY: Theme = {
67 name: 'kitty',
68 c: {
69 ink: '#f4e4d8', soft: '#e3c9b8', muted: '#b8968a', faint: '#7a5f62', rule: '#4a3346', frame: '#ff8f70',
70 accent: '#ff8f70', selBg: '#3d2838', ok: '#a6d189', warn: '#f5c27a', bad: '#f38ba8', add: '#a6d189', del: '#f38ba8',
71 },
72 status: {
73 running: { icon: '🐱', word: 'Running', color: '#f5c27a' },
74 done: { icon: '😺', word: 'Done', color: '#a6d189' },
75 failed: { icon: '😿', word: 'Failed', color: '#f38ba8' },
76 killed: { icon: '🙀', word: 'Stopped', color: '#b8968a' },
77 },
78 runningAlt: '🐈',
79 kind: { workflow: '🧶', group: '🐾', file: '🧾' },
80 bar: { full: '▰', empty: '▱' },
81 warn: '⚠',
82 fold: { open: '▾', closed: '▸' },
83 cursor: '▌',
84 words: {
85 live: 'Out exploring', history: 'Back home',
86 emptyLive: 'ᓚᘏᗢ zzz · No cats out.', emptyHistory: 'No cats have come home yet.',
87 emptyHint: 'Subagents and workflows (🧶) appear here while they run, then move to History.',
88 },
89 lanes: ['#ff8f70', '#c9a0dc', '#89dceb', '#94e2d5', '#fab387', '#f5e0dc'],
90 traceFail: '😿',
91 quiet: 'ᓚᘏᗢ zzz',
92}
93
94// Lines cast for a catch: lake blues, a fin-orange frame. A failure got away.
95const FISH: Theme = {
96 name: 'fish',
97 c: {
98 ink: '#dcebf3', soft: '#b5cfdc', muted: '#86a5b5', faint: '#56748a', rule: '#22394b', frame: '#4fb3d9',
99 accent: '#4fb3d9', selBg: '#173042', ok: '#8bd5a0', warn: '#f2c26b', bad: '#f28b82', add: '#8bd5a0', del: '#f28b82',
100 },
101 status: {
102 running: { icon: '🎣', word: 'Running', color: '#f2c26b' },
103 done: { icon: '🐟', word: 'Done', color: '#8bd5a0' },
104 failed: { icon: '🐡', word: 'Failed', color: '#f28b82' },
105 killed: { icon: '🪝', word: 'Stopped', color: '#86a5b5' },
106 },
107 runningAlt: '🌊',
108 kind: { workflow: '🛶', group: '🐠', file: '🧾' },
109 bar: { full: '▰', empty: '▱' },
110 warn: '⚠',
111 fold: { open: '▾', closed: '▸' },
112 cursor: '▌',
113 words: {
114 live: 'Out fishing', history: 'The catch',
115 emptyLive: '><(((º> · Lines are dry.', emptyHistory: 'Nothing in the bucket yet.',
116 emptyHint: 'Subagents and workflows (🛶) appear here while they run, then move to History.',
117 },
118 lanes: ['#4fb3d9', '#b4a0f0', '#7fdbd0', '#f29b38', '#e6a5c8', '#c9d6df'],
119 traceFail: '🐡',
120 quiet: '><> zzz',
121}
122
123// Dogs out on walks, back with what they fetched: golden tan and warm browns.
124const DOG: Theme = {
125 name: 'dog',
126 c: {
127 ink: '#f3e6d6', soft: '#dcc6ad', muted: '#b09a82', faint: '#77634f', rule: '#43352a', frame: '#e6a85a',
128 accent: '#e6a85a', selBg: '#3a2d22', ok: '#a6d189', warn: '#f5d76e', bad: '#f08a7a', add: '#a6d189', del: '#f08a7a',
129 },
130 status: {
131 running: { icon: '🐕', word: 'Running', color: '#f5d76e' },
132 done: { icon: '🐶', word: 'Done', color: '#a6d189' },
133 failed: { icon: '🥺', word: 'Failed', color: '#f08a7a' },
134 killed: { icon: '🦴', word: 'Stopped', color: '#b09a82' },
135 },
136 runningAlt: '🦮',
137 kind: { workflow: '🎾', group: '🐾', file: '🧾' },
138 bar: { full: '▰', empty: '▱' },
139 warn: '⚠',
140 fold: { open: '▾', closed: '▸' },
141 cursor: '▌',
142 words: {
143 live: 'Out on walks', history: 'Fetched',
144 emptyLive: 'U・ᴥ・U zzz · No dogs out.', emptyHistory: 'No dogs are back from walks yet.',
145 emptyHint: 'Subagents and workflows (🎾) appear here while they run, then move to History.',
146 },
147 lanes: ['#e6a85a', '#9fb7e8', '#7fcfb8', '#d9a0c8', '#c8e04a', '#d6c8b8'],
148 traceFail: '🥺',
149 quiet: 'U-ᴥ-U zzz',
150}
151
152export const THEMES: Record<ThemeName, Theme> = { minimal: MINIMAL, kitty: KITTY, fish: FISH, dog: DOG }
153// An unknown name (an old setting, a typo) falls back to minimal.
154export const themeNameOf = (name: unknown): ThemeName =>
155 THEME_NAMES.includes(name as ThemeName) ? (name as ThemeName) : 'minimal'
156export const themeOf = (name: unknown): Theme => THEMES[themeNameOf(name)]
157
158// The glyph a row shows for what a node is: only what holds others has one.
159export function kindGlyph(t: Theme, n: Pick<AgentNode, 'kind'>): string {
160 if (n.kind === 'workflow') return t.kind.workflow
161 if (n.kind === 'group') return t.kind.group
162 return ''
163}
164
165// ── display width: how many terminal cells a string takes ──────────────────
166const ZERO = (cp: number) => (cp >= 0x300 && cp <= 0x36f) || cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f)
167const WIDE = (cp: number) =>
168 cp >= 0x1f000 ||
169 (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
170 (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6)
171
172export function cellWidth(s: string): number {
173 let w = 0
174 for (const ch of s) {
175 const cp = ch.codePointAt(0)!
176 w += ZERO(cp) ? 0 : WIDE(cp) ? 2 : 1
177 }
178 return w
179}
180
181// Cut to a width in cells, ending with … when anything was cut.
182export function fit(s: string, width: number): string {
183 if (width <= 0) return ''
184 if (cellWidth(s) <= width) return s
185 let out = ''
186 let w = 0
187 for (const ch of s) {
188 const cw = cellWidth(ch)
189 if (w + cw > width - 1) break
190 out += ch
191 w += cw
192 }
193 return out + '…'
194}
195
196export const padEnd = (s: string, width: number) => s + ' '.repeat(Math.max(0, width - cellWidth(s)))
197export const padStart = (s: string, width: number) => ' '.repeat(Math.max(0, width - cellWidth(s))) + s
198hooks/list.ts 627 lines1import type { Act, AgentNode, EditRecord } from '../types'
2
3// Subagents are cats, workflow agents sea creatures, a workflow its lead creature.
4export type Species = 'cat' | 'sea' | 'school'
5export const speciesOf = (n: Pick<AgentNode, 'kind'>, inWorkflow: boolean): Species =>
6 n.kind !== 'agent' ? 'school' : inWorkflow ? 'sea' : 'cat'
7
8const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
9const baseName = (p: string) => p.split('/').filter(Boolean).pop() ?? p
10const firstLine = (s: string) => s.split('\n')[0]?.trim() ?? ''
11const hostOf = (url: string) => /^[a-z]+:\/\/([^/]+)/i.exec(url)?.[1] ?? url
12const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
13
14// What a tool call is doing, as a sentence a person reads, and the prop the icon holds.
15export function describeTool(tool: string, input: Record<string, unknown>): { act: Act; activity: string } {
16 const s = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
17 switch (tool) {
18 case 'Read': return { act: 'read', activity: `Reading ${baseName(s('file_path'))}` }
19 case 'Grep': return { act: 'read', activity: `Searching the code for "${clip(s('pattern'), 32)}"` }
20 case 'Glob': return { act: 'read', activity: `Looking for files matching ${clip(s('pattern'), 32)}` }
21 case 'LS': return { act: 'read', activity: `Listing ${baseName(s('path'))}` }
22 case 'ToolSearch': return { act: 'read', activity: 'Looking up tools' }
23 case 'LSP': return { act: 'read', activity: 'Asking the language server' }
24 case 'Edit':
25 case 'MultiEdit': return { act: 'write', activity: `Editing ${baseName(s('file_path'))}` }
26 case 'Write': return { act: 'write', activity: `Writing ${baseName(s('file_path'))}` }
27 case 'NotebookEdit': return { act: 'write', activity: `Editing ${baseName(s('notebook_path'))}` }
28 // a Bash call's own description says what it does in words; the command is the fallback
29 case 'Bash': return { act: 'run', activity: s('description') ? clip(s('description'), 60) : `Running ${clip(firstLine(s('command')), 40)}` }
30 case 'Monitor': return { act: 'run', activity: 'Watching a process' }
31 case 'Agent':
32 case 'Task': return { act: 'run', activity: `Handing off: ${clip(s('description'), 36)}` }
33 case 'Workflow': return { act: 'run', activity: 'Starting a workflow' }
34 case 'WebFetch': return { act: 'web', activity: `Reading ${hostOf(s('url'))}` }
35 case 'WebSearch': return { act: 'web', activity: `Searching the web for "${clip(s('query'), 30)}"` }
36 }
37 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
38 if (mcp) return { act: 'web', activity: `Calling ${mcp[1]?.replace(/^claude_ai_/, '')} · ${mcp[2]?.replace(/_/g, ' ')}` }
39 return { act: 'think', activity: `Using ${tool}` }
40}
41
42const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
43export const editedPath = (tool: string, input: Record<string, unknown>): string | undefined => {
44 if (!EDIT_TOOLS.has(tool)) return undefined
45 const p = input.file_path ?? input.notebook_path
46 return typeof p === 'string' ? p : undefined
47}
48
49// "Read 3 files, edited 1, ran 2 commands": what an agent did, from its tool counts.
50export function workSummary(n: Pick<AgentNode, 'work' | 'changes' | 'tools'>): string {
51 const w = n.work ?? {}
52 const edited = Object.keys(n.changes ?? {}).length
53 const parts = [
54 w.read ? `read ${plural(w.read, 'file')}` : '',
55 edited ? `edited ${plural(edited, 'file')}` : '',
56 w.run ? `ran ${plural(w.run, 'command')}` : '',
57 w.web ? `${plural(w.web, 'web call')}` : '',
58 ].filter(Boolean)
59 if (parts.length === 0) return n.tools ? plural(n.tools, 'tool call') : 'No tools used'
60 const s = parts.join(', ')
61 return s[0]!.toUpperCase() + s.slice(1)
62}
63
64const lineCount = (s: unknown) => (typeof s === 'string' && s.length > 0 ? s.split('\n').length : 0)
65
66// What one edit did to one file, in lines, like a diffstat.
67export function editStats(tool: string, input: Record<string, unknown>): { path: string; added: number; removed: number } | undefined {
68 const path = editedPath(tool, input)
69 if (!path) return undefined
70 if (tool === 'Edit') return { path, added: lineCount(input.new_string), removed: lineCount(input.old_string) }
71 if (tool === 'MultiEdit') {
72 const edits = Array.isArray(input.edits) ? (input.edits as { old_string?: unknown; new_string?: unknown }[]) : []
73 return { path, added: edits.reduce((a, e) => a + lineCount(e.new_string), 0), removed: edits.reduce((a, e) => a + lineCount(e.old_string), 0) }
74 }
75 if (tool === 'Write') return { path, added: lineCount(input.content), removed: 0 }
76 return { path, added: lineCount(input.new_source), removed: 0 }
77}
78
79export type FileRow = { path: string; added: number; removed: number; edits: number; by: { node: AgentNode; added: number; removed: number; edits: number }[] }
80
81// Every file the given agents changed, with who changed it: the trail from a file back to its agents.
82export function fileRows(scope: AgentNode[]): FileRow[] {
83 const rows = new Map<string, FileRow>()
84 for (const n of scope) {
85 for (const [path, c] of Object.entries(n.changes ?? {})) {
86 const row = rows.get(path) ?? { path, added: 0, removed: 0, edits: 0, by: [] }
87 row.added += c.added
88 row.removed += c.removed
89 row.edits += c.edits
90 row.by.push({ node: n, ...c })
91 rows.set(path, row)
92 }
93 }
94 return [...rows.values()].sort((a, b) => b.added + b.removed - (a.added + a.removed) || a.path.localeCompare(b.path))
95}
96
97// Paths shown relative to what they share, so "hooks/view.tsx", not a 90-column absolute path.
98export function shortPaths(paths: string[]): Map<string, string> {
99 const split = paths.map(p => p.split('/'))
100 let common = 0
101 if (split.length > 0) {
102 const first = split[0]!
103 while (common < first.length - 1 && split.every(s => s.length - 1 > common && s[common] === first[common])) common++
104 }
105 // keep one directory of context when every file sits in the same folder
106 const keep = Math.max(0, Math.min(common, ...split.map(s => s.length - 2)))
107 return new Map(paths.map((p, i) => [p, split[i]!.slice(paths.length === 1 ? Math.max(0, split[i]!.length - 2) : keep).join('/')]))
108}
109
110export const diffstat = (added: number, removed: number) => `+${added} −${removed}`
111
112export type DiffLine = { op: ' ' | '+' | '-'; text: string; oldNo?: number; newNo?: number }
113
114// A line diff of one edit's before and after, numbered from where it sits in the
115// file: a longest-common-subsequence walk, as `diff` does, so context lines the edit
116// repeated are context, not a removal and an addition.
117export function lineDiff(before: string, after: string, start = 1): DiffLine[] {
118 const a = before ? before.split('\n') : []
119 const b = after ? after.split('\n') : []
120 const out: DiffLine[] = []
121 if (a.length * b.length > 400_000) {
122 a.forEach((text, i) => out.push({ op: '-', text, oldNo: start + i }))
123 b.forEach((text, i) => out.push({ op: '+', text, newNo: start + i }))
124 return out
125 }
126 const lcs: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0))
127 for (let i = a.length - 1; i >= 0; i--) {
128 for (let j = b.length - 1; j >= 0; j--) {
129 lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
130 }
131 }
132 let i = 0
133 let j = 0
134 while (i < a.length || j < b.length) {
135 if (i < a.length && j < b.length && a[i] === b[j]) { out.push({ op: ' ', text: a[i]!, oldNo: start + i, newNo: start + j }); i++; j++ }
136 // removals before additions, as diff prints them
137 else if (i < a.length && (j >= b.length || lcs[i + 1]![j]! >= lcs[i]![j + 1]!)) { out.push({ op: '-', text: a[i]!, oldNo: start + i }); i++ }
138 else { out.push({ op: '+', text: b[j]!, newNo: start + j }); j++ }
139 }
140 return out
141}
142
143export const diffCounts = (lines: DiffLine[]) => ({
144 added: lines.filter(l => l.op === '+').length,
145 removed: lines.filter(l => l.op === '-').length,
146})
147
148// What one tool call wrote, as before/after text and where it sits: one record per
149// change, so a file's diff can be told edit by edit, each with the agent that made it.
150export function editRecords(tool: string, input: Record<string, unknown>, before: string | undefined, after: string | undefined): { path: string; old: string; new: string; line: number; isNew?: boolean }[] {
151 const path = editedPath(tool, input)
152 if (!path) return []
153 const lineOf = (text: string) => {
154 const at = after && text ? after.indexOf(text) : -1
155 return at < 0 ? 1 : after!.slice(0, at).split('\n').length
156 }
157 const str = (x: unknown) => (typeof x === 'string' ? x : '')
158 if (tool === 'Edit') return [{ path, old: str(input.old_string), new: str(input.new_string), line: lineOf(str(input.new_string)) }]
159 if (tool === 'MultiEdit') {
160 const edits = Array.isArray(input.edits) ? (input.edits as { old_string?: unknown; new_string?: unknown }[]) : []
161 return edits.map(e => ({ path, old: str(e.old_string), new: str(e.new_string), line: lineOf(str(e.new_string)) }))
162 }
163 if (tool === 'Write') return [{ path, old: before ?? '', new: str(input.content), line: 1, isNew: before === undefined }]
164 return [{ path, old: '', new: str(input.new_source), line: 1 }]
165}
166
167// "Draft 2 done → Refine 1 running": a workflow's phases in words.
168export function phaseWords(p: { phase: string; total: number; running: number; done: number; failed: number; stopped: number }): string {
169 const parts = [
170 p.running ? `${p.running} running` : '',
171 p.done ? `${p.done} done` : '',
172 p.failed ? `${p.failed} failed` : '',
173 p.stopped ? `${p.stopped} stopped` : '',
174 ].filter(Boolean)
175 return parts.join(', ') || 'waiting'
176}
177
178// A workflow's return value is often JSON: its strings, joined, read better than its braces.
179export function readableResult(s: string | undefined): string {
180 if (!s) return ''
181 const t = s.trim()
182 if (!t.startsWith('{') && !t.startsWith('[')) return plain(t)
183 try {
184 const strings: string[] = []
185 const walk = (v: unknown) => {
186 if (typeof v === 'string') strings.push(v)
187 else if (Array.isArray(v)) v.forEach(walk)
188 else if (v && typeof v === 'object') Object.values(v).forEach(walk)
189 }
190 walk(JSON.parse(t))
191 return plain(strings.join(' '))
192 } catch { return plain(t) }
193}
194
195export function changesLine(rows: readonly FileRow[]): string {
196 if (rows.length === 0) return 'No files changed'
197 const added = rows.reduce((a, r) => a + r.added, 0)
198 const removed = rows.reduce((a, r) => a + r.removed, 0)
199 return `Changed ${plural(rows.length, 'file')} (${diffstat(added, removed)})`
200}
201
202// A workflow's phases in order with their agents' counts: "Explore ✓3 → Verify ●2 ✓1".
203export function pipeline(wf: AgentNode, nodes: AgentNode[]) {
204 const kids = nodes.filter(n => n.parentId === wf.id)
205 const order = [...(wf.phases ?? [])]
206 for (const k of kids) if (!order.includes(k.phase ?? 'Agents')) order.push(k.phase ?? 'Agents')
207 return order.map(phase => ({ phase, ...counts(kids.filter(k => (k.phase ?? 'Agents') === phase)) }))
208}
209
210// The agents a scope covers: a run with everything under it, one phase, or one agent and its own subagents.
211export function scopeNodes(nodes: AgentNode[], scope: { kind: 'node'; id: string } | { kind: 'phase'; wfId: string; phase: string }): AgentNode[] {
212 if (scope.kind === 'phase') return nodes.filter(n => n.parentId === scope.wfId && (n.phase ?? 'Agents') === scope.phase)
213 const under = (id: string): AgentNode[] => nodes.filter(n => n.parentId === id).flatMap(k => [k, ...under(k.id)])
214 const root = nodes.find(n => n.id === scope.id)
215 return root ? [root, ...under(root.id)] : []
216}
217
218// ── emoji: a cat for a subagent; a workflow agent's own sea creature, fixed by its id ──
219const SEA = ['🐡', '🐠', '🐙', '🐟']
220const hash = (s: string) => {
221 let h = 2166136261
222 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619)
223 return h >>> 0
224}
225export const markFor = (n: Pick<AgentNode, 'id' | 'kind'>, inWorkflow: boolean): string =>
226 n.kind === 'workflow' ? '🌊' : n.kind === 'group' ? '🐾' : inWorkflow ? SEA[hash(n.id) % SEA.length]! : '🐱'
227
228// Markdown to one plain line: headings, emphasis, code marks, links and list bullets go.
229export function plain(s: string | undefined): string {
230 return (s ?? '')
231 .replace(/```[\s\S]*?```/g, ' ')
232 .replace(/^\s{0,3}#{1,6}\s+/gm, '')
233 .replace(/^\s*[-*+]\s+/gm, '')
234 .replace(/^\s*\d+\.\s+/gm, '')
235 .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
236 .replace(/(\*\*|__|\*|_|`)/g, '')
237 .replace(/\s+/g, ' ')
238 .trim()
239}
240
241export const firstSentence = (s: string | undefined, max = 140) => {
242 const p = plain(s)
243 const m = /^(.+?[.!?])(\s|$)/.exec(p)
244 return clip(m?.[1] ?? p, max)
245}
246
247export function prettyModel(m: string | undefined): string | undefined {
248 if (!m) return undefined
249 const x = /(haiku|sonnet|opus|fable)(?:-(\d+)-(\d+))?/i.exec(m)
250 if (!x) return m
251 const name = x[1]![0]!.toUpperCase() + x[1]!.slice(1).toLowerCase()
252 return x[2] ? `${name} ${x[2]}.${x[3]}` : name
253}
254
255export function prettyType(n: Pick<AgentNode, 'kind' | 'type'>, inWorkflow: boolean): string {
256 if (n.kind === 'workflow') return 'Workflow'
257 if (n.kind === 'group') return 'Agent group'
258 const t = n.type
259 if (!t) return inWorkflow ? 'Workflow agent' : 'Agent'
260 const name = t.includes(':') ? t.split(':').pop()! : t
261 const words = name.replace(/[-_]/g, ' ')
262 return `${words[0]!.toUpperCase()}${words.slice(1)} agent`
263}
264
265export const whereLabel = (w: AgentNode['where']) =>
266 w === 'worktree' ? 'Own worktree' : w === 'remote' ? 'Remote' : 'Main checkout'
267
268export type JournalEntry = { agentId: string; label?: string; phase?: string; isDone: boolean }
269
270// A workflow run's journal.jsonl: a `started` line per agent (id, label, phase), a `result` line as each ends.
271export function parseJournal(text: string): JournalEntry[] {
272 const byId = new Map<string, JournalEntry>()
273 for (const line of text.split('\n')) {
274 if (!line.trim()) continue
275 let row: { type?: unknown; agentId?: unknown; label?: unknown; phase?: unknown }
276 try { row = JSON.parse(line) } catch { continue }
277 if (typeof row.agentId !== 'string') continue
278 const cur = byId.get(row.agentId) ?? { agentId: row.agentId, isDone: false }
279 if (row.type === 'started') {
280 if (typeof row.label === 'string') cur.label = row.label
281 if (typeof row.phase === 'string') cur.phase = row.phase
282 } else if (row.type === 'result') {
283 cur.isDone = true
284 }
285 byId.set(row.agentId, cur)
286 }
287 return [...byId.values()]
288}
289
290// The task an agent was given. A workflow agent's transcript opens with harness
291// frames: the relayed user request (skipped: it is the user's, not the agent's
292// task) and the computed task, whose text follows "follows:" indented two spaces.
293export function firstPrompt(jsonl: string): string | undefined {
294 for (const line of jsonl.split('\n').slice(0, 20)) {
295 let row: { message?: { role?: unknown; content?: unknown } }
296 try { row = JSON.parse(line) } catch { continue }
297 const m = row.message
298 if (m?.role !== 'user') continue
299 const text = typeof m.content === 'string'
300 ? m.content
301 : Array.isArray(m.content)
302 ? m.content.map(b => (b && typeof b === 'object' && 'text' in b ? String(b.text) : '')).join('\n').trim()
303 : ''
304 if (!text) continue
305 if (text.startsWith('[Workflow harness — user request]')) continue
306 if (text.startsWith('[Workflow harness')) {
307 const body = text.split(/follows:\s*\n/)[1]
308 return body ? body.split('\n').map(l => l.replace(/^ {2}/, '')).join('\n').trim() : undefined
309 }
310 return text
311 }
312 return undefined
313}
314
315// A digest reply: JSON with an outcome sentence and up to three points, tolerating fences and chatter.
316export function parseDigest(raw: string): { outcome?: string; points?: string[] } | undefined {
317 const json = /\{[\s\S]*\}/.exec(raw)?.[0]
318 if (!json) return undefined
319 try {
320 const d = JSON.parse(json) as { outcome?: unknown; points?: unknown }
321 const outcome = typeof d.outcome === 'string' ? plain(d.outcome).slice(0, 300) : undefined
322 const points = Array.isArray(d.points) ? d.points.filter((p): p is string => typeof p === 'string').map(p => plain(p).slice(0, 120)).slice(0, 3) : undefined
323 return { outcome, points }
324 } catch { return undefined }
325}
326
327// The report an agent handed back. Agents that end with a SubagentHandback call
328// leave no final text, so their report is that call's message; otherwise it is
329// the last text the agent wrote.
330export function handbackOf(transcript: string): string | undefined {
331 const lines = transcript.split('\n').filter(Boolean)
332 let lastText: string | undefined
333 for (let i = lines.length - 1; i >= 0; i--) {
334 let row: { message?: { role?: string; content?: unknown } }
335 try { row = JSON.parse(lines[i]!) } catch { continue }
336 const content = row.message?.role === 'assistant' && Array.isArray(row.message.content) ? row.message.content : []
337 for (const b of content as { type?: string; name?: string; text?: string; input?: { message?: unknown } }[]) {
338 if (b.type === 'tool_use' && b.name === 'SubagentHandback' && typeof b.input?.message === 'string') return b.input.message
339 if (b.type === 'text' && b.text?.trim() && lastText === undefined) lastText = b.text
340 }
341 }
342 return lastText
343}
344
345// A root folder as Claude Code names its projects folder: every character
346// that is not a letter or digit becomes '-'. Runs compare by this key.
347export const folderKey = (path: string) => path.replace(/[^a-zA-Z0-9]/g, '-')
348export const runKey = (n: { repo?: string; repoKey?: string }) => n.repoKey ?? (n.repo ? folderKey(n.repo) : undefined)
349
350// Why a finished agent has no report, in words, instead of a bare "No report".
351export function noReportReason(n: { status: string }): string {
352 if (n.status === 'running') return 'Still working.'
353 if (n.status === 'failed') return 'Stopped with an error before it reported.'
354 if (n.status === 'killed') return 'Stopped before it reported.'
355 return 'Finished without a written report.'
356}
357
358// Task text as a person reads it: runs recorded before the harness-aware reader kept the frames.
359export function taskText(prompt: string | undefined): string {
360 if (!prompt) return ''
361 if (prompt.startsWith('[Workflow harness — user request]')) return ''
362 if (prompt.startsWith('[Workflow harness')) return plain(prompt.split(/follows:\s*/)[1] ?? '')
363 return plain(prompt)
364}
365
366export function cleanTitle(raw: string): string | undefined {
367 const t = firstLine(raw).replace(/^["'`*#\s]+|["'`*.\s]+$/g, '').replace(/^title:\s*/i, '')
368 return t && t.length <= 80 ? t : undefined
369}
370
371export type Filter = 'all' | 'running' | 'failed'
372export type Group = 'none' | 'type' | 'where' | 'status'
373export const GROUPS: Group[] = ['none', 'type', 'where', 'status']
374export const GROUP_NAMES: Record<Group, string> = { none: 'no grouping', type: 'by type', where: 'by worktree', status: 'by status' }
375
376// A row of the run list or of a run's tree. `guide` is the tree's drawing to the
377// left of the row (├─ └─ │), `fold` a row that holds others and can open or close.
378export type Fold = { key: string; isOpen: boolean }
379export type Item =
380 | { kind: 'node'; node: AgentNode; species: Species; depth: number; guide?: string; fold?: Fold }
381 | { kind: 'phase'; key: string; wfId: string; phase: string; isOpen: boolean; total: number; done: number; failed: number; running: number; stopped: number; guide?: string }
382 | { kind: 'more'; key: string; count: number; guide?: string }
383 // a heading over rows; History's day headings carry a note and Older folds
384 | { kind: 'header'; text: string; count?: number; note?: string; isOpen?: boolean }
385 // a line under a cluster's row: its aspects, a file overlap, its combined outcome
386 | { kind: 'info'; tone: 'chips' | 'warn' | 'outcome'; nodeId: string; guide: string }
387 // a line of a pattern card on the History page: its title, evidence or what to try
388 | { kind: 'pattern'; tone: 'title' | 'evidence' | 'try'; text: string }
389
390export const isSelectable = (it: Item | undefined) => it !== undefined && it.kind !== 'header' && it.kind !== 'info' && it.kind !== 'pattern'
391
392// Live and History as one list. A run that just finished is in both, so each id
393// is kept once, and the live copy wins: it is the newer one.
394export function joinNodes(live: AgentNode[], history: AgentNode[]): AgentNode[] {
395 const seen = new Set(live.map(n => n.id))
396 return [...live, ...history.filter(n => !seen.has(n.id))]
397}
398
399export function childrenOf(nodes: AgentNode[], id: string) {
400 return nodes.filter(n => n.parentId === id)
401}
402
403export function counts(kids: AgentNode[]) {
404 const c = { total: kids.length, running: 0, done: 0, failed: 0, stopped: 0 }
405 for (const k of kids) {
406 if (k.status === 'running') c.running++
407 else if (k.status === 'failed') c.failed++
408 else if (k.status === 'killed') c.stopped++
409 else c.done++
410 }
411 return c
412}
413
414export const passes = (n: AgentNode, kids: AgentNode[], f: Filter) =>
415 f === 'all' ||
416 (f === 'running' && (n.status === 'running' || kids.some(k => k.status === 'running'))) ||
417 (f === 'failed' && (n.status === 'failed' || kids.some(k => k.status === 'failed')))
418
419const DAY = 86_400_000
420export const dayLabel = (t: number, now: number) => {
421 const startOf = (x: number) => { const d = new Date(x); d.setHours(0, 0, 0, 0); return d.getTime() }
422 const diff = Math.round((startOf(now) - startOf(t)) / DAY)
423 if (diff === 0) return 'Today'
424 if (diff === 1) return 'Yesterday'
425 return new Date(t).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
426}
427
428const STATUS_GROUP: Record<AgentNode['status'], string> = { running: 'Running', failed: 'Failed', done: 'Done', killed: 'Stopped' }
429const groupOf = (n: AgentNode, g: Group, now: number): string =>
430 g === 'type' ? prettyType(n, false).replace(/ agent$/, '') + (n.kind !== 'agent' ? 's' : ' agents')
431 : g === 'where' ? whereLabel(n.where)
432 : g === 'status' ? STATUS_GROUP[n.status]
433 : dayLabel(n.startedAt, now)
434
435// The top level: one row per subagent or workflow; a subagent's own subagents sit under it.
436// Live holds what is still running; a finished run moves to History. Rows sit under
437// group headers: the chosen grouping, or the day in History when there is none.
438export function topItems(
439 nodes: AgentNode[], filter: Filter, isHistory: boolean, now: number, group: Group = 'none',
440 flipped: readonly string[] = [], showDone: readonly string[] = [],
441): Item[] {
442 const ids = new Set(nodes.map(n => n.id))
443 const tops = nodes.filter(n => !n.parentId || !ids.has(n.parentId))
444 const sorted = isHistory
445 ? [...tops].sort((a, b) => b.startedAt - a.startedAt)
446 : [...tops].sort((a, b) => a.startedAt - b.startedAt)
447 const shown = sorted.filter(n => {
448 const kids = childrenOf(nodes, n.id)
449 if (!isHistory && n.status !== 'running' && !kids.some(k => k.status === 'running')) return false
450 return passes(n, kids, filter)
451 })
452 const useHeaders = group !== 'none' || isHistory
453 const buckets = new Map<string, AgentNode[]>()
454 for (const n of shown) {
455 const key = useHeaders ? groupOf(n, group, now) : ''
456 buckets.set(key, [...(buckets.get(key) ?? []), n])
457 }
458 const out: Item[] = []
459 for (const [key, list] of buckets) {
460 if (useHeaders) out.push({ kind: 'header', text: key, count: list.length })
461 // folded by default: the run list stays one row per run until one is opened
462 for (const n of list) emitTree(n, nodes, { filter, flipped, showDone }, out, { guide: '', prefix: '', depth: 0, defaultOpen: false })
463 }
464 return out
465}
466
467// ── the tree: a run and everything under it, with guide lines ─────────────
468
469type TreeOpts = { filter: Filter; flipped: readonly string[]; showDone: readonly string[] }
470type At = { guide: string; prefix: string; depth: number; defaultOpen: boolean }
471
472const isOpenNow = (key: string, byDefault: boolean, flipped: readonly string[]) => byDefault !== flipped.includes(key)
473const visibleKids = (nodes: AgentNode[], id: string, f: Filter) =>
474 childrenOf(nodes, id).filter(k => passes(k, childrenOf(nodes, k.id), f)).sort((a, b) => a.startedAt - b.startedAt)
475const branch = (prefix: string, isLast: boolean) => ({ guide: `${prefix}${isLast ? '└─ ' : '├─ '}`, prefix: `${prefix}${isLast ? ' ' : '│ '}` })
476
477// One node, then (when open) its cluster lines and its children: a group's members,
478// a workflow's phases and their agents, an agent's own subagents.
479function emitTree(n: AgentNode, nodes: AgentNode[], o: TreeOpts, out: Item[], at: At): void {
480 const isWorkflow = n.kind === 'workflow'
481 const kids = isWorkflow ? [] : visibleKids(nodes, n.id, o.filter)
482 const hasKids = isWorkflow ? childrenOf(nodes, n.id).length > 0 : kids.length > 0
483 // closed-by-default rows (the run list) and open-by-default rows (inside a run) keep separate keys
484 const key = `${at.defaultOpen ? 'o' : 'c'}:${n.id}`
485 const isOpen = hasKids && isOpenNow(key, at.defaultOpen, o.flipped)
486 out.push({ kind: 'node', node: n, species: speciesOf(n, !!n.phase), depth: at.depth, guide: at.guide, ...(hasKids ? { fold: { key, isOpen } } : {}) })
487 if (!isOpen) return
488 const more = hasKids ? `${at.prefix}│ ` : `${at.prefix} `
489 if (n.kind === 'group') {
490 // no aspect chips here: the members are the rows right below
491 if (overlaps(scopeNodes(nodes, { kind: 'node', id: n.id }).filter(k => k.id !== n.id)).length) out.push({ kind: 'info', tone: 'warn', nodeId: n.id, guide: more })
492 if (n.summary && n.status !== 'running') out.push({ kind: 'info', tone: 'outcome', nodeId: n.id, guide: more })
493 }
494 if (isWorkflow) return emitPhases(n, nodes, o, out, at)
495 kids.forEach((k, i) => emitTree(k, nodes, o, out, { ...branch(at.prefix, i === kids.length - 1), depth: at.depth + 1, defaultOpen: true }))
496}
497
498// A workflow's phases: one folding row each; a phase with nothing running or failed
499// folds to its row, and finished agents past the first few fold to a "… N done" row.
500function emitPhases(wf: AgentNode, nodes: AgentNode[], o: TreeOpts, out: Item[], at: At): void {
501 const kids = childrenOf(nodes, wf.id).sort((a, b) => a.startedAt - b.startedAt)
502 const order = [...(wf.phases ?? [])]
503 for (const k of kids) if (!order.includes(k.phase ?? 'Agents')) order.push(k.phase ?? 'Agents')
504 const phases = order
505 .map(phase => ({ phase, list: kids.filter(k => (k.phase ?? 'Agents') === phase && passes(k, [], o.filter)) }))
506 .filter(p => p.list.length > 0)
507 phases.forEach(({ phase, list }, pi) => {
508 const c = counts(list)
509 const key = `${wf.id}:${phase}`
510 const isOpen = (c.running > 0 || c.failed > 0) !== o.flipped.includes(key)
511 const b = branch(at.prefix, pi === phases.length - 1)
512 out.push({ kind: 'phase', key, wfId: wf.id, phase, isOpen, ...c, guide: b.guide })
513 if (!isOpen) return
514 const failed = list.filter(k => k.status === 'failed')
515 const running = list.filter(k => k.status === 'running')
516 const done = list.filter(k => k.status !== 'running' && k.status !== 'failed').sort((a, z) => (z.endedAt ?? 0) - (a.endedAt ?? 0))
517 const doneShown = o.showDone.includes(key) || done.length <= SHOWN_DONE + 1 ? done : done.slice(0, SHOWN_DONE)
518 const rows = [...failed, ...running, ...doneShown]
519 const hidden = done.length - doneShown.length
520 rows.forEach((n, i) => emitTree(n, nodes, o, out, { ...branch(b.prefix, i === rows.length - 1 && hidden === 0), depth: at.depth + 2, defaultOpen: true }))
521 if (hidden > 0) out.push({ kind: 'more', key, count: hidden, guide: branch(b.prefix, true).guide })
522 })
523}
524
525// Inside an opened run: the run's own row first, then its whole tree, open.
526export function runTree(run: AgentNode, nodes: AgentNode[], filter: Filter, flipped: readonly string[], showDone: readonly string[]): Item[] {
527 const out: Item[] = []
528 emitTree(run, nodes, { filter, flipped, showDone }, out, { guide: '', prefix: '', depth: 0, defaultOpen: true })
529 return out
530}
531
532// Files two or more agents of a scope edited: where parallel work can collide.
533export function overlaps(scope: AgentNode[]): { path: string; by: AgentNode[] }[] {
534 return fileRows(scope)
535 .map(r => ({ path: r.path, by: [...new Map(r.by.map(b => [b.node.id, b.node])).values()] }))
536 .filter(r => r.by.length >= 2)
537}
538
539// The agents a run or a top-level view covers, for the board, the timeline and the insights.
540export const leafAgents = (scope: AgentNode[]) => scope.filter(n => n.kind === 'agent')
541
542const SHOWN_DONE = 3
543
544// Inside a workflow: agents grouped by phase. A phase with nothing running or failed
545// folds to its header; finished agents past the first few fold to a "… N done" row.
546export function workflowItems(
547 wf: AgentNode, nodes: AgentNode[], filter: Filter, flipped: readonly string[], showDone: readonly string[],
548): Item[] {
549 const kids = childrenOf(nodes, wf.id).sort((a, b) => a.startedAt - b.startedAt)
550 const order = [...(wf.phases ?? [])]
551 for (const k of kids) if (!order.includes(k.phase ?? 'Agents')) order.push(k.phase ?? 'Agents')
552 const out: Item[] = []
553 for (const phase of order) {
554 const inPhase = kids.filter(k => (k.phase ?? 'Agents') === phase && passes(k, [], filter))
555 if (inPhase.length === 0) continue
556 const c = counts(inPhase)
557 const key = `${wf.id}:${phase}`
558 const isOpen = (c.running > 0 || c.failed > 0) !== flipped.includes(key)
559 out.push({ kind: 'phase', key, wfId: wf.id, phase, isOpen, ...c })
560 if (!isOpen) continue
561 const failed = inPhase.filter(k => k.status === 'failed')
562 const running = inPhase.filter(k => k.status === 'running')
563 const done = inPhase.filter(k => k.status !== 'running' && k.status !== 'failed')
564 .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
565 const doneShown = showDone.includes(key) || done.length <= SHOWN_DONE + 1 ? done : done.slice(0, SHOWN_DONE)
566 for (const n of [...failed, ...running, ...doneShown]) out.push({ kind: 'node', node: n, species: 'sea', depth: 1 })
567 if (doneShown.length < done.length) out.push({ kind: 'more', key, count: done.length - doneShown.length })
568 }
569 return out
570}
571
572// Inside a run: a workflow's phases and agents, or a subagent with its own subagents.
573export function runItems(
574 run: AgentNode, nodes: AgentNode[], filter: Filter, flipped: readonly string[], showDone: readonly string[],
575): Item[] {
576 if (run.kind === 'workflow') return workflowItems(run, nodes, filter, flipped, showDone)
577 const out: Item[] = []
578 const walk = (n: AgentNode, depth: number) => {
579 out.push({ kind: 'node', node: n, species: 'cat', depth })
580 for (const k of childrenOf(nodes, n.id)) walk(k, depth + 1)
581 }
582 // a group lists its members in the order they started, each with its own subagents
583 if (run.kind === 'group') for (const k of childrenOf(nodes, run.id).filter(k => passes(k, [], filter)).sort((a, b) => a.startedAt - b.startedAt)) walk(k, 1)
584 else walk(run, 0)
585 return out
586}
587
588// Keep the client's props under the tree bound: drop bulky text first, then the oldest history.
589export function fitProps<T extends { nodes: AgentNode[]; history: AgentNode[] }>(p: T, limit = 90_000): T {
590 const size = (x: unknown) => JSON.stringify(x).length
591 if (size(p) <= limit) return p
592 // a missing prompt or result stays missing: props that hold undefined are refused
593 const slim = (n: AgentNode): AgentNode => ({
594 ...n, ...(n.prompt !== undefined ? { prompt: n.prompt.slice(0, 160) } : {}), ...(n.result !== undefined ? { result: n.result.slice(0, 300) } : {}),
595 })
596 let next = { ...p, nodes: p.nodes.map(n => (n.status === 'running' ? n : slim(n))), history: p.history.map(slim) }
597 while (size(next) > limit && next.history.length > 0) next = { ...next, history: next.history.slice(Math.ceil(next.history.length / 4)) }
598 return next
599}
600
601// An agent's transcript keeps every edit call it made, so a run recorded before
602// edits were kept can still show its diffs. Failed calls are left out; lines are
603// placed against the file as it is now, which is right unless a later edit moved them.
604export function transcriptEdits(text: string, agentId: string, now: string | undefined): EditRecord[] {
605 const calls: { id: string; tool: string; input: Record<string, unknown>; at: number }[] = []
606 const failed = new Set<string>()
607 for (const line of text.split('\n')) {
608 if (!line.trim()) continue
609 let row: { timestamp?: string; message?: { content?: unknown } }
610 try { row = JSON.parse(line) } catch { continue }
611 const content = Array.isArray(row.message?.content) ? (row.message!.content as Record<string, unknown>[]) : []
612 const at = row.timestamp ? Date.parse(row.timestamp) || 0 : 0
613 for (const b of content) {
614 if (b.type === 'tool_use' && typeof b.id === 'string' && typeof b.name === 'string' && b.input && typeof b.input === 'object')
615 calls.push({ id: b.id, tool: b.name, input: b.input as Record<string, unknown>, at })
616 if (b.type === 'tool_result' && b.is_error === true && typeof b.tool_use_id === 'string') failed.add(b.tool_use_id)
617 }
618 }
619 return calls
620 .filter(c => !failed.has(c.id) && editedPath(c.tool, c.input))
621 // A transcript can not tell whether a Write made a new file, so isNew is left out
622 // (never set to undefined: props that hold undefined are refused).
623 .flatMap(c => editRecords(c.tool, c.input, undefined, now).map(({ isNew: _unknown, ...r }, i) => ({
624 ...r, id: `${c.id}:${i}`, agentId, at: c.at,
625 })))
626}
627hooks/report.ts 165 lines1import type { AgentNode, Report } from '../types'
2import { childrenOf, counts, fileRows, firstSentence, noReportReason, phaseWords, pipeline, plain, readableResult, scopeNodes, type FileRow } from './list'
3
4// The short report a person reads when work finishes. The model writes the words
5// (result, done, decisions, problems, next); what changed and the totals come from
6// the mod's own records, so they are exact. Reports are written bottom-up: an agent
7// from its own report, a workflow phase from its agents' reports, a run from its
8// part reports. Each call reads one level down and is capped, so none grows with the run.
9
10// Simplified Technical English, about 60% of the way: what every report prompt says.
11export const REPORT_RULES = [
12 'Write in Simplified Technical English, about 60% of the way:',
13 '- One idea per sentence. At most 15 words in a sentence.',
14 '- Use active voice and plain words.',
15 '- Name files, agents and numbers exactly as the input gives them.',
16 '- Lead with the main point. No hedging, no filler, no praise.',
17 '- Keep linking words (because, so, only when) so the reader can follow the logic.',
18 '- Use only facts that are in the input. Do not invent. Write "unknown" when the input does not say.',
19 '- At most 3 items in each list. At most 120 words in total.',
20].join('\n')
21
22export const REPORT_SYSTEM = [
23 'You write the short report a person reads when AI agents finish a piece of work.',
24 REPORT_RULES,
25 'Reply with JSON only, no markdown:',
26 '{"result": "the verdict, then at most 1 more sentence", "done": ["what was done"],',
27 ' "decisions": [{"text": "a design choice the agents made", "source": "the agent or phase it came from"}],',
28 ' "problems": [{"text": "what is wrong or still open, and where", "source": "the agent or phase it came from"}],',
29 ' "next": "1 sentence: the next step, or empty"}',
30 'The first sentence of result is the verdict: at most 12 words, with the key number. A reader who stops there knows the outcome. Example: "Found 1 real error; 8 of 9 claims are overstated."',
31 'Each problem names the item it is about (a file, claim or agent) and what is wrong with it.',
32 'Use [] for an empty list. Decisions are only design choices the input states. Leave out files and line counts: the dashboard adds them from its own records.',
33].join('\n')
34
35// The caps that keep every report call bounded, however big the run.
36export const CAP_EACH = 400
37export const CAP_ALL = 4000
38export const CAP_FINAL = 1500
39export const CAP_TASK = 500
40
41// A model's reply as a report: tolerant of fences and chatter, and of the older
42// {outcome, points} digest shape. Fields it cannot read are left out, never undefined.
43export function parseReport(raw: string): Report | undefined {
44 const json = /\{[\s\S]*\}/.exec(raw)?.[0]
45 if (!json) return undefined
46 let d: Record<string, unknown>
47 try { d = JSON.parse(json) as Record<string, unknown> } catch { return undefined }
48 const str = (x: unknown, n: number) => {
49 const t = typeof x === 'string' ? plain(x) : ''
50 return t ? t.slice(0, n) : undefined
51 }
52 const tagged = (x: unknown) => (Array.isArray(x) ? x : []).flatMap(i => {
53 const text = str(typeof i === 'string' ? i : (i as { text?: unknown } | null)?.text, 200)
54 if (!text) return []
55 const source = i && typeof i === 'object' ? str((i as { source?: unknown }).source, 60) : undefined
56 return [source ? { text, source } : { text }]
57 }).slice(0, 3)
58 const doneRaw = Array.isArray(d.done) ? d.done : Array.isArray(d.points) ? d.points : []
59 const done = doneRaw.map(p => str(typeof p === 'string' ? p : (p as { text?: unknown } | null)?.text, 160)).filter((p): p is string => !!p).slice(0, 3)
60 const out: Report = {}
61 const result = str(d.result, 300) ?? str(d.outcome, 300)
62 if (result) out.result = result
63 if (done.length) out.done = done
64 const decisions = tagged(d.decisions)
65 if (decisions.length) out.decisions = decisions
66 const problems = tagged(d.problems)
67 if (problems.length) out.problems = problems
68 const next = str(d.next, 200)
69 if (next) out.next = next
70 return Object.keys(out).length ? out : undefined
71}
72
73// The report a node shows: its own, else one shaped from what an older run kept
74// (summary as the result, points as what was done). No model call.
75export function reportOf(n: AgentNode): Report | undefined {
76 if (n.report) return n.report
77 const result = n.summary || (n.kind === 'workflow' ? firstSentence(readableResult(n.result), 300) : firstSentence(n.result, 300))
78 const out: Report = {}
79 if (result) out.result = result
80 if (n.points?.length) out.done = n.points.slice(0, 3)
81 return Object.keys(out).length ? out : undefined
82}
83
84const STATUS_WORD: Record<AgentNode['status'], string> = { running: 'running', done: 'done', failed: 'failed', killed: 'stopped' }
85
86// One line per part for the level above: its result and its problems, never its raw text.
87export function partLine(n: AgentNode): string {
88 const r = reportOf(n)
89 const problems = r?.problems?.map(p => p.text).join(' ') ?? ''
90 return `- ${n.label} [${STATUS_WORD[n.status]}]: ${r?.result ?? noReportReason(n)}${problems ? ` Problems: ${problems}` : ''}`.slice(0, CAP_EACH)
91}
92
93// Lines joined until the cap, so the input never grows with the run.
94export function capLines(lines: string[], cap = CAP_ALL): string {
95 let out = ''
96 for (const l of lines) {
97 if (out.length + l.length + 1 > cap) { out += `${out ? '\n' : ''}- … ${lines.length - out.split('\n').length} more not shown`; break }
98 out += `${out ? '\n' : ''}${l}`
99 }
100 return out
101}
102
103// A workflow phase's part report reads its agents' reports.
104export function partInput(wf: AgentNode, phase: string, nodes: AgentNode[]): string {
105 const agents = childrenOf(nodes, wf.id).filter(k => (k.phase ?? 'Agents') === phase).sort((a, b) => a.startedAt - b.startedAt)
106 return [
107 `Workflow: ${wf.label.slice(0, 200)}`,
108 `Part: the ${phase} phase, ${agents.length} agent${agents.length === 1 ? '' : 's'} (${phaseWords({ phase, ...counts(agents) })})`,
109 '',
110 'Agent reports:',
111 capLines(agents.map(partLine)),
112 ].join('\n')
113}
114
115// A run's report reads one level down: a single agent its own report text; a
116// cluster its members' reports; a workflow its phases' part reports.
117export function runInput(n: AgentNode, nodes: AgentNode[]): string {
118 const task = `Task: ${(n.prompt ?? n.label).slice(0, CAP_TASK)}`
119 if (n.kind === 'group') {
120 const kids = childrenOf(nodes, n.id).sort((a, b) => a.startedAt - b.startedAt)
121 return [task, '', `Part reports, one per member (${kids.length}):`, capLines(kids.map(partLine))].join('\n')
122 }
123 if (n.kind === 'workflow') {
124 const parts = pipeline(n, nodes).map(p => {
125 const pr = n.partReports?.[p.phase]
126 const problems = pr?.problems?.map(x => `${x.text}${x.source ? ` [${x.source}]` : ''}`).join(' ') ?? ''
127 return `- ${p.phase} phase [${phaseWords(p)}]: ${pr?.result ?? 'unknown'}${problems ? ` Problems: ${problems}` : ''}`.slice(0, CAP_EACH)
128 })
129 const final = readableResult(n.result).slice(0, CAP_FINAL)
130 return [task, '', `Part reports, one per phase (${parts.length}):`, capLines(parts), ...(final ? ['', `Final result: ${final}`] : [])].join('\n')
131 }
132 return [task, '', 'Report:', (n.result ?? '').slice(0, CAP_ALL)].join('\n')
133}
134
135// ── facts from records: what changed and the totals, never from the model ────
136export type Facts = {
137 files: FileRow[]
138 added: number
139 removed: number
140 agents: number
141 phases: number
142 ms: number
143 tokens: number
144}
145
146export function reportFacts(nodes: AgentNode[], scope: { kind: 'node'; id: string } | { kind: 'phase'; wfId: string; phase: string }, now: number): Facts {
147 const list = scopeNodes(nodes, scope)
148 const agents = list.filter(n => n.kind === 'agent')
149 const files = fileRows(list)
150 const root = scope.kind === 'node' ? nodes.find(n => n.id === scope.id) : undefined
151 const starts = list.map(n => n.startedAt).filter(Number.isFinite)
152 const ends = list.map(n => n.endedAt ?? now)
153 return {
154 files,
155 added: files.reduce((a, f) => a + f.added, 0),
156 removed: files.reduce((a, f) => a + f.removed, 0),
157 agents: agents.length,
158 phases: root?.kind === 'workflow' ? pipeline(root, nodes).length : 0,
159 ms: starts.length ? Math.max(0, Math.max(...ends) - Math.min(...starts)) : 0,
160 tokens: agents.reduce((a, n) => a + (n.tokens ?? 0), 0),
161 }
162}
163
164export const kTokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k` : String(n))
165hooks/trace.ts 418 lines1import type { AgentNode, TraceLane, TraceRow, TraceZoom } from '../types'
2import { describeTool, editedPath } from './list'
3
4// The trail behind a run: what each agent did, in order, and how the agents
5// handed work to each other. Read from transcripts when a trace opens; pure here.
6
7export type TraceKind = 'spawn' | 'tool' | 'edit' | 'message' | 'handback' | 'report' | 'fail' | 'quiet' | 'note' | 'step'
8
9export type TraceEvent = {
10 agentId: string
11 at: number
12 kind: TraceKind
13 // the tool's name for a tool call, else the kind
14 name: string
15 summary: string
16 input?: string
17 output?: string
18 tokens?: number
19 // a spawn's child (its agent id when the result names it, else its description), a message's recipient
20 to?: string
21 // a message's sender, when it arrived from another agent or session
22 from?: string
23 path?: string
24 added?: number
25 removed?: number
26 error?: boolean
27}
28
29export const ZOOMS: TraceZoom[] = ['story', 'steps', 'raw']
30export const ZOOM_NAMES: Record<TraceZoom, string> = { story: 'Story', steps: 'Steps', raw: 'Raw' }
31export const QUIET_GAP = 120_000
32const EXCERPT = 160
33
34const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
35export const excerpt = (s: unknown, n = EXCERPT): string | undefined => {
36 if (typeof s !== 'string') return undefined
37 const t = oneLine(s)
38 if (!t) return undefined
39 return t.length > n ? `${t.slice(0, n - 1)}…` : t
40}
41const baseName = (p: string) => p.split('/').filter(Boolean).pop() ?? p
42const lines = (s: unknown) => (typeof s === 'string' && s.length ? s.split('\n').length : 0)
43
44// The text of a tool result: a string, or text blocks.
45function resultText(content: unknown): string {
46 if (typeof content === 'string') return content
47 if (Array.isArray(content)) return content.map(b => (b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string' ? (b as { text: string }).text : '')).join('\n')
48 return ''
49}
50
51// The few input fields a person reads, not the whole input.
52function inputText(name: string, input: Record<string, unknown>): string | undefined {
53 const s = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
54 if (name === 'Bash') return excerpt(s('command'))
55 if (name === 'Read' || name === 'Write' || name === 'Edit' || name === 'MultiEdit') return excerpt(s('file_path'))
56 if (name === 'Grep' || name === 'Glob') return excerpt([s('pattern'), s('path')].filter(Boolean).join(' in '))
57 if (name === 'WebFetch') return excerpt(s('url'))
58 if (name === 'WebSearch') return excerpt(s('query'))
59 if (name === 'Agent' || name === 'Task') return excerpt(s('prompt'))
60 if (name === 'SendMessage') return excerpt(s('message') || s('content'))
61 if (name === 'SubagentHandback') return excerpt(s('message'))
62 const first = Object.entries(input).find(([, v]) => typeof v === 'string')
63 return first ? excerpt(String(first[1])) : undefined
64}
65
66// What a message that arrived says: who sent it and its first words.
67const INCOMING = /<(cross-session-message|agent-message)\b([^>]*)>([\s\S]*?)(?:<\/\1>|$)/g
68function incoming(text: string): { from: string; body: string }[] {
69 const out: { from: string; body: string }[] = []
70 for (const m of text.matchAll(INCOMING)) {
71 const attrs = m[2] ?? ''
72 const from = /from-name="([^"]*)"/.exec(attrs)?.[1] ?? /from="([^"]*)"/.exec(attrs)?.[1] ?? 'another session'
73 out.push({ from, body: m[3] ?? '' })
74 }
75 return out
76}
77
78type Row = { type?: string; timestamp?: string; message?: { role?: string; content?: unknown; usage?: Record<string, unknown> } }
79type Block = { type?: string; id?: string; name?: string; input?: Record<string, unknown>; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean }
80
81// A parse that can take a transcript in pieces: whole lines in, events out.
82export type Parse = { agentId: string; events: TraceEvent[]; pending: Map<string, number>; lastText?: { at: number; text: string }; handedBack: boolean; sawTask: boolean; lastAt: number; beforeTask: boolean }
83
84export const newParse = (agentId: string): Parse => ({ agentId, events: [], pending: new Map(), handedBack: false, sawTask: false, lastAt: 0, beforeTask: false })
85
86export function parseLines(p: Parse, text: string): Parse {
87 for (const line of text.split('\n')) {
88 if (!line.trim()) continue
89 let row: Row
90 try { row = JSON.parse(line) as Row } catch { continue }
91 const at = row.timestamp ? Date.parse(row.timestamp) : p.lastAt
92 if (Number.isFinite(at) && at > 0) p.lastAt = at
93 const content = row.message?.content
94 // A fork's transcript opens with the parent's own call that started it; its
95 // work begins at the first text it is given (the directive).
96 if (row.type === 'fork-context-ref') { p.beforeTask = true; continue }
97 if (p.beforeTask) {
98 const text = row.type === 'user' && (typeof content === 'string' || (Array.isArray(content) && (content as Block[]).some(b => b.type === 'text')))
99 if (text) { p.beforeTask = false; p.sawTask = true }
100 continue
101 }
102 if (row.type === 'assistant' && Array.isArray(content)) {
103 const u = row.message?.usage ?? {}
104 const num = (k: string) => (typeof u[k] === 'number' ? (u[k] as number) : 0)
105 let tokens: number | undefined = num('input_tokens') + num('cache_creation_input_tokens') + num('output_tokens') || undefined
106 for (const b of content as Block[]) {
107 if (b.type === 'text' && b.text?.trim()) p.lastText = { at: p.lastAt, text: b.text }
108 if (b.type !== 'tool_use' || !b.name) continue
109 const ev = toolEvent(p.agentId, p.lastAt, b.name, b.input ?? {})
110 if (tokens) { ev.tokens = tokens; tokens = undefined }
111 if (ev.kind === 'handback') p.handedBack = true
112 if (b.id) p.pending.set(b.id, p.events.length)
113 p.events.push(ev)
114 }
115 } else if (row.type === 'user') {
116 const blocks: Block[] = typeof content === 'string' ? [{ type: 'text', text: content }] : Array.isArray(content) ? (content as Block[]) : []
117 for (const b of blocks) {
118 if (b.type === 'tool_result' && b.tool_use_id) {
119 const i = p.pending.get(b.tool_use_id)
120 if (i === undefined) continue
121 p.pending.delete(b.tool_use_id)
122 const ev = p.events[i]!
123 const out = resultText(b.content)
124 const o = excerpt(out)
125 if (o) ev.output = o
126 if (b.is_error) ev.error = true
127 // a background spawn's result names the agent it started
128 const child = ev.kind === 'spawn' ? /agentId:\s*([a-z0-9]{8,})/i.exec(out)?.[1] : undefined
129 if (child) ev.to = child
130 } else if (b.type === 'text' && b.text) {
131 // the first user text is the task this agent was given, not a message
132 if (!p.sawTask) { p.sawTask = true; continue }
133 for (const m of incoming(b.text)) {
134 p.events.push({ agentId: p.agentId, at: p.lastAt, kind: 'message', name: 'message', summary: excerpt(m.body, 80) ?? 'a message', from: m.from, ...(excerpt(m.body) ? { input: excerpt(m.body) } : {}) })
135 }
136 }
137 }
138 }
139 }
140 return p
141}
142
143function toolEvent(agentId: string, at: number, name: string, input: Record<string, unknown>): TraceEvent {
144 const s = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
145 const base = { agentId, at, name, ...(inputText(name, input) ? { input: inputText(name, input) } : {}) }
146 if (name === 'Agent' || name === 'Task') return { ...base, kind: 'spawn', summary: `started ${s('description') || s('name') || 'a subagent'}`, to: s('description') || s('name') }
147 if (name === 'SendMessage') return { ...base, kind: 'message', summary: excerpt(s('summary') || s('message') || s('content'), 80) ?? 'a message', to: s('to') || s('recipient') }
148 if (name === 'SubagentHandback') return { ...base, kind: 'handback', summary: excerpt(s('message').split('\n').find(l => l.trim()) ?? 'its report', 80) ?? 'its report' }
149 const path = editedPath(name, input)
150 if (path) {
151 const added = name === 'Write' ? lines(input.content) : lines(input.new_string)
152 const removed = name === 'Write' ? 0 : lines(input.old_string)
153 return { ...base, kind: 'edit', summary: `${name === 'Write' ? 'wrote' : 'edited'} ${baseName(path)}`, path, added, removed }
154 }
155 return { ...base, kind: 'tool', summary: describeTool(name, input).activity }
156}
157
158// The end of a parse: an agent that wrote a last text and handed nothing back reported that text.
159export function finishParse(p: Parse): TraceEvent[] {
160 const out = [...p.events]
161 if (!p.handedBack && p.lastText) {
162 out.push({ agentId: p.agentId, at: p.lastText.at, kind: 'report', name: 'report', summary: excerpt(p.lastText.text.split('\n').find(l => l.trim()) ?? '', 80) ?? 'its report', ...(excerpt(p.lastText.text) ? { input: excerpt(p.lastText.text) } : {}) })
163 }
164 return out
165}
166
167export const parseTranscript = (text: string, agentId: string): TraceEvent[] => finishParse(parseLines(newParse(agentId), text))
168
169// ── zoom: steps group runs of like tool calls; a story keeps a few beats ──────
170
171type Cls = 'read' | 'search' | 'run' | 'web' | 'other'
172const clsOf = (e: TraceEvent): Cls => {
173 if (e.name === 'Read' || e.name === 'LS') return 'read'
174 if (e.name === 'Grep' || e.name === 'Glob' || e.name === 'ToolSearch' || e.name === 'LSP') return 'search'
175 if (e.name === 'Bash' || e.name === 'Monitor') return 'run'
176 if (e.name === 'WebFetch' || e.name === 'WebSearch' || e.name.startsWith('mcp__')) return 'web'
177 return 'other'
178}
179const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
180// the folder most of the paths share, for "read 6 files in src/auth"
181function commonDir(paths: string[]): string {
182 const parts = paths.map(p => p.split('/').filter(Boolean).slice(0, -1))
183 if (!parts.length) return ''
184 let n = 0
185 while (parts.every(ps => ps[n] !== undefined && ps[n] === parts[0]![n])) n++
186 const dir = parts[0]!.slice(0, n)
187 return dir.slice(-2).join('/')
188}
189function groupText(cls: Cls, list: TraceEvent[]): string {
190 if (cls === 'read') {
191 const dir = commonDir(list.map(e => e.input ?? ''))
192 return `read ${plural(list.length, 'file')}${dir ? ` in ${dir}` : ''}`
193 }
194 if (cls === 'search') return `searched ${plural(list.length, 'time')}`
195 if (cls === 'run') return `ran ${plural(list.length, 'command')}`
196 if (cls === 'web') return plural(list.length, 'web call')
197 return `used ${plural(list.length, 'tool')}`
198}
199
200export type Step = TraceEvent & { count?: number; end?: number; error?: boolean }
201
202// Steps: consecutive tool calls of one kind become one row; edits to one file become one row.
203export function stepsOf(events: TraceEvent[]): Step[] {
204 const out: Step[] = []
205 for (const e of events) {
206 const last = out[out.length - 1]
207 if (last && last.agentId === e.agentId && e.kind === 'tool' && last.kind === 'tool' && clsOf(last) === clsOf(e)) {
208 const members = [...((last as Step & { members?: TraceEvent[] }).members ?? [last]), e]
209 Object.assign(last, { count: members.length, end: e.at, summary: groupText(clsOf(e), members), members, error: last.error || e.error,
210 tokens: (last.tokens ?? 0) + (e.tokens ?? 0) || undefined, output: e.output ?? last.output })
211 continue
212 }
213 if (last && last.agentId === e.agentId && e.kind === 'edit' && last.kind === 'edit' && last.path === e.path) {
214 const count = (last.count ?? 1) + 1
215 Object.assign(last, { count, end: e.at, summary: `${last.summary.split(' ×')[0]} ×${count}`, added: (last.added ?? 0) + (e.added ?? 0), removed: (last.removed ?? 0) + (e.removed ?? 0),
216 tokens: (last.tokens ?? 0) + (e.tokens ?? 0) || undefined })
217 continue
218 }
219 out.push({ ...e })
220 }
221 return out.map(({ ...s }) => { delete (s as { members?: unknown }).members; return s })
222}
223
224// A story: per agent, its own beats (spawns, edits, messages, hand-backs, reports)
225// stay, and its tool work folds into at most a few beats between them.
226export function storyOf(events: TraceEvent[], maxBeats = 8): Step[] {
227 const steps = stepsOf(events)
228 const byAgent = new Map<string, Step[]>()
229 for (const s of steps) byAgent.set(s.agentId, [...(byAgent.get(s.agentId) ?? []), s])
230 const out: Step[] = []
231 for (const agentId of [...new Set(steps.map(s => s.agentId))]) {
232 const list: Step[] = byAgent.get(agentId) ?? []
233 const keep = list.filter((s: Step) => s.kind !== 'tool')
234 const room = Math.max(3, maxBeats - keep.length)
235 // tool steps fold into `room` buckets of neighbours
236 const tools: Step[] = list.filter((s: Step) => s.kind === 'tool')
237 const size = Math.max(1, Math.ceil(tools.length / room))
238 const buckets: Step[] = []
239 for (let i = 0; i < tools.length; i += size) {
240 const part = tools.slice(i, i + size)
241 if (part.length === 1) { buckets.push(part[0]!); continue }
242 // a step keeps its first call's tool name, and steps group one class each
243 const tally = (c: Cls) => part.filter(t => clsOf(t) === c).reduce((a, t) => a + (t.count ?? 1), 0)
244 const bits = [
245 tally('read') ? `read ${plural(tally('read'), 'file')}` : '',
246 tally('search') ? `searched ${plural(tally('search'), 'time')}` : '',
247 tally('run') ? `ran ${plural(tally('run'), 'command')}` : '',
248 tally('web') ? plural(tally('web'), 'web call') : '',
249 tally('other') ? `used ${plural(tally('other'), 'other tool')}` : '',
250 ].filter(Boolean)
251 buckets.push({ ...part[0]!, summary: bits.join(', ') || `used ${plural(part.length, 'tool')}`, count: part.reduce((a, t) => a + (t.count ?? 1), 0), end: part[part.length - 1]!.end ?? part[part.length - 1]!.at,
252 error: part.some(t => t.error), tokens: part.reduce((a, t) => a + (t.tokens ?? 0), 0) || undefined })
253 }
254 out.push(...[...keep, ...buckets].sort((a, b) => a.at - b.at))
255 }
256 return out.sort((a, b) => a.at - b.at)
257}
258
259export const zoomed = (events: TraceEvent[], zoom: TraceZoom): Step[] => (zoom === 'raw' ? events.map(e => ({ ...e })) : zoom === 'steps' ? stepsOf(events) : storyOf(events))
260
261// ── lanes: one per agent, the main session first; rows in time order ─────────
262
263// Two letters a lane is known by: "review:bugs" → RB, "Fix cart totals" → FC.
264export function chipOf(label: string): string {
265 const words = label.split(/[^A-Za-z0-9]+/).filter(Boolean)
266 if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase()
267 return (words[0] ?? label).slice(0, 2).toUpperCase().padEnd(2, ' ')
268}
269
270export const MAIN = 'main'
271
272// Builds a run's trace: its lanes, and its rows at a zoom.
273// `events` holds what each agent's transcript gave; an agent missing from it has no
274// transcript on disk, and its lane is built from what its node holds.
275// `stories`: agents whose steps a model wrote; at the story zoom those steps stand in for
276// their tool calls, spread over the time the agent ran, between its real edits and hand-backs.
277export function buildTrace(run: AgentNode, scope: AgentNode[], events: Map<string, TraceEvent[]>, now: number, zoom: TraceZoom, stories?: Map<string, string[]>): { lanes: TraceLane[]; rows: TraceRow[]; missing: number } {
278 const agents = scope.filter(n => n.kind === 'agent').sort((a, b) => a.startedAt - b.startedAt)
279 const isAgent = new Set(agents.map(a => a.id))
280 const parentLane = (n: AgentNode) => (n.parentId && isAgent.has(n.parentId) ? n.parentId : MAIN)
281 const hasMain = agents.some(a => parentLane(a) === MAIN)
282 const laneIds = [...(hasMain ? [MAIN] : []), ...agents.map(a => a.id)]
283 const laneOf = new Map(laneIds.map((id, i) => [id, i]))
284 const all: TraceEvent[] = []
285 let missing = 0
286 // copies: linking a spawn to its child's lane must not change what the cache holds
287 const copies = new Map<string, TraceEvent[]>()
288 for (const a of agents) { const own = events.get(a.id); if (own) copies.set(a.id, own.map(e => ({ ...e }))) }
289 // which spawn events of a parent are linked to a child lane already
290 const linked = new Set<TraceEvent>()
291 for (const a of agents) {
292 const own = copies.get(a.id)
293 const parent = parentLane(a)
294 // the arrow into this lane: from its parent's own spawn call when that is in a transcript, else from the main session
295 const parentEvents = parent === MAIN ? undefined : copies.get(parent)
296 const spawn = parentEvents?.find(e => e.kind === 'spawn' && !linked.has(e) && (e.to === a.id || e.to === a.label))
297 if (spawn) { linked.add(spawn); spawn.to = a.id }
298 else all.push({ agentId: parent, at: a.startedAt, kind: 'spawn', name: 'spawn', summary: `started ${a.label}`, to: a.id, ...(excerpt(a.prompt) ? { input: excerpt(a.prompt) } : {}) })
299 // a story outlives its transcript: its steps show even when the file is gone
300 const story = zoom === 'story' ? stories?.get(a.id) : undefined
301 if (story?.length) {
302 const t0 = a.startedAt
303 const t1 = Math.max(t0 + story.length, a.endedAt ?? now)
304 story.forEach((text, i) => all.push({ agentId: a.id, at: Math.round(t0 + ((t1 - t0) * (i + 0.5)) / story.length), kind: 'step', name: 'step', summary: text }))
305 }
306 if (own && story?.length) {
307 all.push(...own.filter(e => e.kind !== 'tool'))
308 } else if (own) {
309 all.push(...own)
310 } else {
311 missing++
312 all.push({ agentId: a.id, at: a.startedAt, kind: 'note', name: 'note', summary: 'transcript no longer on disk' })
313 const said = a.report?.result ?? a.summary ?? a.result
314 if (said && a.status !== 'running') all.push({ agentId: a.id, at: a.endedAt ?? a.startedAt, kind: 'report', name: 'report', summary: excerpt(said, 80) ?? said, ...(excerpt(said) ? { input: excerpt(said) } : {}) })
315 }
316 if (a.status === 'failed' || a.status === 'killed') {
317 all.push({ agentId: a.id, at: a.endedAt ?? now, kind: 'fail', name: 'fail', summary: a.status === 'failed' ? 'failed' : 'stopped' })
318 }
319 }
320 // a hand-back or a last report goes to the lane that started the agent
321 for (const e of all) {
322 if ((e.kind === 'handback' || e.kind === 'report') && laneOf.has(e.agentId)) {
323 const a = agents.find(x => x.id === e.agentId)
324 if (a) e.to = parentLane(a)
325 }
326 // a spawn the parent made that matches no lane stays a plain row
327 if (e.kind === 'spawn' && e.to && !laneOf.has(e.to)) e.to = undefined
328 }
329 all.sort((a, b) => a.at - b.at)
330 const steps = zoomed(all, zoom)
331 // quiet gaps: a lane that called nothing for two minutes or more, while it ran
332 const withQuiet: Step[] = []
333 const lastAt = new Map<string, number>()
334 for (const s of steps) {
335 const prev = lastAt.get(s.agentId)
336 if (prev !== undefined && s.at - prev >= QUIET_GAP && s.agentId !== MAIN) {
337 withQuiet.push({ agentId: s.agentId, at: prev, kind: 'quiet', name: 'quiet', summary: `quiet ${mins(s.at - prev)}` })
338 }
339 lastAt.set(s.agentId, s.end ?? s.at)
340 withQuiet.push(s)
341 }
342 for (const a of agents) {
343 const prev = lastAt.get(a.id)
344 if (a.status === 'running' && prev !== undefined && now - prev >= QUIET_GAP) {
345 withQuiet.push({ agentId: a.id, at: prev, kind: 'quiet', name: 'quiet', summary: `quiet ${mins(now - prev)} so far` })
346 }
347 }
348 withQuiet.sort((a, b) => a.at - b.at)
349 const rows: TraceRow[] = withQuiet.map(s => compact(s, laneOf))
350 const lanes: TraceLane[] = laneIds.map((id, i) => {
351 const a = agents.find(x => x.id === id)
352 const idx = rows.flatMap((r, k) => (r.lane === i || r.to === i ? [k] : []))
353 return {
354 id, label: a ? a.label : 'Main session', chip: a ? chipOf(a.label) : 'MS',
355 status: a ? a.status : run.status === 'running' ? 'running' : 'done',
356 from: idx.length ? Math.min(...idx) : 0, to: idx.length ? Math.max(...idx) : 0,
357 }
358 })
359 return { lanes, rows, missing }
360}
361
362const mins = (ms: number) => (ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`)
363
364// A row as the view gets it: short keys, nothing undefined.
365function compact(s: Step, laneOf: Map<string, number>): TraceRow {
366 const r: TraceRow = { kind: s.kind, lane: laneOf.get(s.agentId) ?? 0, at: s.at, text: s.summary }
367 if (s.to !== undefined && laneOf.has(s.to)) r.to = laneOf.get(s.to)!
368 else if (s.to) r.target = s.to
369 if (s.from) r.from = s.from
370 if (s.count && s.count > 1) r.count = s.count
371 if (s.end && s.end !== s.at) r.end = s.end
372 if (s.input) r.input = s.input
373 if (s.output) r.output = s.output
374 if (s.tokens) r.tokens = s.tokens
375 if (s.path) r.path = s.path
376 if (s.added !== undefined && s.kind === 'edit') r.added = s.added
377 if (s.removed !== undefined && s.kind === 'edit') r.removed = s.removed
378 if (s.error) r.error = true
379 if (s.kind === 'tool' || s.kind === 'edit') r.name = s.name
380 return r
381}
382
383export const TRACE_WINDOW = 160
384
385// The rows the view gets: a window around where it reads, so props stay small.
386export function traceWindow(rows: TraceRow[], offset: number, size = TRACE_WINDOW): { offset: number; rows: TraceRow[] } {
387 const at = Math.max(0, Math.min(offset, Math.max(0, rows.length - size)))
388 return { offset: at, rows: rows.slice(at, at + size) }
389}
390
391// ── reading a trace: one lane only, the rows worth a look, and a search ──────
392
393// The rows of one lane: what it did, and the arrows into and out of it. The lanes
394// keep their places (and so their colours); their spans are measured again.
395export function onlyLane(rows: TraceRow[], lanes: TraceLane[], lane: number): { rows: TraceRow[]; lanes: TraceLane[] } {
396 const kept = rows.filter(r => r.lane === lane || r.to === lane)
397 return {
398 rows: kept,
399 lanes: lanes.map((L, i) => {
400 const idx = kept.flatMap((r, k) => (r.lane === i || r.to === i ? [k] : []))
401 return { ...L, from: idx.length ? Math.min(...idx) : 0, to: idx.length ? Math.max(...idx) : -1 }
402 }),
403 }
404}
405
406export const MARKS_KEPT = 400
407
408// Rows worth a look: a failure, a call that errored, a quiet gap.
409export const traceMarks = (rows: TraceRow[]): number[] =>
410 rows.flatMap((r, i) => (r.kind === 'fail' || r.error || r.kind === 'quiet' ? [i] : [])).slice(0, MARKS_KEPT)
411
412// Rows whose words hold the search, any case: the row's text, its tool, what went in and out.
413export function traceHits(rows: TraceRow[], query: string): number[] {
414 const q = query.trim().toLowerCase()
415 if (!q) return []
416 return rows.flatMap((r, i) => ([r.text, r.name, r.input, r.output, r.path, r.from, r.target].some(t => t?.toLowerCase().includes(q)) ? [i] : [])).slice(0, MARKS_KEPT)
417}
418hooks/ai.ts 235 lines1import type { AgentNode, Brief, PatternCard } from '../types'
2import { REPORT_RULES, capLines, reportOf } from './report'
3import { childrenOf, fileRows, plain, prettyModel, scopeNodes } from './list'
4import type { Step } from './trace'
5
6// The AI layer: who may write what, the prompts, what each call reads (capped, so
7// no call grows with the run or the history), and how each reply is read back.
8// Pure: the hooks make the calls, the view draws what comes back.
9
10// What the /config setting allows: nothing, Haiku only, or Haiku and Sonnet on demand.
11export type AiMode = 'off' | 'cheap' | 'full'
12export const AI_MODES: AiMode[] = ['off', 'cheap', 'full']
13export type Tier = 'haiku' | 'sonnet'
14export const aiAllows = (mode: AiMode, tier: Tier) => mode === 'full' || (mode === 'cheap' && tier === 'haiku')
15export const TIER_NAME: Record<Tier, string> = { haiku: 'Haiku', sonnet: 'Sonnet' }
16
17// The caps: one line, a whole input, how many rows a call reads.
18export const STORY_STEPS_IN = 40
19export const STORY_CAP = 3000
20export const EXPLAIN_CAP = 6000
21export const PATTERN_RUNS = 50
22export const PATTERNS_CAP = 8000
23const LINE = 300
24
25const clip = (s: string | undefined, n = LINE) => plain(s ?? '').slice(0, n)
26
27// ── story steps: what one agent did, in a few short steps ─────────────────────
28
29export const STORY_SYSTEM = [
30 'You turn the tool log of one AI agent into the few steps a person reads to follow what it did.',
31 REPORT_RULES,
32 'Write 3 to 8 steps, in the order they happened. Each step is one sentence that starts with a verb in the past tense.',
33 'Group many small calls into one step. Name files and commands as the log gives them.',
34 'Reply with JSON only, no markdown: {"steps": ["..."]}',
35].join('\n')
36
37// Each call in its own words (a shell call's own description), repeats folded, the beats
38// that are not tool calls always kept, and the rest sampled evenly: at most 40 lines.
39// A shell call with no description of its own reads as its command, not its first 40 characters.
40const said = (e: Step) => (e.kind === 'tool' && e.summary.startsWith('Running ') && e.input ? `ran ${clip(e.input, 140)}` : e.summary)
41
42export function storyLines(events: Step[], max = STORY_STEPS_IN): { lines: Step[]; dropped: number } {
43 const folded: Step[] = []
44 for (const e0 of events) {
45 const e = { ...e0, summary: said(e0) }
46 const last = folded[folded.length - 1]
47 if (last && last.kind === e.kind && last.summary === e.summary) { last.count = (last.count ?? 1) + (e.count ?? 1); last.error = last.error || e.error; continue }
48 folded.push({ ...e })
49 }
50 if (folded.length <= max) return { lines: folded, dropped: 0 }
51 const beats = folded.filter(e => e.kind !== 'tool')
52 const tools = folded.filter(e => e.kind === 'tool')
53 const room = Math.max(0, max - beats.length)
54 const every = tools.length / Math.max(1, room)
55 const kept = new Set(Array.from({ length: Math.min(room, tools.length) }, (_, i) => tools[Math.floor(i * every)]!))
56 const lines = folded.filter(e => e.kind !== 'tool' || kept.has(e)).slice(0, max)
57 return { lines, dropped: folded.length - lines.length }
58}
59
60// The agent's task, its calls in their own words (at most 40), and what it reported, capped.
61export function storyInput(n: AgentNode, events: Step[]): string {
62 const { lines, dropped } = storyLines(events)
63 const shown = lines.map(s => `- ${s.kind === 'tool' ? '' : `[${s.kind}] `}${clip(s.summary, 160)}${s.count && s.count > 1 ? ` (×${s.count})` : ''}${s.error ? ' [failed]' : ''}`)
64 const more = dropped ? [`- … ${dropped} more calls not shown`] : []
65 const said = reportOf(n)?.result ?? clip(n.result)
66 return [
67 `Agent: ${clip(n.label, 120)}`,
68 `Task: ${clip(n.prompt)}`,
69 '',
70 'Tool log:',
71 capLines([...shown, ...more], STORY_CAP),
72 ...(said ? ['', `It reported: ${clip(said)}`] : []),
73 ].join('\n')
74}
75
76const jsonOf = (raw: string): Record<string, unknown> | undefined => {
77 const json = /\{[\s\S]*\}/.exec(raw)?.[0]
78 if (!json) return undefined
79 try { return JSON.parse(json) as Record<string, unknown> } catch { return undefined }
80}
81const texts = (x: unknown, n: number, max: number): string[] =>
82 (Array.isArray(x) ? x : []).map(i => clip(typeof i === 'string' ? i : '', n)).filter(Boolean).slice(0, max)
83
84// A reply as story steps: the JSON asked for, else its bulleted or numbered lines.
85export function parseStory(raw: string): string[] | undefined {
86 const d = jsonOf(raw)
87 const fromJson = d ? texts(d.steps, 160, 8) : []
88 if (fromJson.length) return fromJson
89 const lines = raw.split('\n').map(l => /^\s*(?:[-*•]|\d+[.)])\s+(.+)$/.exec(l)?.[1]).filter((l): l is string => !!l).map(l => clip(l, 160)).slice(0, 8)
90 return lines.length ? lines : undefined
91}
92
93// ── explain this run: how the work hung together ─────────────────────────────
94
95export const EXPLAIN_SYSTEM = [
96 'You explain one finished or running piece of AI agent work to the person who asked for it.',
97 'Write in Simplified Technical English, about 60% of the way:',
98 '- One idea per sentence. At most 15 words in a sentence.',
99 '- Use active voice and plain words. Name agents, files and numbers exactly as the input gives them.',
100 '- No hedging, no filler, no praise. Use only facts in the input; write "unknown" when it does not say.',
101 '- At most 250 words in total. At most 5 items in each list.',
102 'Reply with JSON only, no markdown:',
103 '{"goal": "1 sentence: what the work set out to do",',
104 ' "who": [{"agent": "agent name", "did": "1 sentence"}],',
105 ' "connected": "1 or 2 sentences: how the parts fed each other",',
106 ' "outcome": "1 or 2 sentences: what it achieved",',
107 ' "open": ["what is still open or wrong"]}',
108 'The pane shows outcome first, as a heading. Its first sentence is the verdict: at most 12 words, with the key number.',
109].join('\n')
110
111// The run's own report, its part reports, each agent's steps or result, and what needs a look.
112export function explainInput(run: AgentNode, nodes: AgentNode[], insights: string[]): string {
113 const r = reportOf(run)
114 const scope = scopeNodes(nodes, { kind: 'node', id: run.id })
115 const agents = scope.filter(n => n.kind === 'agent').sort((a, b) => a.startedAt - b.startedAt)
116 const parts = Object.entries(run.partReports ?? {}).map(([phase, p]) => `- ${phase} phase: ${clip(p.result) || 'unknown'}${p.problems?.length ? ` Problems: ${p.problems.map(x => x.text).join(' ')}` : ''}`)
117 const agentLines = agents.map(a => {
118 const steps = a.story?.steps?.length ? a.story.steps.join(' ') : reportOf(a)?.result ?? clip(a.result) ?? ''
119 const parent = a.parentId && a.parentId !== run.id ? scope.find(p => p.id === a.parentId)?.label : undefined
120 return `- ${a.label} [${a.status}${a.phase ? `, ${a.phase} phase` : ''}${parent ? `, started by ${parent}` : ''}]: ${clip(steps) || 'no report'}`
121 })
122 const files = fileRows(scope).map(f => `${f.path} (+${f.added} −${f.removed})`)
123 return [
124 `Run: ${clip(run.label, 200)} (${run.kind}, ${run.status})`,
125 `Task: ${clip(run.prompt)}`,
126 '',
127 'Run report:',
128 capLines([
129 ...(r?.result ? [`- Result: ${clip(r.result)}`] : []),
130 ...(r?.decisions ?? []).map(d => `- Decision: ${clip(d.text)}${d.source ? ` [${d.source}]` : ''}`),
131 ...(r?.problems ?? []).map(p => `- Problem: ${clip(p.text)}${p.source ? ` [${p.source}]` : ''}`),
132 ...(r?.next ? [`- Next: ${clip(r.next)}`] : []),
133 ], 1500) || '- none',
134 ...(parts.length ? ['', 'Part reports:', capLines(parts, 1500)] : []),
135 '',
136 `Agents (${agents.length}):`,
137 capLines(agentLines, EXPLAIN_CAP - 3000),
138 ...(files.length ? ['', `Files changed: ${capLines(files.map(f => `- ${f}`), 600)}`] : []),
139 ...(insights.length ? ['', 'Needs a look:', capLines(insights.map(t => `- ${clip(t)}`), 600)] : []),
140 ].join('\n').slice(0, EXPLAIN_CAP)
141}
142
143// A reply as a brief. Fields it cannot read are left out, never undefined.
144export function parseBrief(raw: string): Omit<Brief, 'model' | 'at'> | undefined {
145 const d = jsonOf(raw)
146 if (!d) return undefined
147 const out: Omit<Brief, 'model' | 'at'> = {}
148 const goal = clip(typeof d.goal === 'string' ? d.goal : '')
149 if (goal) out.goal = goal
150 const who = (Array.isArray(d.who) ? d.who : []).flatMap(w => {
151 if (typeof w === 'string') return clip(w) ? [clip(w)] : []
152 const x = w as { agent?: unknown; did?: unknown } | null
153 const did = clip(typeof x?.did === 'string' ? x.did : '')
154 const agent = clip(typeof x?.agent === 'string' ? x.agent : '', 80)
155 return did ? [agent ? `${agent}: ${did}` : did] : []
156 }).slice(0, 5)
157 if (who.length) out.who = who
158 const connected = clip(typeof d.connected === 'string' ? d.connected : '')
159 if (connected) out.connected = connected
160 const outcome = clip(typeof d.outcome === 'string' ? d.outcome : '')
161 if (outcome) out.outcome = outcome
162 const open = texts(d.open, LINE, 5)
163 if (open.length) out.open = open
164 return Object.keys(out).length ? out : undefined
165}
166
167// ── patterns across runs ─────────────────────────────────────────────────────
168
169export const PATTERNS_SYSTEM = [
170 'You read a list of past AI agent runs and find what repeats: failures with one cause, files many runs change, costly agent types, slow phases.',
171 'Write in Simplified Technical English, about 60% of the way: one idea per sentence, at most 15 words, active voice, plain words, exact names and numbers.',
172 'Use only facts in the input. Each pattern must rest on at least 2 runs. No hedging, no filler.',
173 'Write 2 to 4 patterns. Each has a short title, one evidence sentence with numbers, one concrete thing to try, and the exact names of the runs it rests on.',
174 'Reply with JSON only, no markdown:',
175 '{"patterns": [{"title": "", "evidence": "", "try": "", "runs": ["run name"]}]}',
176].join('\n')
177
178const durationText = (ms: number) => (ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`)
179
180// One compact row per run, newest first, at most 50 runs and 8,000 characters.
181export function patternsInput(runs: AgentNode[], nodes: AgentNode[]): string {
182 const rows = runs.slice(0, PATTERN_RUNS).map(run => {
183 const scope = scopeNodes(nodes, { kind: 'node', id: run.id })
184 const agents = scope.filter(n => n.kind === 'agent')
185 const r = reportOf(run)
186 const files = fileRows(scope)
187 const models = [...new Set(agents.map(a => prettyModel(a.model)).filter(Boolean))].join('/')
188 const tokens = agents.reduce((a, n) => a + (n.tokens ?? 0), 0) || run.tokens || 0
189 const failed = agents.filter(a => a.status === 'failed' || a.status === 'killed').map(a => `${a.label} (${a.status === 'failed' ? 'failed' : 'stopped'}${reportOf(a)?.result ? `: ${clip(reportOf(a)!.result, 80)}` : ''})`)
190 return clip([
191 `- "${clip(run.label, 100)}" [${run.status}] ${run.kind}${run.kind !== 'agent' ? `, ${childrenOf(nodes, run.id).length} parts` : ''}`,
192 models && `models ${models}`,
193 tokens && `${Math.round(tokens / 1000)}k tokens`,
194 run.endedAt && `${durationText(run.endedAt - run.startedAt)}`,
195 files.length && `files ${files.slice(0, 4).map(f => f.path.split('/').pop()).join(', ')}${files.length > 4 ? ` +${files.length - 4}` : ''}`,
196 r?.result && `result: ${clip(r.result, 120)}`,
197 r?.problems?.length && `problems: ${r.problems.map(p => clip(p.text, 80)).join('; ')}`,
198 failed.length && `failed: ${failed.join('; ')}`,
199 ].filter(Boolean).join(' · '), LINE + 120)
200 })
201 const more = runs.length > PATTERN_RUNS ? [`- … ${runs.length - PATTERN_RUNS} older runs not shown`] : []
202 return [`Runs, newest first (${Math.min(runs.length, PATTERN_RUNS)} of ${runs.length}):`, capLines([...rows, ...more], PATTERNS_CAP)].join('\n')
203}
204
205// A reply as pattern cards: each card keeps only the runs it names that exist, by id.
206export function parsePatterns(raw: string, runs: AgentNode[]): PatternCard[] | undefined {
207 const d = jsonOf(raw)
208 if (!d) return undefined
209 const byLabel = new Map(runs.map(r => [r.label.trim().toLowerCase(), r]))
210 const cards = (Array.isArray(d.patterns) ? d.patterns : []).flatMap(p => {
211 const x = p as { title?: unknown; evidence?: unknown; try?: unknown; runs?: unknown } | null
212 const title = clip(typeof x?.title === 'string' ? x.title : '', 90)
213 if (!title) return []
214 const card: PatternCard = { title, runs: [] }
215 const evidence = clip(typeof x?.evidence === 'string' ? x.evidence : '')
216 if (evidence) card.evidence = evidence
217 const tryIt = clip(typeof x?.try === 'string' ? x.try : '')
218 if (tryIt) card.try = tryIt
219 for (const name of texts(x?.runs, 200, 8)) {
220 const hit = byLabel.get(name.trim().toLowerCase().replace(/^"|"$/g, ''))
221 if (hit && !card.runs.some(r => r.id === hit.id)) card.runs.push({ id: hit.id, label: hit.label })
222 }
223 return [card]
224 }).slice(0, 4)
225 return cards.length ? cards : undefined
226}
227
228// "just now", "12m ago", "3h ago", "2d ago": how old an AI-written block is.
229export function ago(ms: number): string {
230 if (ms < 60_000) return 'just now'
231 if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`
232 if (ms < 48 * 3_600_000) return `${Math.round(ms / 3_600_000)}h ago`
233 return `${Math.round(ms / 86_400_000)}d ago`
234}
235hooks/lens.ts 240 lines1import type { AgentNode } from '../types'
2import { childrenOf, counts, firstSentence, overlaps, passes, pipeline, scopeNodes, type Filter } from './list'
3
4// Four ways to look at the same runs: their shape (tree), where they stand
5// (board), where the time went (timeline), and, inside a run, the trail of what
6// each agent did and handed to the others (trace). Pure: the view draws what these return.
7export type Lens = 'tree' | 'board' | 'timeline' | 'trace'
8export const LENSES: Lens[] = ['tree', 'board', 'timeline', 'trace']
9export const LENS_NAMES: Record<Lens, string> = { tree: 'Tree', board: 'Board', timeline: 'Timeline', trace: 'Trace' }
10// The lenses a place offers: a trace needs a run open.
11export const lensesFor = (hasRun: boolean): Lens[] => (hasRun ? LENSES : LENSES.filter(l => l !== 'trace'))
12
13export type Column = 'running' | 'done' | 'failed'
14export const COLUMNS: Column[] = ['running', 'done', 'failed']
15export const columnOf = (n: Pick<AgentNode, 'status'>): Column => (n.status === 'running' ? 'running' : n.status === 'done' ? 'done' : 'failed')
16
17export type Lane = { key: string; title: string; kind: 'phase' | 'cluster' | 'workflow' | 'agent' | 'singles'; cards: AgentNode[] }
18
19const byStart = (a: AgentNode, b: AgentNode) => a.startedAt - b.startedAt
20const agentsUnder = (nodes: AgentNode[], id: string) => scopeNodes(nodes, { kind: 'node', id }).filter(n => n.kind === 'agent')
21
22// The board's swimlanes. Inside a workflow: one lane per phase. Inside a cluster:
23// one lane for its members. Inside a subagent: one lane, it and its own subagents.
24// On the run list: one lane per workflow or cluster, and one for single agents.
25export function boardLanes(run: AgentNode | undefined, nodes: AgentNode[], tops: AgentNode[], filter: Filter): Lane[] {
26 const keep = (list: AgentNode[]) => list.filter(n => passes(n, [], filter)).sort(byStart)
27 if (run) {
28 if (run.kind === 'workflow') {
29 return pipeline(run, nodes)
30 .map(p => ({ key: `${run.id}:${p.phase}`, title: p.phase, kind: 'phase' as const, cards: keep(childrenOf(nodes, run.id).filter(k => (k.phase ?? 'Agents') === p.phase).flatMap(k => agentsUnder(nodes, k.id))) }))
31 .filter(l => l.cards.length > 0)
32 }
33 const kind = run.kind === 'group' ? 'cluster' : 'agent'
34 return [{ key: run.id, title: run.label, kind, cards: keep(agentsUnder(nodes, run.id).filter(n => run.kind === 'agent' || n.id !== run.id)) }]
35 }
36 const lanes: Lane[] = []
37 const singles: AgentNode[] = []
38 for (const t of tops) {
39 if (t.kind === 'agent') { singles.push(...agentsUnder(nodes, t.id)); continue }
40 const cards = keep(agentsUnder(nodes, t.id))
41 if (cards.length) lanes.push({ key: t.id, title: t.label, kind: t.kind === 'group' ? 'cluster' : 'workflow', cards })
42 }
43 if (singles.length) lanes.push({ key: 'singles', title: 'Single agents', kind: 'singles', cards: keep(singles) })
44 return lanes
45}
46
47export type TimeRow = { kind: 'bar'; node: AgentNode } | { kind: 'label'; text: string }
48
49// The timeline's rows: on the run list one bar per run; inside a workflow its agents
50// under phase labels; inside a cluster or a subagent every agent under it.
51export function timelineRows(run: AgentNode | undefined, nodes: AgentNode[], tops: AgentNode[], filter: Filter): TimeRow[] {
52 if (!run) return tops.filter(n => passes(n, childrenOf(nodes, n.id), filter)).map(node => ({ kind: 'bar' as const, node }))
53 if (run.kind === 'workflow') {
54 const out: TimeRow[] = []
55 for (const p of pipeline(run, nodes)) {
56 const list = childrenOf(nodes, run.id).filter(k => (k.phase ?? 'Agents') === p.phase && passes(k, [], filter)).flatMap(k => agentsUnder(nodes, k.id)).sort(byStart)
57 if (!list.length) continue
58 out.push({ kind: 'label', text: p.phase })
59 for (const node of list) out.push({ kind: 'bar', node })
60 }
61 return out
62 }
63 return agentsUnder(nodes, run.id).filter(n => (run.kind === 'agent' || n.id !== run.id) && passes(n, [], filter)).sort(byStart).map(node => ({ kind: 'bar' as const, node }))
64}
65
66// The run list's timeline, opened up: each run as a heading with its agents under
67// it, newest runs first (at most `maxRuns`), so the picture shows who ran when.
68// A run that is one agent stays one bar, with no heading.
69export function listTimelineRows(nodes: AgentNode[], tops: AgentNode[], maxRuns = 8): { rows: TimeRow[]; runs: number; hidden: number } {
70 const shown = [...tops].sort((a, b) => b.startedAt - a.startedAt).slice(0, maxRuns).sort(byStart)
71 const rows: TimeRow[] = []
72 for (const top of shown) {
73 if (top.kind === 'agent' && !childrenOf(nodes, top.id).length) { rows.push({ kind: 'bar', node: top }); continue }
74 rows.push({ kind: 'label', text: top.label })
75 for (const node of agentsUnder(nodes, top.id).filter(n => n.id !== top.id || top.kind === 'agent').sort(byStart)) rows.push({ kind: 'bar', node })
76 }
77 return { rows, runs: shown.length, hidden: Math.max(0, tops.length - shown.length) }
78}
79
80// ── insights: what needs a look, each pointing at the item it names ─────────
81
82export const QUIET_MS = 120_000
83export type Insight = { kind: 'quiet' | 'overlap' | 'failed' | 'slow' | 'tokens'; text: string; target?: string; tone: 'warn' | 'bad' | 'info' }
84
85const mins = (ms: number) => (ms < 60_000 ? `${Math.max(1, Math.round(ms / 1000))}s` : `${Math.round(ms / 60_000)}m`)
86const short = (p: string) => p.split('/').filter(Boolean).pop() ?? p
87const kTok = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
88const list2 = (names: string[]) => (names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}`)
89
90// The scope is every node the view shows (a run's subtree, or the tab's runs).
91export function insights(scope: AgentNode[], all: AgentNode[], now: number): Insight[] {
92 const agents = scope.filter(n => n.kind === 'agent')
93 const out: Insight[] = []
94 for (const n of agents) {
95 if (n.status !== 'running') continue
96 const since = now - (n.lastToolAt ?? n.startedAt)
97 if (since >= QUIET_MS) out.push({ kind: 'quiet', tone: 'warn', target: n.id, text: `${n.label} made no tool call for ${mins(since)}.` })
98 }
99 for (const o of overlaps(agents)) {
100 out.push({ kind: 'overlap', tone: 'warn', target: o.by[0]!.id, text: `${o.by.length} agents edited ${short(o.path)}: ${list2(o.by.map(b => b.label))}.` })
101 }
102 const failed = agents.filter(n => n.status === 'failed')
103 if (failed.length) out.push({ kind: 'failed', tone: 'bad', target: failed[0]!.id, text: `${failed.length} agent${failed.length === 1 ? '' : 's'} failed: ${list2(failed.map(f => f.label))}.` })
104 // the slowest phase of each workflow in scope, when it took most of the time
105 for (const wf of scope.filter(n => n.kind === 'workflow')) {
106 const spans = pipeline(wf, all).map(p => {
107 const list = childrenOf(all, wf.id).filter(k => (k.phase ?? 'Agents') === p.phase)
108 const start = Math.min(...list.map(k => k.startedAt))
109 const end = Math.max(...list.map(k => k.endedAt ?? now))
110 return { phase: p.phase, ms: Math.max(0, end - start) }
111 }).filter(p => Number.isFinite(p.ms))
112 if (spans.length < 2 || spans.some(p => p.ms <= 0)) continue
113 const total = spans.reduce((a, p) => a + p.ms, 0)
114 const top = [...spans].sort((a, b) => b.ms - a.ms)[0]!
115 const share = total > 0 ? Math.round((top.ms / total) * 100) : 0
116 if (share >= 60 && total >= 10_000) out.push({ kind: 'slow', tone: 'info', target: wf.id, text: `The ${top.phase} phase took ${share}% of the time.` })
117 }
118 const spent = agents.filter(n => (n.tokens ?? 0) > 0)
119 if (spent.length >= 2) {
120 const total = spent.reduce((a, n) => a + (n.tokens ?? 0), 0)
121 const top = [...spent].sort((a, b) => (b.tokens ?? 0) - (a.tokens ?? 0))[0]!
122 out.push({ kind: 'tokens', tone: 'info', target: top.id, text: `${top.label} used ${Math.round(((top.tokens ?? 0) / total) * 100)}% of the tokens (${kTok(top.tokens ?? 0)} of ${kTok(total)}).` })
123 }
124 return out
125}
126
127// A cluster's aspects: what each member covers, with how it went.
128export const aspects = (nodes: AgentNode[], groupId: string) =>
129 childrenOf(nodes, groupId).sort(byStart).map(k => ({ node: k, text: firstSentence(k.label, 40) }))
130
131// Done of total, for a roll-up: finished (done, failed or stopped) out of all.
132export const rollup = (list: AgentNode[]) => {
133 const c = counts(list)
134 return { ...c, finished: c.total - c.running }
135}
136
137// ── where the time went: the chain that set the end time, and the idle time ──
138
139// Agents count as one after another when the later one starts within this of the earlier one's end.
140const SLACK_MS = 3000
141const endOf = (n: AgentNode, now: number) => (n.status === 'running' ? now : n.endedAt ?? now)
142
143export type CriticalPath = { ids: string[]; ms: number; span: number }
144
145// The critical path: the agent that ended last, then, going back in time, the agent
146// that ended last before it started, and so on. Shortening any of them shortens the
147// run; the others ran beside them. Needs two agents or more.
148export function criticalPath(agents: AgentNode[], now: number): CriticalPath | undefined {
149 const list = agents.filter(n => n.kind === 'agent')
150 if (list.length < 2) return undefined
151 const t0 = Math.min(...list.map(n => n.startedAt))
152 const t1 = Math.max(...list.map(n => endOf(n, now)))
153 const latest = (xs: AgentNode[]) => xs.reduce((a, b) => (endOf(b, now) > endOf(a, now) || (endOf(b, now) === endOf(a, now) && b.startedAt < a.startedAt) ? b : a))
154 const chain: AgentNode[] = [latest(list)]
155 for (;;) {
156 const cur = chain[chain.length - 1]!
157 const before = list.filter(n => !chain.includes(n) && endOf(n, now) <= cur.startedAt + SLACK_MS)
158 if (!before.length) break
159 chain.push(latest(before))
160 }
161 chain.reverse()
162 return { ids: chain.map(n => n.id), ms: chain.reduce((a, n) => a + Math.max(0, endOf(n, now) - n.startedAt), 0), span: t1 - t0 }
163}
164
165export type Gap = { from: number; to: number }
166
167// Stretches inside a run when none of its agents ran: the main session thinking,
168// or waiting for the person. Short gaps are noise and are left out.
169export function idleGaps(agents: AgentNode[], now: number): { gaps: Gap[]; ms: number } {
170 const spans = agents.filter(n => n.kind === 'agent').map(n => ({ from: n.startedAt, to: endOf(n, now) })).sort((a, b) => a.from - b.from)
171 if (spans.length < 2) return { gaps: [], ms: 0 }
172 const span = Math.max(...spans.map(s => s.to)) - spans[0]!.from
173 const least = Math.max(5000, span * 0.03)
174 const gaps: Gap[] = []
175 let reach = spans[0]!.to
176 for (const s of spans.slice(1)) {
177 if (s.from - reach >= least) gaps.push({ from: reach, to: s.from })
178 reach = Math.max(reach, s.to)
179 }
180 return { gaps, ms: gaps.reduce((a, g) => a + g.to - g.from, 0) }
181}
182
183// ── cost: tokens by model family ─────────────────────────────────────────────
184
185const FAMILIES = ['Fable', 'Opus', 'Sonnet', 'Haiku'] as const
186export const modelFamily = (m: string | undefined): string => FAMILIES.find(f => m?.toLowerCase().includes(f.toLowerCase())) ?? (m ? 'Other' : 'Unknown')
187
188// What the agents of a scope spent, per model family, most first.
189export function spendByModel(scope: AgentNode[]): { model: string; tokens: number; agents: number }[] {
190 const by = new Map<string, { model: string; tokens: number; agents: number }>()
191 for (const n of scope) {
192 if (n.kind !== 'agent' || !n.tokens) continue
193 const model = modelFamily(n.model)
194 const cur = by.get(model) ?? { model, tokens: 0, agents: 0 }
195 cur.tokens += n.tokens
196 cur.agents += 1
197 by.set(model, cur)
198 }
199 return [...by.values()].sort((a, b) => b.tokens - a.tokens)
200}
201
202// Tokens a node and everything under it spent.
203export const tokensUnder = (nodes: AgentNode[], n: AgentNode) =>
204 n.kind === 'agent' ? n.tokens ?? 0 : scopeNodes(nodes, { kind: 'node', id: n.id }).filter(k => k.kind === 'agent').reduce((a, k) => a + (k.tokens ?? 0), 0) || (n.tokens ?? 0)
205
206// ── flags: an insight, shown on the row it is about ──────────────────────────
207
208export type Flag = { tone: 'warn' | 'bad' | 'info'; text: string }
209
210// A short tag per row that needs a look: agents that edited a file another agent
211// also edited, the agent that spent the most, a workflow's slowest phase, and the
212// agent that ended last on the critical path. Keys: node ids, "phase:<wf>:<phase>".
213export function rowFlags(scope: AgentNode[], all: AgentNode[], now: number): Map<string, Flag> {
214 const out = new Map<string, Flag>()
215 const agents = scope.filter(n => n.kind === 'agent')
216 for (const o of overlaps(agents)) for (const b of o.by) if (!out.has(b.id)) out.set(b.id, { tone: 'warn', text: `⚠ shares ${short(o.path)}` })
217 const spent = agents.filter(n => (n.tokens ?? 0) > 0)
218 if (spent.length >= 3) {
219 const total = spent.reduce((a, n) => a + (n.tokens ?? 0), 0)
220 const top = [...spent].sort((a, b) => (b.tokens ?? 0) - (a.tokens ?? 0))[0]!
221 const share = Math.round(((top.tokens ?? 0) / total) * 100)
222 if (share >= 40 && !out.has(top.id)) out.set(top.id, { tone: 'info', text: `▲ ${share}% of tokens` })
223 }
224 for (const wf of scope.filter(n => n.kind === 'workflow')) {
225 const spans = pipeline(wf, all).map(p => {
226 const list = childrenOf(all, wf.id).filter(k => (k.phase ?? 'Agents') === p.phase)
227 return { phase: p.phase, ms: Math.max(...list.map(k => endOf(k, now))) - Math.min(...list.map(k => k.startedAt)) }
228 }).filter(p => Number.isFinite(p.ms) && p.ms > 0)
229 if (spans.length < 2) continue
230 const total = spans.reduce((a, p) => a + p.ms, 0)
231 const top = [...spans].sort((a, b) => b.ms - a.ms)[0]!
232 const share = Math.round((top.ms / total) * 100)
233 if (share >= 50) out.set(`phase:${wf.id}:${top.phase}`, { tone: 'info', text: `slowest · ${share}% of time` })
234 }
235 const path = criticalPath(agents, now)
236 const last = path && path.ids.length >= 2 ? path.ids[path.ids.length - 1]! : undefined
237 if (last && !out.has(last)) out.set(last, { tone: 'info', text: '◆ ended last' })
238 return out
239}
240hooks/desktop/svg.ts 333 lines1import type { NodeStatus } from '../../types'
2import type { ThemeName } from '../theme'
3import { ART, type ArtSet, type Slot } from './sprites'
4
5// Every picture the desktop view draws, as SVG source strings. An Svg element is
6// drawn as an image, where currentColor means nothing, so every colour is written
7// out from the desk tokens. Sizes are CSS pixels.
8
9// The desktop can not tell its light or dark mode to a plugin, so these tokens are
10// mid-tone hues that read on both; text and grounds stay the surface's own.
11export type DeskTokens = {
12 name: ThemeName
13 // the pixel art drawn in place of line icons; minimal has none
14 art?: ArtSet
15 // the empty states' titles, and what the empty Live scene shows (its alt text)
16 words: { emptyLive: string; emptyHistory: string; scene: string }
17 accent: string
18 ok: string
19 warn: string
20 bad: string
21 stop: string
22 muted: string
23 rule: string
24 add: string
25 del: string
26 lanes: string[]
27}
28
29export const DESK: Record<ThemeName, DeskTokens> = {
30 minimal: {
31 name: 'minimal', accent: '#3b82f6', ok: '#16a34a', warn: '#d97706', bad: '#dc2626', stop: '#6b7280',
32 muted: '#6b7280', rule: '#9ca3af', add: '#16a34a', del: '#dc2626',
33 lanes: ['#3b82f6', '#8b5cf6', '#0891b2', '#0d9488', '#ea580c', '#64748b'],
34 words: { emptyLive: 'Nothing is running right now.', emptyHistory: 'No finished runs yet.', scene: '' },
35 },
36 kitty: {
37 name: 'kitty', accent: '#e8735a', ok: '#4d9e5c', warn: '#d9893b', bad: '#d9566e', stop: '#9b7f86',
38 muted: '#9b7f86', rule: '#d8b8a8', add: '#4d9e5c', del: '#d9566e',
39 lanes: ['#e8735a', '#a874c4', '#3aa6c4', '#3ea58f', '#d9893b', '#9b7f86'],
40 art: ART.kitty,
41 words: { emptyLive: 'No cats out.', emptyHistory: 'No cats have come home yet.', scene: 'A cat asleep on a keyboard' },
42 },
43 fish: {
44 name: 'fish', accent: '#2f8fc4', ok: '#2f9e6a', warn: '#d9973b', bad: '#d9566e', stop: '#7c8fa0',
45 muted: '#7c8fa0', rule: '#a9c3d4', add: '#2f9e6a', del: '#d9566e',
46 lanes: ['#2f8fc4', '#8b6fd6', '#2aa198', '#d9893b', '#c06c9f', '#7c8fa0'],
47 art: ART.fish,
48 words: { emptyLive: 'Lines are dry.', emptyHistory: 'Nothing in the bucket yet.', scene: 'A rod propped on a dock at night, a fish asleep below' },
49 },
50 dog: {
51 name: 'dog', accent: '#c98a3c', ok: '#4d9e5c', warn: '#c99a1f', bad: '#d9566e', stop: '#9b8670',
52 muted: '#9b8670', rule: '#d8c2a8', add: '#4d9e5c', del: '#d9566e',
53 lanes: ['#c98a3c', '#6f8fd6', '#3ea58f', '#c06c9f', '#8fa62a', '#9b8670'],
54 art: ART.dog,
55 words: { emptyLive: 'No dogs out.', emptyHistory: 'No dogs are back from walks yet.', scene: 'A dog asleep by its dog house' },
56 },
57}
58
59export const statusColor = (t: DeskTokens, s: NodeStatus) =>
60 s === 'running' ? t.warn : s === 'done' ? t.ok : s === 'failed' ? t.bad : t.stop
61
62const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
63
64// ── pixel sprites ────────────────────────────────────────────────────────────
65// One rect per run of same-coloured pixels in a row, so a 16×16 sprite stays a
66// few hundred characters and the 64×40 scene stays far under the 131072 cap.
67function spriteRects(grid: readonly string[], pal: Record<string, string>, dx = 0): string {
68 let out = ''
69 grid.forEach((row, y) => {
70 let x = 0
71 while (x < row.length) {
72 const ch = row[x]!
73 if (ch === '.') { x++; continue }
74 let end = x + 1
75 while (end < row.length && row[end] === ch) end++
76 out += `<rect x="${x + dx}" y="${y}" width="${end - x}" height="1" fill="${pal[ch]}"/>`
77 x = end
78 }
79 })
80 return out
81}
82
83export const SPRITE_FOR: Record<NodeStatus, Slot> = { running: 'walk0', done: 'done', failed: 'failed', killed: 'stopped' }
84
85export function spriteSvg(art: ArtSet, name: Slot, px: number): string {
86 const grid = art.pix[name]
87 const w = grid[0]!.length
88 const h = grid.length
89 const scale = px / Math.max(w, h)
90 return `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(w * scale)}" height="${Math.round(h * scale)}" viewBox="0 0 ${w} ${h}" shape-rendering="crispEdges">${spriteRects(grid, art.pal)}</svg>`
91}
92
93// The running header sprite walks: four frames, each shown for a quarter of the
94// cycle by SMIL. It needs an interactive Svg, which the desktop draws in a frame.
95export function walkSvg(art: ArtSet, px: number): string {
96 const frames = ['walk0', 'walk1', 'walk2', 'walk3'] as const
97 const layers = frames.map((f, i) =>
98 `<g visibility="hidden">${spriteRects(art.pix[f], art.pal)}<animate attributeName="visibility" values="${frames.map((_, j) => (j === i ? 'visible' : 'hidden')).join(';')}" dur="0.6s" calcMode="discrete" repeatCount="indefinite"/></g>`)
99 return `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 16 16" shape-rendering="crispEdges">${layers.join('')}</svg>`
100}
101
102// ── minimal: one line-icon set, 16 px grid ───────────────────────────────────
103const ICON: Record<string, string> = {
104 workflow: '<circle cx="3.75" cy="4.25" r="1.75"/><circle cx="12.25" cy="4.25" r="1.75"/><circle cx="8" cy="11.75" r="1.75"/><path d="M4.8 5.7 7.1 10.2M11.2 5.7 8.9 10.2"/>',
105 group: '<rect x="2.5" y="5.5" width="8" height="8" rx="2"/><path d="M5.5 3h5.5a2 2 0 0 1 2 2v5.5"/>',
106 agent: '<circle cx="8" cy="8" r="5.25"/><circle cx="8" cy="8" r="1.6" fill="COLOR" stroke="none"/>',
107 running: '<circle cx="8" cy="8" r="5.75" opacity=".25"/><path d="M8 2.25a5.75 5.75 0 0 1 5.75 5.75"/>',
108 done: '<circle cx="8" cy="8" r="6"/><path d="m5.3 8.2 1.85 1.85L10.8 6.2"/>',
109 failed: '<circle cx="8" cy="8" r="6"/><path d="m5.9 5.9 4.2 4.2m0-4.2-4.2 4.2"/>',
110 killed: '<circle cx="8" cy="8" r="6"/><rect x="6" y="6" width="4" height="4" rx="1" fill="COLOR" stroke="none"/>',
111 quiet: '<circle cx="8" cy="8" r="6"/><path d="M6 6.5h4l-4 3.5h4"/>',
112 worktree: '<circle cx="4.5" cy="3.5" r="1.5"/><circle cx="4.5" cy="12.5" r="1.5"/><circle cx="11.5" cy="5" r="1.5"/><path d="M4.5 5v6M11.5 6.5c0 3-7 1.8-7 4.5"/>',
113 file: '<path d="M4 2.25h5l3 3v8.5H4Z"/><path d="M9 2.25v3h3"/>',
114 warn: '<path d="M8 2.5 14 13H2Z"/><path d="M8 6.5v3M8 11.2v.1"/>',
115 info: '<circle cx="8" cy="8" r="6"/><path d="M8 7.5v3.5M8 5v.1"/>',
116 empty: '<circle cx="8" cy="8" r="6" stroke-dasharray="2 2"/>',
117}
118
119export function iconSvg(name: string, color: string, px = 16): string {
120 const body = (ICON[name] ?? ICON.agent!).replace(/COLOR/g, color)
121 return `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 16 16" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`
122}
123
124// ── small charts ─────────────────────────────────────────────────────────────
125
126// A bar split by status: done, running, failed, stopped, then the empty track.
127export function statusBarSvg(t: DeskTokens, c: { done: number; running: number; failed: number; stopped: number; total: number }, w = 96, h = 6): string {
128 const total = Math.max(1, c.total)
129 const parts: [number, string][] = [[c.done, t.ok], [c.running, t.warn], [c.failed, t.bad], [c.stopped, t.stop]]
130 let x = 0
131 let rects = `<rect x="0" y="0" width="${w}" height="${h}" rx="${h / 2}" fill="${t.rule}" opacity=".35"/>`
132 for (const [n, color] of parts) {
133 if (!n) continue
134 const pw = (n / total) * w
135 rects += `<rect x="${x.toFixed(1)}" y="0" width="${pw.toFixed(1)}" height="${h}" fill="${color}"/>`
136 x += pw
137 }
138 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><clipPath id="c"><rect width="${w}" height="${h}" rx="${h / 2}"/></clipPath><g clip-path="url(#c)">${rects}</g></svg>`
139}
140
141// Lines added and removed as two proportional bars, the larger side scaled to `w`.
142export function diffBarSvg(t: DeskTokens, added: number, removed: number, max: number, w = 60, h = 6): string {
143 const m = Math.max(1, max)
144 const a = Math.max(added ? 2 : 0, Math.round((added / m) * w))
145 const r = Math.max(removed ? 2 : 0, Math.round((removed / m) * w))
146 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect x="0" y="0" width="${a}" height="${h}" rx="1" fill="${t.add}"/><rect x="${a + (a && r ? 1 : 0)}" y="0" width="${Math.min(r, w - a)}" height="${h}" rx="1" fill="${t.del}"/></svg>`
147}
148
149// Runs per day: bars from a data-derived scale, its extremes labelled.
150export function sparkSvg(t: DeskTokens, perDay: number[], w = 140, h = 34): string {
151 const max = Math.max(1, ...perDay)
152 const n = perDay.length || 1
153 const bw = (w - (n - 1) * 3) / n
154 const chartH = h - 12
155 const bars = perDay.map((v, i) => {
156 const bh = v ? Math.max(2, (v / max) * chartH) : 1
157 const isToday = i === n - 1
158 return `<rect x="${(i * (bw + 3)).toFixed(1)}" y="${(chartH - bh).toFixed(1)}" width="${bw.toFixed(1)}" height="${bh.toFixed(1)}" rx="1.5" fill="${isToday ? t.accent : t.rule}"/>`
159 }).join('')
160 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${bars}<text x="0" y="${h - 1}" font-size="9" font-family="-apple-system, sans-serif" fill="${t.muted}">7 days ago</text><text x="${w}" y="${h - 1}" font-size="9" font-family="-apple-system, sans-serif" fill="${t.muted}" text-anchor="end">today · max ${max}</text></svg>`
161}
162
163// ── the timeline: one bar per agent on a shared ruler ────────────────────────
164export type TimeBar = { label: string; start: number; end: number; status: NodeStatus; isHeader?: boolean; isSelected?: boolean; isCritical?: boolean }
165
166// `idle`: stretches when no agent ran, shaded across every row.
167export type TimelineOpts = { idle?: { from: number; to: number }[] }
168
169// Type sizes for the pictures. An Svg is the one place the pane sets its own type
170// size, so labels match the native text (about 13 px) instead of shrinking below it.
171export const PIC = { body: 13, small: 12, head: 12, rowH: 32, barH: 14, font: 'font-family="-apple-system, BlinkMacSystemFont, sans-serif"' } as const
172// About how many characters of 13 px text fit in a width.
173const fits = (px: number, size: number = PIC.body) => Math.max(6, Math.floor(px / (size * 0.56)))
174// The height a timeline of these rows draws at, for the Svg box.
175export const timelineHeight = (rows: number) => 30 + rows * PIC.rowH + 6
176
177// The pane's timeline draws its labels as native text (they follow the theme and
178// can be pressed), so its pictures are a ruler and one strip per bar.
179const QUARTERS = [0, 0.25, 0.5, 0.75, 1]
180export const STRIP_H = 24
181export function rulerSvg(t: DeskTokens, from: number, to: number, w: number): string {
182 const span = Math.max(1, to - from)
183 const pad = 4
184 const marks = QUARTERS.map(f => {
185 const tx = pad + f * (w - 2 * pad - 56)
186 return `<text x="${tx.toFixed(1)}" y="15" font-size="${PIC.small}" fill="${t.muted}" text-anchor="${f === 0 ? 'start' : f === 1 ? 'end' : 'middle'}" ${PIC.font}>${esc(clockOff(span * f))}</text>`
187 }).join('')
188 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="20" viewBox="0 0 ${w} 20">${marks}</svg>`
189}
190export function stripSvg(t: DeskTokens, b: TimeBar, from: number, to: number, w: number, idleGaps: { from: number; to: number }[] = []): string {
191 const span = Math.max(1, to - from)
192 const pad = 4
193 const plot = w - 2 * pad - 56
194 const x = (at: number) => pad + ((at - from) / span) * plot
195 const h = STRIP_H
196 const mid = h / 2
197 const grid = QUARTERS.map(f => `<line x1="${(pad + f * plot).toFixed(1)}" y1="0" x2="${(pad + f * plot).toFixed(1)}" y2="${h}" stroke="${t.rule}" stroke-opacity=".3"/>`).join('')
198 const idle = idleGaps.map(g => {
199 const x0 = x(Math.max(from, g.from))
200 const x1 = x(Math.min(to, g.to))
201 return x1 - x0 < 1 ? '' : `<rect x="${x0.toFixed(1)}" y="0" width="${(x1 - x0).toFixed(1)}" height="${h}" fill="${t.rule}" opacity=".16"/>`
202 }).join('')
203 const x0 = x(b.start)
204 const x1 = Math.max(x0 + 4, x(b.end))
205 const color = statusColor(t, b.status)
206 const crit = b.isCritical ? ` stroke="${t.accent}" stroke-width="2.5"` : ''
207 const live = b.status === 'running' ? `<rect x="${(x1 - 8).toFixed(1)}" y="${mid - PIC.barH / 2}" width="8" height="${PIC.barH}" fill="${color}" opacity=".5"/>` : ''
208 const bar = `<rect x="${x0.toFixed(1)}" y="${mid - PIC.barH / 2}" width="${(x1 - x0).toFixed(1)}" height="${PIC.barH}" rx="4" fill="${color}" opacity="${b.status === 'running' ? 0.75 : 0.92}"${crit}/>`
209 const dur = `<text x="${(x1 + 6).toFixed(1)}" y="${mid + 4}" font-size="${PIC.small}" fill="${t.muted}" ${PIC.font}>${esc(clockOff(b.end - b.start).slice(1))}</text>`
210 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${idle}${grid}${bar}${live}${dur}</svg>`
211}
212
213export function timelineSvg(t: DeskTokens, bars: TimeBar[], from: number, to: number, w: number, opts: TimelineOpts = {}): string {
214 // labels get their own column; bars never run under them
215 const LABEL = Math.min(300, Math.max(150, Math.round(w * 0.32)))
216 const DUR = 64
217 const plot = Math.max(80, w - LABEL - DUR - 12)
218 const span = Math.max(1, to - from)
219 const rowH = PIC.rowH
220 const top = 30
221 const h = timelineHeight(bars.length)
222 const F = PIC.font
223 const x = (at: number) => LABEL + ((at - from) / span) * plot
224 const ticks = [0, 0.25, 0.5, 0.75, 1].map(f => {
225 const tx = LABEL + f * plot
226 return `<line x1="${tx}" y1="${top - 6}" x2="${tx}" y2="${h}" stroke="${t.rule}" stroke-opacity=".3"/><text x="${tx}" y="16" font-size="${PIC.small}" fill="${t.muted}" text-anchor="${f === 0 ? 'start' : f === 1 ? 'end' : 'middle'}" ${F}>${esc(clockOff(span * f))}</text>`
227 }).join('')
228 const idle = (opts.idle ?? []).map(g => {
229 const x0 = x(Math.max(from, g.from))
230 const x1 = x(Math.min(to, g.to))
231 return x1 - x0 < 1 ? '' : `<rect x="${x0.toFixed(1)}" y="${top - 6}" width="${(x1 - x0).toFixed(1)}" height="${h - top + 6}" fill="${t.rule}" opacity=".16"><title>idle ${esc(clockOff(g.to - g.from).slice(1))}</title></rect>`
232 }).join('')
233 const rows = bars.map((b, i) => {
234 const y = top + i * rowH
235 const mid = y + rowH / 2
236 if (b.isHeader) {
237 // a run or phase heading: full width, with a rule above it
238 return `<line x1="0" y1="${y + 2}" x2="${w}" y2="${y + 2}" stroke="${t.rule}" stroke-opacity=".45"/><text x="0" y="${mid + 6}" font-size="${PIC.head}" font-weight="700" fill="${t.muted}" ${F}>${esc(clip(b.label, fits(w, PIC.head)))}</text>`
239 }
240 const x0 = x(b.start)
241 const x1 = Math.max(x0 + 4, x(b.end))
242 const color = statusColor(t, b.status)
243 const sel = b.isSelected ? `<rect x="0" y="${y + 2}" width="${w}" height="${rowH - 4}" fill="${t.accent}" opacity=".14" rx="5"/>` : ''
244 const live = b.status === 'running' ? `<rect x="${x1 - 8}" y="${mid - PIC.barH / 2}" width="8" height="${PIC.barH}" fill="${color}" opacity=".5"/>` : ''
245 // the critical path: an accent outline, and the label in bold
246 const crit = b.isCritical ? ` stroke="${t.accent}" stroke-width="2.5"` : ''
247 const weight = b.isCritical ? ' font-weight="600"' : ''
248 const label = `<text x="12" y="${mid + 5}" font-size="${PIC.body}" fill="${t.muted}"${weight} ${F}>${esc(clip(b.label, fits(LABEL - 20)))}<title>${esc(b.label)}</title></text>`
249 const bar = `<rect x="${x0.toFixed(1)}" y="${mid - PIC.barH / 2}" width="${(x1 - x0).toFixed(1)}" height="${PIC.barH}" rx="4" fill="${color}" opacity="${b.status === 'running' ? 0.75 : 0.92}"${crit}/>`
250 const dur = `<text x="${(x1 + 6).toFixed(1)}" y="${mid + 4}" font-size="${PIC.small}" fill="${t.muted}" ${F}>${esc(clockOff(b.end - b.start).slice(1))}</text>`
251 return `${sel}${label}${bar}${live}${dur}`
252 }).join('')
253 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${idle}${ticks}${rows}</svg>`
254}
255
256// ── the trace: lanes on a shared time axis, arrows between them ──────────────
257export type TraceMark = { lane: number; to?: number; at: number; kind: string; text: string; isSelected?: boolean }
258
259// `focus`: the lane of the agent picked elsewhere; the other lanes draw faint.
260export function traceSvg(t: DeskTokens, lanes: { label: string; chip: string; status: NodeStatus }[], marks: TraceMark[], from: number, to: number, w: number, focus?: number): string {
261 const LABEL = Math.min(240, Math.max(140, Math.round(w * 0.28)))
262 const plot = Math.max(80, w - LABEL - 20)
263 const span = Math.max(1, to - from)
264 const laneH = 40
265 const h = 24 + lanes.length * laneH + 8
266 const x = (at: number) => LABEL + ((at - from) / span) * plot
267 const yOf = (lane: number) => 24 + lane * laneH + laneH / 2
268 const color = (lane: number) => t.lanes[lane % t.lanes.length]!
269 const ruler = [0, 0.5, 1].map(f => `<text x="${LABEL + f * plot}" y="14" font-size="${PIC.small}" fill="${t.muted}" text-anchor="${f === 0 ? 'start' : f === 1 ? 'end' : 'middle'}" font-family="-apple-system, sans-serif">${esc(clockOff(span * f))}</text>`).join('')
270 const faint = (lane: number) => (focus !== undefined && focus >= 0 && lane !== focus ? ' opacity=".3"' : '')
271 const laneRows = lanes.map((l, i) => {
272 const y = yOf(i)
273 return `<g${faint(i)}><circle cx="8" cy="${y}" r="4" fill="${statusColor(t, l.status)}"/><text x="18" y="${y + 5}" font-size="${PIC.body}" fill="${t.muted}" font-family="-apple-system, sans-serif">${esc(clip(l.label, fits(LABEL - 26)))}</text><line x1="${LABEL}" y1="${y}" x2="${LABEL + plot}" y2="${y}" stroke="${color(i)}" stroke-opacity=".35" stroke-width="2"/></g>`
274 }).join('')
275 const arrows = marks.filter(m => m.to !== undefined && m.to !== m.lane).map(m => {
276 const x0 = x(m.at)
277 const y0 = yOf(m.lane)
278 const y1 = yOf(m.to!)
279 const dash = m.kind === 'message' ? ' stroke-dasharray="4 3"' : m.kind === 'handback' ? ' stroke-dasharray="1.5 3"' : ''
280 return `<path d="M${x0.toFixed(1)} ${y0} L${x0.toFixed(1)} ${y1 + (y1 > y0 ? -5 : 5)}" stroke="${color(m.lane)}" stroke-width="1.5"${dash}/><path d="M${(x0 - 4).toFixed(1)} ${y1 + (y1 > y0 ? -8 : 8)} L${x0.toFixed(1)} ${y1 + (y1 > y0 ? -2 : 2)} L${(x0 + 4).toFixed(1)} ${y1 + (y1 > y0 ? -8 : 8)}" fill="none" stroke="${color(m.lane)}" stroke-width="1.5"/>`
281 }).join('')
282 const dots = marks.map(m => {
283 const cx = x(m.at)
284 const cy = yOf(m.lane)
285 const c = m.kind === 'fail' ? t.bad : color(m.lane)
286 const ring = m.isSelected ? `<circle cx="${cx.toFixed(1)}" cy="${cy}" r="7" fill="none" stroke="${t.accent}" stroke-width="2"/>` : ''
287 const shape = m.kind === 'edit'
288 ? `<rect x="${(cx - 4).toFixed(1)}" y="${cy - 4}" width="8" height="8" transform="rotate(45 ${cx.toFixed(1)} ${cy})" fill="${c}"/>`
289 : m.kind === 'fail'
290 ? `<path d="M${(cx - 4).toFixed(1)} ${cy - 4}l8 8m0-8-8 8" stroke="${c}" stroke-width="2"/>`
291 : m.kind === 'quiet'
292 ? `<rect x="${(cx - 6).toFixed(1)}" y="${cy - 3}" width="12" height="6" fill="${t.rule}" opacity=".6"/>`
293 : `<circle cx="${cx.toFixed(1)}" cy="${cy}" r="3.5" fill="${c}"/>`
294 return `<g${faint(m.lane)}>${ring}${shape}<title>${esc(m.text)}</title></g>`
295 }).join('')
296 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${ruler}${laneRows}${arrows}${dots}</svg>`
297}
298
299// ── a picture to save: a white page with a title, a subtitle and a legend over the drawing ──
300// `inner` is a whole <svg> of `innerH` rows; it is placed as a nested svg, so its own
301// coordinates stay as they are.
302export function pictureSvg(t: DeskTokens, title: string, subtitle: string, legend: string[], inner: string, w: number, innerH: number): string {
303 const PAD = 24
304 const head = 64
305 const foot = legend.length ? 28 : 8
306 const h = head + innerH + foot + PAD
307 const font = 'font-family="-apple-system, Helvetica, Arial, sans-serif"'
308 // legend items one after another, spaced by their own length (about 5.6 px a character at 11 px)
309 let lx = PAD
310 const keys = legend.map(k => {
311 const el = `<text x="${lx}" y="${head + innerH + 22}" font-size="11" fill="${t.muted}" ${font}>${esc(k)}</text>`
312 lx += Math.round(k.length * 5.6) + 28
313 return el
314 }).join('')
315 const body = inner.replace(/^<svg /, `<svg x="${PAD}" y="${head}" `)
316 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w + PAD * 2}" height="${h}" viewBox="0 0 ${w + PAD * 2} ${h}">`
317 + `<rect width="100%" height="100%" fill="#ffffff"/>`
318 + `<text x="${PAD}" y="${PAD + 12}" font-size="17" font-weight="600" fill="#111827" ${font}>${esc(clip(title, 90))}</text>`
319 + `<text x="${PAD}" y="${PAD + 32}" font-size="12" fill="${t.muted}" ${font}>${esc(clip(subtitle, 140))}</text>`
320 + `${body}${keys}</svg>`
321}
322
323// ── helpers ──────────────────────────────────────────────────────────────────
324export const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
325
326function clockOff(ms: number): string {
327 const s = Math.round(ms / 1000)
328 if (s < 60) return `+${s}s`
329 const m = Math.floor(s / 60)
330 if (m < 60) return `+${m}m${s % 60 ? ` ${String(s % 60).padStart(2, '0')}s` : ''}`
331 return `+${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
332}
333hooks/desktop/pane.tsx 919 lines1import type { AgentNode, DeskUi, EditRecord, PatternsDoc, TraceZoom, ViewProps } from '../../types'
2import { HISTORY_FILTERS, HISTORY_FILTER_NAMES, historyItems, historyStats, outcomeOf, runTokens } from '../history'
3import { aspects, boardLanes, criticalPath, idleGaps, insights, listTimelineRows, rollup, timelineRows, type Lane } from '../lens'
4import {
5 childrenOf, counts, fileRows, folderKey, joinNodes, lineDiff, noReportReason, overlaps, pipeline, prettyModel, prettyType, runKey,
6 runTree, scopeNodes, shortPaths, topItems, type Item,
7} from '../list'
8import { kTokens, reportFacts, reportOf } from '../report'
9import { ZOOMS, ZOOM_NAMES } from '../trace'
10import { unifiedHunks } from '../view'
11import {
12 BriefBlock, Chip, Empty, Heading, KindMark, ReportBlock, SectionLabel, StatusMark, STATUS_WORD, SummaryCard, aiLabel, briefMarkdown, changeTotals, clock, duration, metaLine, num,
13 mdText, outputMarkdown, tilesOf, tint, tokensOf, type Els, type Part, type Tile,
14} from './parts'
15import { STRIP_H, clip, diffBarSvg, iconSvg, rulerSvg, sparkSvg, spriteSvg, statusBarSvg, statusColor, stripSvg, traceSvg, walkSvg, type DeskTokens, type TimeBar } from './svg'
16
17// The desktop's own view of agent-tree: native controls, Svg pictures and pixel
18// cats, drawn straight into the pane (a Client's elements have no Svg). It takes
19// the same data the terminal view gets and the desk state; presses call `acts`,
20// which the hooks module turns into $.state writes.
21
22export type DeskActs = {
23 set: (patch: Partial<DeskUi>) => void
24 openDiff: (path: string | null) => void
25 askTrace: (run: string | null, zoom: TraceZoom, offset: number) => void
26 /** Ask Sonnet for a run's brief, or for patterns across runs (the hooks check ai: full again). */
27 explain: (run: string) => void
28 patterns: () => void
29 /** Save a run's timeline or trace as a picture in ~/Downloads/agent-tree (the hooks say where). */
30 exportPic: (run: string, lens: 'timeline' | 'trace', zoom: TraceZoom, focus?: string) => void
31}
32
33// Below this many columns nothing fits well, so the pane says so. The desktop
34// lays panes out in character cells, as the terminal does; a cell is about 7.5 px.
35export const MIN_COLUMNS = 60
36export const WIDE_COLUMNS = 120
37const CELL_PX = 7.5
38
39export function drawDesktop(els: Els, data: ViewProps, ui: DeskUi, acts: DeskActs, columns: number) {
40 const { Box, Text } = els
41 const t = tokensOf(data.theme ?? 'minimal')
42 const now = data.at
43 if (columns < MIN_COLUMNS) {
44 return (
45 <Box flexDirection="column" alignItems="center" paddingY={2}>
46 <Text color={t.muted}>Widen the pane to at least 480 px to see your agents.</Text>
47 </Box>
48 )
49 }
50 const here = data.repo ? folderKey(data.repo) : undefined
51 const history = ui.onlyRepo && here
52 ? (() => {
53 const byId = new Map(data.history.map(n => [n.id, n]))
54 const top = (n: AgentNode) => { let x = n; while (x.parentId && byId.has(x.parentId)) x = byId.get(x.parentId)!; return x }
55 return data.history.filter(n => runKey(top(n)) === here)
56 })()
57 : data.history
58 const pool = ui.tab === 'live' ? data.nodes : history
59 const all = joinNodes(data.nodes, data.history)
60 const run = ui.run ? (pool.find(n => n.id === ui.run) ?? all.find(n => n.id === ui.run)) : undefined
61 const wide = columns >= WIDE_COLUMNS
62 const ctx: Ctx = { els, t, ui, acts, now, all, pool, wide, columns, data }
63
64 return (
65 <Box flexDirection="column" gap={1} paddingX={1} paddingY={1}>
66 {Header(ctx, history, run)}
67 {Insights(ctx, run)}
68 {run ? RunView(ctx, run) : ui.tab === 'live' ? LiveList(ctx) : HistoryPage(ctx, history)}
69 </Box>
70 )
71}
72
73type Ctx = {
74 els: Els; t: DeskTokens; ui: DeskUi; acts: DeskActs; now: number; all: AgentNode[]; pool: AgentNode[]
75 wide: boolean; columns: number; data: ViewProps
76}
77
78const openRun = (c: Ctx, id: string | null) => {
79 c.acts.set({ run: id, sel: null, view: 'overview', file: null, traceOffset: 0, confirm: '', note: '', ...(id === null && c.ui.lens === 'trace' ? { lens: 'tree' as const } : {}) })
80 if (c.ui.file) c.acts.openDiff(null)
81 if (c.ui.lens === 'trace') c.acts.askTrace(id, c.ui.zoom, 0)
82}
83
84// A segmented control: one Button per option, the chosen one drawn as primary.
85function Segmented<T extends string>(c: Ctx, key: string, options: readonly [T, string, string?][], current: T, pick: (v: T) => void) {
86 const { Box, Button } = c.els
87 return (
88 <Box flexDirection="row" gap={0}>
89 {options.map(([v, label, hotkey]) => (
90 <Button key={`${key}:${v}`} label={label} {...(hotkey ? { hotkey } : {})} {...(v === current ? { variant: 'primary' as const } : {})} onPress={() => pick(v)} />
91 ))}
92 </Box>
93 )
94}
95
96// ── header: what you see, where, and how it is going ──────────────────────────
97function Header(c: Ctx, history: AgentNode[], run: AgentNode | undefined) {
98 const { Box, Text, Button, Input, Svg } = c.els
99 const { t, ui } = c
100 const liveTops = c.data.nodes.filter(n => !n.parentId || !c.data.nodes.some(m => m.id === n.parentId))
101 const running = liveTops.filter(n => n.status === 'running').length
102 const histRuns = history.filter(n => !n.parentId || !history.some(m => m.id === n.parentId)).length
103 const cnt = counts(c.pool.filter(n => n.kind === 'agent'))
104 // Board last: it is the view used least
105 const lenses: [DeskUi['lens'], string][] = [['tree', 'Tree'], ['timeline', 'Timeline'], ...(run ? [['trace', 'Trace'] as [DeskUi['lens'], string]] : []), ['board', 'Board']]
106 // a count draws in its colour only when it is not zero, so the eye lands on what is there
107 const tally = (n: number, word: string, color: string) => <Text color={n ? color : t.muted} {...(n ? { bold: true } : {})}>{n} {word}</Text>
108 const lensBar = Segmented(c, 'lens', lenses, ui.lens, v => {
109 c.acts.set({ lens: v, traceOffset: 0 })
110 c.acts.askTrace(v === 'trace' && run ? run.id : null, ui.zoom, 0)
111 })
112 // a run is open: one row of controls, so its summary card sits near the top.
113 // The run's name shows here only when the card below is about a part of it.
114 if (run) {
115 const inPart = !!ui.sel && ui.sel !== run.id
116 return (
117 <Box flexDirection="column" gap={1}>
118 <Box flexDirection="row" columnGap={2} rowGap={1} flexWrap="wrap" alignItems="center">
119 <Button key="back" plain label={`‹ ${ui.tab === 'live' ? 'Live' : 'History'}`} onPress={() => openRun(c, null)} />
120 {lensBar}
121 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
122 {inPart ? <Text color={t.muted} wrap="truncate-end">in {run.label}</Text> : null}
123 </Box>
124 {ExplainButton(c, run)}
125 </Box>
126 {ui.note ? <Text color={t.warn}>{ui.note}</Text> : null}
127 {ui.confirm === 'explain' ? ConfirmRow(c, 'explain', 'Write a new brief? It asks Sonnet again.', () => c.acts.explain(run.id)) : null}
128 </Box>
129 )
130 }
131 return (
132 <Box flexDirection="column" gap={1}>
133 {/* row 1: what you look at */}
134 <Box flexDirection="row" columnGap={2} rowGap={1} flexWrap="wrap" alignItems="center">
135 <Box flexDirection="row" gap={1} alignItems="center">
136 {t.art
137 ? <Svg source={spriteSvg(t.art, 'group', 20)} alt="Agents" width={20} height={20} />
138 : <Svg source={iconSvg('workflow', t.accent, 18)} alt="Agents" width={18} height={18} />}
139 <Text bold>Agents</Text>
140 </Box>
141 {Segmented(c, 'tab', [['live', `Live ${running}`, '1'], ['history', `History ${histRuns}`, '2']], ui.tab, v => {
142 c.acts.set({ tab: v, run: null, sel: null, file: null, view: 'overview', ...(ui.lens === 'trace' ? { lens: 'tree' as const } : {}) })
143 if (ui.lens === 'trace') c.acts.askTrace(null, ui.zoom, 0)
144 })}
145 {Segmented(c, 'scope', [['repo', 'This repo', 'r'], ['all', 'All repos']], ui.onlyRepo ? 'repo' : 'all', v => c.acts.set({ onlyRepo: v === 'repo' }))}
146 </Box>
147 {/* row 2: how you look at it */}
148 <Box flexDirection="row" columnGap={2} rowGap={1} flexWrap="wrap" alignItems="center">
149 {lensBar}
150 {ui.tab === 'history'
151 ? <Box flexGrow={1} minWidth={24}><Input key="search" placeholder="Search names and outcomes" value={ui.query} onInput={(v: string) => c.acts.set({ query: v })} onSubmit={(v: string) => c.acts.set({ query: v })} /></Box>
152 : null}
153 </Box>
154 {/* row 3: one quiet status line */}
155 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
156 {tally(cnt.running, 'running', t.warn)}<Text color={t.muted}>·</Text>
157 {tally(cnt.done, 'done', t.ok)}<Text color={t.muted}>·</Text>
158 {tally(cnt.failed + cnt.stopped, 'failed or stopped', t.bad)}<Text color={t.muted}>·</Text>
159 <Text color={t.muted}>{ui.onlyRepo && c.data.repo ? shortHome(c.data.repo) : 'all repos'}</Text><Text color={t.muted}>·</Text>
160 <Text color={c.data.ai === 'off' ? t.muted : t.accent}>AI · {c.data.ai ?? 'cheap'}</Text>
161 <Text color={t.muted}>(change in /config)</Text>
162 </Box>
163 {ui.note ? <Text color={t.warn}>{ui.note}</Text> : null}
164 </Box>
165 )
166}
167
168const shortHome = (p: string) => p.replace(/^\/Users\/[^/]+/, '~')
169
170const isFull = (c: Ctx) => c.data.ai === 'full'
171
172// Explain this run: Sonnet, on Full only; with a brief there it asks first.
173function ExplainButton(c: Ctx, run: AgentNode) {
174 const { Button } = c.els
175 const writing = !!c.data.explaining?.includes(run.id)
176 const label = writing ? 'Writing the brief…' : run.explain ? 'Regenerate brief' : 'Explain this run'
177 return (
178 <Button key="explain" label={label} hotkey="e" {...(isFull(c) ? {} : { dimColor: true })} onPress={() => {
179 if (!isFull(c)) return c.acts.set({ note: 'Explain needs ai: full in /config. It asks Sonnet, on demand.' })
180 if (writing) return
181 if (run.explain) return c.acts.set({ confirm: 'explain', note: '', view: 'overview' })
182 c.acts.set({ confirm: '', note: '', view: 'overview' })
183 c.acts.explain(run.id)
184 }} />
185 )
186}
187
188// A small confirm row for a Sonnet job that would write over what is there.
189function ConfirmRow(c: Ctx, what: 'explain' | 'patterns', question: string, go: () => void) {
190 const { Box, Text, Button } = c.els
191 return (
192 <Box flexDirection="row" gap={2} alignItems="center">
193 <Text color={c.t.warn}>{question}</Text>
194 <Button key={`${what}:yes`} label="Yes, ask Sonnet" variant="primary" onPress={() => { c.acts.set({ confirm: '' }); go() }} />
195 <Button key={`${what}:no`} plain label="Cancel" onPress={() => c.acts.set({ confirm: '' })} />
196 </Box>
197 )
198}
199
200// ── the insights strip: what needs a look, each naming the item it is about ──
201function Insights(c: Ctx, run: AgentNode | undefined) {
202 const { Box, Text, Button, Svg } = c.els
203 const { t } = c
204 if (!run && c.ui.tab === 'history') return null
205 const scope = run ? scopeNodes(c.all, { kind: 'node', id: run.id }) : c.pool
206 const list = insights(scope, c.all, c.now)
207 if (!list.length) return null
208 const go = (it: (typeof list)[number]) => {
209 if (!it.target) return
210 if (run) c.acts.set({ sel: it.target, view: it.kind === 'overlap' ? 'changes' : 'overview' })
211 else {
212 const byId = new Map(c.all.map(n => [n.id, n]))
213 let top = byId.get(it.target)
214 while (top?.parentId && byId.has(top.parentId)) top = byId.get(top.parentId)
215 if (top) c.acts.set({ run: top.id, sel: it.target, view: it.kind === 'overlap' ? 'changes' : 'overview', file: null })
216 }
217 }
218 // one insight per line; warnings and failures in a box, plain notes as a quiet line under it
219 const line = (it: (typeof list)[number], i: number) => (
220 <Box key={`ins-${i}`} flexDirection="row" gap={1} alignItems="center">
221 <Svg source={iconSvg(it.tone === 'bad' ? 'failed' : it.kind === 'quiet' ? 'quiet' : it.tone === 'info' ? 'info' : 'warn', it.tone === 'bad' ? t.bad : it.tone === 'warn' ? t.warn : t.muted, 14)} alt={it.kind} width={14} height={14} />
222 <Box flexShrink={1}><Button key={`insight:${i}`} plain {...(it.tone === 'info' ? { dimColor: true } : {})} label={it.text} onPress={() => go(it)} /></Box>
223 </Box>
224 )
225 const loud = list.map((it, i) => [it, i] as const).filter(([it]) => it.tone !== 'info')
226 const quiet = list.map((it, i) => [it, i] as const).filter(([it]) => it.tone === 'info')
227 return (
228 <Box flexDirection="column" gap={1}>
229 {loud.length
230 ? (
231 <Box flexDirection="column" backgroundColor={tint(loud.some(([it]) => it.tone === 'bad') ? t.bad : t.warn)} paddingX={2} paddingY={1}>
232 <Text bold color={loud.some(([it]) => it.tone === 'bad') ? t.bad : t.warn}>Needs a look ({loud.length})</Text>
233 {loud.map(([it, i]) => line(it, i))}
234 </Box>
235 )
236 : null}
237 {quiet.length ? <Box flexDirection="column">{quiet.map(([it, i]) => line(it, i))}</Box> : null}
238 </Box>
239 )
240}
241
242// ── Live, no run open: one card per running run ──────────────────────────────
243function LiveList(c: Ctx) {
244 const { Box } = c.els
245 const items = topItems(c.data.nodes, 'all', false, c.now).filter((it): it is Extract<Item, { kind: 'node' }> => it.kind === 'node' && it.depth === 0)
246 if (c.ui.lens === 'board') return Board(c, boardLanes(undefined, c.data.nodes, items.map(i => i.node), 'all'), undefined)
247 if (c.ui.lens === 'timeline') return Timeline(c, undefined, items.map(i => i.node))
248 if (!items.length) {
249 return Empty(c.els, c.t, c.t.words.emptyLive, 'Subagents and workflows appear here while they run, then move to History.')
250 }
251 return <Box flexDirection="column" gap={1}>{items.map(it => RunCard(c, it.node, c.data.nodes))}</Box>
252}
253
254function RunCard(c: Ctx, n: AgentNode, nodes: AgentNode[]) {
255 const { Box, Text, Button, Svg } = c.els
256 const { t } = c
257 const scope = scopeNodes(nodes, { kind: 'node', id: n.id })
258 const agents = scope.filter(k => k.kind === 'agent')
259 const r = rollup(n.kind === 'agent' ? [n] : agents)
260 const files = fileRows(scope)
261 const ch = changeTotals(files)
262 const clash = n.kind === 'group' ? overlaps(agents) : []
263 return (
264 <Box key={`card-${n.id}`} flexDirection="column" backgroundColor={tint(n.status === 'running' ? t.accent : t.rule)} paddingX={2} paddingY={1}>
265 <Box flexDirection="row" gap={1} alignItems="center">
266 {StatusMark(c.els, t, n.status)}
267 {KindMark(c.els, t, n.kind)}
268 <Button key={`run:${n.id}`} plain label={clip(n.label, 70)} onPress={() => openRun(c, n.id)} />
269 <Text color={statusColor(t, n.status)}>{STATUS_WORD[n.status]}</Text>
270 </Box>
271 <Text color={t.muted}>{metaLine(n, c.now)}{n.kind !== 'agent' ? ` · ${agents.length} agents` : ''}{files.length ? ` · +${num(ch.added)} −${num(ch.removed)}` : ''}</Text>
272 {n.kind !== 'agent'
273 ? (
274 <Box flexDirection="row" gap={1} alignItems="center">
275 <Svg source={statusBarSvg(t, { ...r, total: r.total })} alt={`${r.finished} of ${r.total} finished`} width={96} height={6} />
276 <Text color={t.muted}>{r.finished}/{r.total} done</Text>
277 </Box>
278 )
279 : null}
280 {n.kind === 'group'
281 ? <Box flexDirection="row" gap={1} flexWrap="wrap">{aspects(nodes, n.id).map(a => (
282 <Box key={`asp-${a.node.id}`} flexDirection="row" gap={1} backgroundColor={tint(t.rule, '26')} paddingX={1}>
283 {StatusMark(c.els, t, a.node.status, 12)}
284 <Text>{a.text}</Text>
285 </Box>
286 ))}</Box>
287 : null}
288 {clash.map(o => <Text key={`clash-${o.path}`} color={t.warn}>⚠ {o.path.split('/').pop()} edited by {o.by.length} members ({o.by.map(b => b.label).join(', ')})</Text>)}
289 </Box>
290 )
291}
292
293// ── History, no run open: numbers, filters, runs by day ──────────────────────
294function HistoryPage(c: Ctx, history: AgentNode[]) {
295 const { Box, Text, Button, Svg } = c.els
296 const { t, ui } = c
297 if (ui.lens === 'board') return Board(c, boardLanes(undefined, history, history.filter(n => !n.parentId || !history.some(m => m.id === n.parentId)), 'all'), undefined)
298 if (ui.lens === 'timeline') return Timeline(c, undefined, history.filter(n => !n.parentId || !history.some(m => m.id === n.parentId)).slice(0, 30))
299 const stats = historyStats(history, c.now)
300 const items = historyItems(history, c.now, { filter: ui.hfilter, query: ui.query, olderOpen: ui.olderOpen })
301 const stat = (value: string, label: string) => (
302 <Box flexDirection="column">
303 <Text bold>{value}</Text>
304 <Text color={t.muted}>{label}</Text>
305 </Box>
306 )
307 return (
308 <Box flexDirection="column" gap={1}>
309 <Box flexDirection="row" gap={4} alignItems="flex-end" flexWrap="wrap">
310 {stat(String(stats.runs), 'runs')}
311 {stat(`${stats.okPct}%`, 'finished OK')}
312 {stat(kTokens(stats.tokens), 'tokens')}
313 <Svg source={sparkSvg(t, stats.perDay)} alt={`Runs per day for 7 days: ${stats.perDay.join(', ')}`} width={140} height={34} />
314 {c.data.ai !== 'off' || c.data.aiWeek ? stat(kTokens(c.data.aiWeek ?? 0), 'AI tokens this week') : null}
315 </Box>
316 <Box flexDirection="row" gap={2} alignItems="center" flexWrap="wrap">
317 {Segmented(c, 'hfilter', HISTORY_FILTERS.map(f => [f, HISTORY_FILTER_NAMES[f].replace(/^./, ch => ch.toUpperCase())] as [typeof f, string]), ui.hfilter, v => c.acts.set({ hfilter: v }))}
318 {ui.query ? <Button key="clear-search" plain label={`Clear “${clip(ui.query, 20)}”`} onPress={() => c.acts.set({ query: '' })} /> : null}
319 </Box>
320 {!ui.query && ui.hfilter === 'all' ? Patterns(c, c.data.patterns) : null}
321 {items.length === 0
322 ? Empty(c.els, t, t.words.emptyHistory, ui.query || ui.hfilter !== 'all' ? 'Nothing matches the search or filter.' : 'Finished runs land here.')
323 : (
324 <Box flexDirection="column">
325 {items.map((it, i) => {
326 if (it.kind === 'header') {
327 return (
328 <Box key={`h-${i}`} flexDirection="row" gap={2} paddingTop={1}>
329 <Text bold>{it.text}</Text>
330 <Text color={t.muted}>{it.note ?? ''}</Text>
331 {it.isOpen === false || (it.text === 'Older')
332 ? <Button key="older" plain label={ui.olderOpen ? 'Hide' : 'Show'} onPress={() => c.acts.set({ olderOpen: !ui.olderOpen })} />
333 : null}
334 </Box>
335 )
336 }
337 if (it.kind !== 'node') return null
338 return HistoryRow(c, it.node, history)
339 })}
340 </Box>
341 )}
342 </Box>
343 )
344}
345
346// Patterns across runs: Sonnet's cards, each with its evidence, one thing to try, and its runs.
347function Patterns(c: Ctx, doc: PatternsDoc | undefined) {
348 const { Box, Text, Button, Svg } = c.els
349 const { t, ui } = c
350 if (!isFull(c) && !doc) return null
351 const busy = !!c.data.patternsBusy
352 const label = busy ? 'writing… (Sonnet)' : doc ? aiLabel(doc, c.now) : 'Sonnet looks for what repeats across your runs.'
353 const press = () => {
354 if (!isFull(c)) return c.acts.set({ note: 'Patterns need ai: full in /config. They ask Sonnet, on demand.' })
355 if (busy) return
356 if (doc) return c.acts.set({ confirm: 'patterns', note: '', patternsOpen: true })
357 c.acts.set({ confirm: '', note: '', patternsOpen: true })
358 c.acts.patterns()
359 }
360 return (
361 <Box flexDirection="column" gap={1} backgroundColor={tint(t.rule)} paddingX={2} paddingY={1}>
362 <Box flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
363 {doc ? <Button key="patterns:fold" plain label={ui.patternsOpen ? '▾' : '▸'} onPress={() => c.acts.set({ patternsOpen: !ui.patternsOpen })} /> : null}
364 <Text bold>Patterns across runs</Text>
365 <Box flexGrow={1}><Text color={t.muted}>{label}</Text></Box>
366 <Button key="patterns:go" label={busy ? 'Looking…' : doc ? 'Regenerate' : 'Find patterns'} hotkey="g" {...(isFull(c) ? {} : { dimColor: true })} onPress={press} />
367 </Box>
368 {ui.confirm === 'patterns' ? ConfirmRow(c, 'patterns', 'Look for patterns again? It asks Sonnet again.', () => c.acts.patterns()) : null}
369 {doc && ui.patternsOpen
370 ? doc.cards.map((card, i) => (
371 <Box key={`pat-${i}`} flexDirection="column" paddingLeft={1}>
372 <Box flexDirection="row" gap={1} alignItems="center">
373 <Svg source={iconSvg('warn', t.warn, 14)} alt="Pattern" width={14} height={14} />
374 <Text bold>{card.title}</Text>
375 </Box>
376 {card.evidence ? <Text color={t.muted}>{card.evidence}</Text> : null}
377 {card.try ? <Text><Text color={t.accent} bold>Try: </Text>{card.try}</Text> : null}
378 {card.runs.length
379 ? (
380 <Box flexDirection="row" gap={1} flexWrap="wrap">
381 {card.runs.map(r => <Button key={`prun:${i}:${r.id}`} plain label={`↳ ${clip(r.label, 40)}`} onPress={() => openRun(c, r.id)} />)}
382 </Box>
383 )
384 : null}
385 </Box>
386 ))
387 : null}
388 </Box>
389 )
390}
391
392function HistoryRow(c: Ctx, n: AgentNode, history: AgentNode[]) {
393 const { Box, Text, Button } = c.els
394 const { t } = c
395 const files = fileRows(scopeNodes(history, { kind: 'node', id: n.id }))
396 const ch = changeTotals(files)
397 const tok = runTokens(history, n)
398 const outcome = outcomeOf(n) || noReportReason(n)
399 return (
400 <Box key={`hr-${n.id}`} flexDirection="row" gap={1} alignItems="center">
401 {StatusMark(c.els, t, n.status)}
402 <Box width={2}>{KindMark(c.els, t, n.kind, 14)}</Box>
403 <Box width={c.wide ? 34 : 24}><Button key={`run:${n.id}`} plain label={clip(n.label, c.wide ? 34 : 24)} onPress={() => openRun(c, n.id)} /></Box>
404 <Box flexGrow={1}><Text color={t.muted} wrap="truncate-end">{outcome}</Text></Box>
405 {c.wide ? <Box width={14}><Text color={files.length ? t.add : t.muted}>{files.length ? `+${num(ch.added)} −${num(ch.removed)}` : '—'}</Text></Box> : null}
406 <Box width={8}><Text color={t.muted}>{duration((n.endedAt ?? c.now) - n.startedAt)}</Text></Box>
407 {c.wide ? <Box width={7}><Text color={t.muted}>{tok ? kTokens(tok) : ''}</Text></Box> : null}
408 </Box>
409 )
410}
411
412// ── a run: the chosen lens beside (or above) the detail of what is picked ────
413function RunView(c: Ctx, run: AgentNode) {
414 const { Box } = c.els
415 const { ui } = c
416 const nodes = c.all
417 const target = targetOf(ui.sel, nodes) ?? { kind: 'node' as const, node: run }
418 // Only the tree sits beside the detail, and only when both get room. The pictures
419 // (timeline, trace, board) need the full width, so they always sit above it.
420 const side = c.wide && ui.lens === 'tree'
421 const treeCols = Math.max(52, Math.round(c.columns * 0.4))
422 const full = c.columns - 4
423 const lens = ui.lens === 'board'
424 ? Board(c, boardLanes(run, nodes, [], 'all'), run, full)
425 : ui.lens === 'timeline'
426 ? Timeline(c, run, [], full)
427 : ui.lens === 'trace'
428 ? Trace(c, run, full)
429 : Tree(c, run)
430 const detail = Detail(c, run, target)
431 // a run that is one agent has a one-row tree that only repeats the card's title
432 const lone = ui.lens === 'tree' && run.kind === 'agent' && !childrenOf(nodes, run.id).length
433 if (lone) return detail
434 return side
435 ? (
436 <Box flexDirection="row" gap={2}>
437 <Box flexDirection="column" width={treeCols} flexShrink={0} overflow="hidden">{lens}</Box>
438 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={40}>{detail}</Box>
439 </Box>
440 )
441 : <Box flexDirection="column" gap={2}>{lens}{detail}</Box>
442}
443
444type Target = { kind: 'node'; node: AgentNode } | { kind: 'phase'; wf: AgentNode; phase: string }
445
446function targetOf(sel: string | null, nodes: AgentNode[]): Target | undefined {
447 if (!sel) return undefined
448 if (sel.startsWith('phase:')) {
449 const [, wfId, ...rest] = sel.split(':')
450 const wf = nodes.find(n => n.id === wfId)
451 return wf ? { kind: 'phase', wf, phase: rest.join(':') } : undefined
452 }
453 const node = nodes.find(n => n.id === sel)
454 return node ? { kind: 'node', node } : undefined
455}
456
457const toggle = (list: string[], key: string) => (list.includes(key) ? list.filter(k => k !== key) : [...list, key])
458
459// ── Tree: workflow → phase → agent, cluster → members, subagents under parents ─
460function Tree(c: Ctx, run: AgentNode) {
461 const { Box, Text, Button, Svg } = c.els
462 const { t, ui } = c
463 const items = runTree(run, c.all, 'all', ui.flipped, ui.flipped)
464 // the picked row gets a tinted ground; its label never has to share space with a marker
465 const pickedBg = `${t.accent}26`
466 const INDENT = 3
467 // the right-hand facts sit in one block that never shrinks, so they line up and never spill
468 const facts = (parts: unknown[]) => <Box flexShrink={0} flexDirection="row" gap={1} alignItems="center">{parts}</Box>
469 const label = (key: string, text: string, onPress: () => void) => (
470 <Box flexGrow={1} flexShrink={1} minWidth={10} overflow="hidden"><Button key={key} plain label={text} onPress={onPress} /></Box>
471 )
472 const fold = (key: string, isOpen: boolean) => (
473 <Box width={2} flexShrink={0}><Button key={`fold:${key}`} plain label={isOpen ? '▾' : '▸'} onPress={() => c.acts.set({ flipped: toggle(ui.flipped, key) })} /></Box>
474 )
475 const noFold = <Box width={2} flexShrink={0} />
476 return (
477 <Box flexDirection="column" gap={0}>
478 {items.map((it, i) => {
479 if (it.kind === 'node') {
480 const n = it.node
481 const isSel = (ui.sel ?? run.id) === n.id
482 const files = n.kind === 'agent' ? fileRows([n]) : []
483 const ch = changeTotals(files)
484 const kids = n.kind === 'agent' ? [] : scopeNodes(c.all, { kind: 'node', id: n.id }).filter(k => k.kind === 'agent')
485 const r = rollup(kids)
486 return (
487 <Box key={`tn-${n.id}`} flexDirection="row" gap={1} alignItems="center" paddingLeft={it.depth * INDENT} paddingY={0} {...(isSel ? { backgroundColor: pickedBg } : {})}>
488 {it.fold ? fold(it.fold.key, it.fold.isOpen) : noFold}
489 {StatusMark(c.els, t, n.status, 16, isQuiet(n, c.now))}
490 {KindMark(c.els, t, n.kind)}
491 {label(`sel:${n.id}`, n.label, () => c.acts.set({ sel: n.id, file: null }))}
492 {facts([
493 kids.length ? <Svg key="bar" source={statusBarSvg(t, r, 48, 5)} alt={`${r.finished} of ${r.total}`} width={48} height={5} /> : null,
494 kids.length ? <Text key="n" color={t.muted}>{r.finished}/{r.total}</Text> : null,
495 files.length ? <Text key="d" color={t.add}>+{ch.added} <Text color={t.del}>−{ch.removed}</Text></Text> : null,
496 <Box key="ms" width={8} justifyContent="flex-end"><Text color={t.muted}>{duration((n.endedAt ?? c.now) - n.startedAt)}</Text></Box>,
497 ])}
498 </Box>
499 )
500 }
501 if (it.kind === 'phase') {
502 const key = `phase:${it.wfId}:${it.phase}`
503 const isSel = ui.sel === key
504 return (
505 <Box key={`tp-${it.key}`} flexDirection="row" gap={1} alignItems="center" paddingLeft={INDENT} {...(isSel ? { backgroundColor: pickedBg } : {})}>
506 {fold(it.key, it.isOpen)}
507 {label(`sel:${key}`, `${it.phase} phase`, () => c.acts.set({ sel: key, file: null }))}
508 {facts([
509 <Svg key="bar" source={statusBarSvg(t, it, 48, 5)} alt={`${it.done} of ${it.total}`} width={48} height={5} />,
510 <Box key="n" width={8} justifyContent="flex-end"><Text color={t.muted}>{it.total - it.running}/{it.total}</Text></Box>,
511 ])}
512 </Box>
513 )
514 }
515 if (it.kind === 'more') {
516 return <Box key={`tm-${i}`} paddingLeft={INDENT * 2 + 3}><Button key={`more:${it.key}`} plain label={`Show ${it.count} more finished`} onPress={() => c.acts.set({ flipped: toggle(ui.flipped, it.key) })} /></Box>
517 }
518 if (it.kind === 'info' && it.tone === 'warn') {
519 const o = overlaps(scopeNodes(c.all, { kind: 'node', id: it.nodeId }).filter(k => k.id !== it.nodeId))[0]
520 return o ? <Box key={`tw-${i}`} paddingLeft={INDENT * 2 + 3}><Text color={t.warn}>⚠ {o.by.length} members edited {o.path.split('/').pop()}</Text></Box> : null
521 }
522 if (it.kind === 'info' && it.tone === 'outcome') {
523 const n = c.all.find(x => x.id === it.nodeId)
524 // the outcome sits under the row it belongs to, indented past the marks
525 return n ? <Box key={`to-${i}`} paddingLeft={INDENT + 7} paddingBottom={1}><Text color={t.muted}>{clip(reportOf(n)?.result ?? '', 160)}</Text></Box> : null
526 }
527 return null
528 })}
529 </Box>
530 )
531}
532
533const isQuiet = (n: AgentNode, now: number) => n.status === 'running' && now - (n.lastToolAt ?? n.startedAt) >= 120_000
534
535// ── Board: one card per lane, its agents by status, full names and outcomes ────
536// Three fixed status columns squeezed every name into a third of the width, and
537// most of them stood empty. Now status is the order and the mark: running first,
538// then failed or stopped, then done.
539const BOARD_ORDER: Record<AgentNode['status'], number> = { running: 0, failed: 1, killed: 2, done: 3 }
540const BOARD_CAP = 12
541
542function Board(c: Ctx, lanes: Lane[], run: AgentNode | undefined, cols = c.columns - 4) {
543 const { Box, Text, Button } = c.els
544 const { t, ui } = c
545 if (!lanes.length) return Empty(c.els, t, 'Nothing to put on the board.', 'Agents show up here as cards, by status.')
546 const pick = (k: AgentNode) => {
547 if (run) return c.acts.set({ sel: k.id, file: null })
548 const byId = new Map(c.all.map(n => [n.id, n]))
549 let top: AgentNode | undefined = k
550 while (top?.parentId && byId.has(top.parentId)) top = byId.get(top.parentId)
551 if (top) c.acts.set({ run: top.id, sel: k.id, file: null })
552 }
553 const summary = (cards: AgentNode[]) => {
554 const cn = counts(cards)
555 return [cn.running ? `${cn.running} running` : '', cn.failed + cn.stopped ? `${cn.failed + cn.stopped} failed or stopped` : '', cn.done ? `${cn.done} done` : ''].filter(Boolean).join(' · ')
556 }
557 // the card: how the agents stand, as a headline and tiles
558 const every = lanes.flatMap(l => l.cards)
559 const cn = counts(every)
560 const failed = cn.failed + cn.stopped
561 const headline = failed
562 ? `${failed} of ${cn.total} agents failed or stopped.`
563 : cn.running ? `${cn.running} of ${cn.total} agents still run.` : `All ${cn.total} agents finished.`
564 const color = failed ? t.bad : cn.running ? t.warn : t.ok
565 const runs = lanes.filter(l => l.kind !== 'singles').length + (lanes.find(l => l.kind === 'singles')?.cards.length ?? 0)
566 const tiles: Tile[] = [
567 { value: String(cn.running), label: 'running', ...(cn.running ? { color: t.warn } : {}) },
568 { value: String(failed), label: 'failed or stopped', ...(failed ? { color: t.bad } : {}) },
569 { value: String(cn.done), label: 'done', ...(cn.done ? { color: t.ok } : {}) },
570 run ? { value: String(lanes.length), label: lanes.length === 1 ? 'lane' : 'lanes' } : { value: String(runs), label: runs === 1 ? 'run' : 'runs' },
571 ]
572 // agents as tiles, tinted by status: two to a row when each still gets 48 columns
573 const tileW = cols >= 100 ? Math.floor((cols - 1) / 2) : undefined
574 return (
575 <Box flexDirection="column" gap={1}>
576 {SummaryCard(c.els, t, { id: 'board', statusWord: 'Board', color, title: run ? run.label : `${ui.tab === 'live' ? 'Live' : 'History'} runs`, result: headline, tiles })}
577 {!run ? <Text color={t.muted}>Each section is one run. Press an agent to open its run.</Text> : null}
578 {lanes.map(l => {
579 const cards = [...l.cards].sort((a, b) => BOARD_ORDER[a.status] - BOARD_ORDER[b.status] || a.startedAt - b.startedAt)
580 const bad = cards.some(k => k.status === 'failed' || k.status === 'killed')
581 return (
582 <Box key={`lane-${l.key}`} flexDirection="column" gap={1}>
583 <Box flexDirection="column">
584 {Heading(c.els, l.title, `h-lane-${l.key}`)}
585 <Text color={bad ? t.bad : t.muted}>{summary(cards)}</Text>
586 </Box>
587 <Box flexDirection="row" flexWrap="wrap" gap={1}>
588 {cards.slice(0, BOARD_CAP).map(k => {
589 const outcome = k.status === 'running' ? (k.activity ?? '') : (reportOf(k)?.result ?? '')
590 const isSel = ui.sel === k.id
591 return (
592 <Box key={`card-${k.id}`} flexDirection="column" backgroundColor={tint(isSel ? t.accent : statusColor(t, k.status), isSel ? '33' : '1a')} paddingX={1}
593 {...(tileW ? { width: tileW } : { flexGrow: 1 })}>
594 <Box flexDirection="row" gap={1} alignItems="center">
595 {StatusMark(c.els, t, k.status, 14, isQuiet(k, c.now))}
596 <Box flexGrow={1} flexShrink={1} minWidth={10} overflow="hidden"><Button key={`bsel:${k.id}`} plain label={k.label} onPress={() => pick(k)} /></Box>
597 <Box flexShrink={0}><Text color={t.muted}>{duration((k.endedAt ?? c.now) - k.startedAt)}</Text></Box>
598 </Box>
599 {outcome ? <Box paddingLeft={3}><Text color={t.muted}>{clip(outcome, 120)}</Text></Box> : null}
600 </Box>
601 )
602 })}
603 </Box>
604 {cards.length > BOARD_CAP ? <Text color={t.muted}>and {cards.length - BOARD_CAP} more</Text> : null}
605 </Box>
606 )
607 })}
608 </Box>
609 )
610}
611
612// ── Timeline: bars on a shared ruler ──────────────────────────────────────────
613function Timeline(c: Ctx, run: AgentNode | undefined, tops: AgentNode[], cols = c.columns - 4) {
614 const { Box, Text, Button, Svg } = c.els
615 const { t, ui } = c
616 // on the run list, each run opens into its agents (newest 8 runs), so the bars are not 4 thin lines
617 const list = run ? undefined : listTimelineRows(c.all, tops)
618 const rows = run ? timelineRows(run, c.all, tops, 'all') : list!.rows
619 // inside a run: the chain that set its end time, outlined, and the idle time, shaded
620 const agents = rows.flatMap(r => (r.kind === 'bar' ? [r.node] : []))
621 const path = run ? criticalPath(agents, c.now) : undefined
622 const idle = run ? idleGaps(agents, c.now) : { gaps: [], ms: 0 }
623 const onPath = new Set(path?.ids ?? [])
624 if (!agents.length) return Empty(c.els, t, 'No timeline yet.', 'Bars appear as agents start and finish.')
625 const from = Math.min(...agents.map(n => n.startedAt))
626 const to = Math.max(...agents.map(n => n.endedAt ?? c.now))
627 // labels are native text in their own column (they follow the theme and can be
628 // pressed); each bar is a strip picture as wide as the rest of the pane
629 const LABEL = Math.max(18, Math.min(44, Math.round(cols * 0.32)))
630 const w = Math.max(200, Math.round((cols - LABEL - 2) * CELL_PX))
631 const pick = (n: AgentNode) => {
632 if (run) return c.acts.set({ sel: n.id, file: null })
633 const byId = new Map(c.all.map(x => [x.id, x]))
634 let top: AgentNode | undefined = n
635 while (top?.parentId && byId.has(top.parentId)) top = byId.get(top.parentId)
636 if (top) c.acts.set({ run: top.id, sel: n.id, file: null })
637 }
638 // the card: how long it took and where the time went, as a headline and tiles
639 const span = to - from
640 const cn = counts(agents)
641 const failed = cn.failed + cn.stopped
642 const headline = run
643 ? path && path.ids.length
644 ? `It took ${duration(span)}. ${path.ids.length} agent${path.ids.length === 1 ? '' : 's'} on the critical path set the end time.`
645 : `It took ${duration(span)}.`
646 : `${list!.runs} run${list!.runs === 1 ? '' : 's'} and ${agents.length} agents over ${duration(span)}.`
647 const tiles: Tile[] = run
648 ? [
649 { value: duration(span), label: 'total time' },
650 ...(path ? [{ value: duration(path.ms), label: `critical path · ${path.ids.length} agent${path.ids.length === 1 ? '' : 's'}`, color: t.accent }] : []),
651 { value: duration(idle.ms), label: 'idle', ...(idle.ms && idle.ms >= span * 0.2 ? { color: t.warn } : {}) },
652 { value: String(agents.length), label: 'agents' },
653 ]
654 : [
655 { value: String(list!.runs), label: list!.hidden ? `runs · ${list!.hidden} older hidden` : 'runs' },
656 { value: String(agents.length), label: 'agents' },
657 { value: duration(span), label: 'time span' },
658 { value: String(failed), label: 'failed or stopped', ...(failed ? { color: t.bad } : {}) },
659 ]
660 const key = [run && path ? 'Outlined bars: critical path' : '', run && idle.ms ? 'shaded: idle' : '', 'press a name to open it'].filter(Boolean).join(' · ')
661 return (
662 <Box flexDirection="column" gap={1}>
663 {SummaryCard(c.els, t, { id: 'timeline', statusWord: 'Timeline', color: failed ? t.bad : cn.running ? t.warn : t.accent, title: run ? run.label : `${list!.runs} newest runs`, result: headline, tiles })}
664 <Box flexDirection="row" columnGap={2} alignItems="center" flexWrap="wrap">
665 <Box flexGrow={1} flexShrink={1}><Text color={t.muted}>{key.replace(/^./, ch => ch.toUpperCase())}.</Text></Box>
666 {run ? <Button key="save-timeline" plain label="Save picture" hotkey="x" onPress={() => c.acts.exportPic(run.id, 'timeline', ui.zoom)} /> : null}
667 </Box>
668 <Box flexDirection="column" gap={0}>
669 <Box flexDirection="row" gap={1}>
670 <Box width={LABEL} flexShrink={0} />
671 <Svg source={rulerSvg(t, from, to, w)} alt={`Timeline of ${agents.length} agents over ${duration(to - from)}`} width={w} height={20} />
672 </Box>
673 {rows.map((r, i) => {
674 if (r.kind === 'label') {
675 // a run or phase starts a section, as in the report
676 return <Box key={`tlh-${i}`} paddingTop={i ? 1 : 0}>{Heading(c.els, r.text, `h-tl-${i}`)}</Box>
677 }
678 const n = r.node
679 const isSel = ui.sel === n.id
680 const bar: TimeBar = { label: n.label, start: n.startedAt, end: n.endedAt ?? c.now, status: n.status, isCritical: onPath.has(n.id) }
681 return (
682 <Box key={`tl-${n.id}`} flexDirection="row" gap={1} alignItems="center" {...(isSel ? { backgroundColor: `${t.accent}26` } : {})}>
683 <Box width={LABEL} flexShrink={0} overflow="hidden" paddingLeft={1}>
684 <Button key={`tsel:${n.id}`} plain {...(onPath.has(n.id) || !run ? {} : { dimColor: true })} label={n.label} onPress={() => pick(n)} />
685 </Box>
686 <Svg source={stripSvg(t, bar, from, to, w, idle.gaps)} alt={`${n.label}: ${STATUS_WORD[n.status]}, ${duration(bar.end - bar.start)}`} width={w} height={STRIP_H} />
687 </Box>
688 )
689 })}
690 </Box>
691 </Box>
692 )
693}
694
695// ── Trace: lanes on a time axis, arrows for spawns, messages, hand-backs ──────
696function Trace(c: Ctx, run: AgentNode, cols: number) {
697 const { Box, Text, Button, Svg } = c.els
698 const { t, ui } = c
699 const tr = c.data.trace
700 const zoomBar = Segmented(c, 'zoom', ZOOMS.map(z => [z, ZOOM_NAMES[z]] as [TraceZoom, string]), ui.zoom, z => {
701 c.acts.set({ zoom: z, traceOffset: 0 })
702 c.acts.askTrace(run.id, z, 0)
703 })
704 if (!tr || tr.run !== run.id) {
705 return (
706 <Box flexDirection="column" gap={1}>
707 {zoomBar}
708 <Text color={t.muted}>Reading the transcripts…</Text>
709 <Button key="read-trace" plain label="Read the trail now" onPress={() => c.acts.askTrace(run.id, ui.zoom, 0)} />
710 </Box>
711 )
712 }
713 const lanes = tr.lanes.map(l => ({ label: l.label, chip: l.chip, status: l.status }))
714 const timed = tr.rows.filter(r => r.at > 0)
715 const from = timed.length ? Math.min(...timed.map(r => r.at)) : c.now
716 const to = timed.length ? Math.max(...timed.map(r => r.end ?? r.at)) : c.now
717 const w = Math.max(320, Math.round(cols * CELL_PX))
718 const marks = tr.rows.map(r => ({ lane: r.lane, ...(r.to !== undefined ? { to: r.to } : {}), at: r.at || from, kind: r.kind, text: r.text }))
719 // the agent picked in the tree or the timeline: its lane stands out, the others fade
720 const focus = ui.sel ? tr.lanes.findIndex(l => l.id === ui.sel) : -1
721 const svgH = 24 + lanes.length * 40 + 8
722 return (
723 <Box flexDirection="column" gap={1}>
724 <Box flexDirection="row" gap={2} alignItems="center">
725 {zoomBar}
726 <Text color={t.muted}>{tr.lanes.length} lanes · {tr.total} events{tr.missing ? ` · ${tr.missing} transcript${tr.missing === 1 ? '' : 's'} no longer on disk` : ''}{tr.loading ? ' · reading…' : ''}</Text>
727 {ui.zoom === 'story' && tr.storyBy ? <Text color={t.accent}>Story · {tr.storyBy}</Text> : null}
728 <Button key="save-trace" plain label="Save picture" hotkey="x" onPress={() => c.acts.exportPic(run.id, 'trace', ui.zoom, ui.sel ?? undefined)} />
729 </Box>
730 <Svg source={traceSvg(t, lanes, marks, from, to, w, focus >= 0 ? focus : undefined)} alt={`Trace of ${tr.lanes.length} agents`} width={w} height={svgH} />
731 <Box flexDirection="column">
732 {tr.rows.map((r, i) => (
733 <Box key={`tr-${tr.offset + i}`} flexDirection="row" gap={1}>
734 <Box width={8}><Text color={t.muted}>{r.at ? clock(r.at) : ''}</Text></Box>
735 {Chip(c.els, t, tr.lanes[r.lane]?.chip ?? '··', t.lanes[r.lane % t.lanes.length])}
736 <Box flexGrow={1}><Text {...(r.kind === 'fail' ? { color: t.bad } : r.kind === 'quiet' ? { color: t.muted } : {})} wrap="truncate-end">{r.kind === 'spawn' ? '↳ ' : r.kind === 'handback' ? '↩ ' : r.kind === 'message' ? '✉ ' : ''}{r.text}</Text></Box>
737 {r.kind === 'edit' && r.path
738 ? <Button key={`trdiff:${tr.offset + i}`} plain label="diff" onPress={() => { c.acts.set({ view: 'changes', file: r.path! }); c.acts.openDiff(r.path!) }} />
739 : null}
740 </Box>
741 ))}
742 </Box>
743 {tr.total > tr.rows.length
744 ? (
745 <Box flexDirection="row" gap={2}>
746 {tr.offset > 0 ? <Button key="trace-prev" plain label="‹ Earlier" onPress={() => { const o = Math.max(0, tr.offset - tr.rows.length); c.acts.set({ traceOffset: o }); c.acts.askTrace(run.id, ui.zoom, o) }} /> : null}
747 <Text color={t.muted}>{tr.offset + 1}–{tr.offset + tr.rows.length} of {tr.total}</Text>
748 {tr.offset + tr.rows.length < tr.total ? <Button key="trace-next" plain label="Later ›" onPress={() => { const o = tr.offset + tr.rows.length; c.acts.set({ traceOffset: o }); c.acts.askTrace(run.id, ui.zoom, o) }} /> : null}
749 </Box>
750 )
751 : null}
752 </Box>
753 )
754}
755
756// ── the detail of what is picked: header, then Overview, Changes or Output ───
757function Detail(c: Ctx, run: AgentNode, target: Target) {
758 const { Box, Text, Svg, Markdown, Button } = c.els
759 const { t, ui } = c
760 const scope = target.kind === 'node'
761 ? scopeNodes(c.all, { kind: 'node', id: target.node.id })
762 : scopeNodes(c.all, { kind: 'phase', wfId: target.wf.id, phase: target.phase })
763 const files = fileRows(scope)
764 const names = shortPaths(files.map(f => f.path))
765 const head = target.kind === 'node' ? target.node : undefined
766 const title = head ? head.label : `${target.kind === 'phase' ? target.phase : ''} phase`
767 const status = head ? head.status : (() => {
768 const cn = counts(scope)
769 return cn.running ? 'running' as const : cn.failed ? 'failed' as const : cn.stopped ? 'killed' as const : 'done' as const
770 })()
771 const quiet = head ? isQuiet(head, c.now) : false
772 const big = t.art
773 ? status === 'running' && !quiet
774 ? <Svg source={walkSvg(t.art, 48)} alt="Running" width={48} height={48} isInteractive />
775 : <Svg source={spriteSvg(t.art, quiet ? 'quiet' : head?.kind === 'workflow' ? 'workflow' : head?.kind === 'group' ? 'group' : status === 'done' ? 'done' : status === 'failed' ? 'failed' : 'stopped', 48)} alt={STATUS_WORD[status]} width={48} height={48} />
776 : <Svg source={iconSvg(quiet ? 'quiet' : status, quiet ? t.warn : statusColor(t, status), 28)} alt={STATUS_WORD[status]} width={28} height={28} />
777 const meta = head
778 ? [prettyType(head, !!head.phase), prettyModel(head.model), duration((head.endedAt ?? c.now) - head.startedAt), clock(head.startedAt)].filter(Boolean).join(' · ')
779 : `${scope.length} agents`
780 const tabs = Segmented(c, 'view', [['overview', 'Overview', 'o'], ['changes', `Changes ${files.length}`, 'c'], ['output', 'Output', 'u']], ui.view, v => {
781 c.acts.set({ view: v, file: null })
782 if (ui.file) c.acts.openDiff(null)
783 })
784 // the parts of a run: a workflow's phases, or a cluster's members
785 const parts: Part[] = head?.kind === 'workflow'
786 ? pipeline(head, c.all).map(p => ({
787 id: `phase:${head.id}:${p.phase}`, label: p.phase,
788 status: p.running ? 'running' as const : p.failed ? 'failed' as const : p.stopped ? 'killed' as const : 'done' as const,
789 result: head.partReports?.[p.phase]?.result ?? `${p.total - p.running} of ${p.total} agents finished`,
790 meta: `${p.total} agent${p.total === 1 ? '' : 's'}`,
791 }))
792 : head?.kind === 'group'
793 ? childrenOf(c.all, head.id).map(k => ({ id: k.id, label: k.label, status: k.status, result: reportOf(k)?.result ?? noReportReason(k) }))
794 : []
795 let body
796 if (ui.view === 'changes') body = Changes(c, scope, files, names)
797 else if (ui.view === 'output') {
798 const md = target.kind === 'node'
799 ? outputMarkdown(target.node, childrenOf(c.all, target.node.id))
800 : [`### ${target.phase} phase`, target.wf.partReports?.[target.phase]?.result ?? '', ...scope.map(k => `- **${k.label}** — ${reportOf(k)?.result ?? noReportReason(k)}`)].filter(Boolean).join('\n\n')
801 const brief = target.kind === 'node' && target.node.id === run.id && (run.explain || c.data.explaining?.includes(run.id))
802 ? `${briefMarkdown(run.explain, !!c.data.explaining?.includes(run.id), c.now)}\n\n---\n\n`
803 : ''
804 body = <Markdown key="output" text={brief + md} />
805 } else if (head) {
806 const prs = Object.values(head.partReports ?? {}).filter(p => p.model)
807 const partsBy = prs.length ? aiLabel({ model: prs[0]!.model, tokens: prs.reduce((a, p) => a + (p.tokens ?? 0), 0), at: Math.max(...prs.map(p => p.at ?? 0)) || undefined }, c.now) : ''
808 const writing = !!c.data.explaining?.includes(head.id)
809 const showBrief = head.id === run.id && (head.explain || writing)
810 body = (
811 <Box flexDirection="column" gap={1}>
812 {showBrief ? BriefBlock(c.els, t, head.explain, writing, c.now) : null}
813 {ReportBlock(c.els, t, head, c.all, c.now, parts, id => c.acts.set({ sel: id, file: null }), partsBy)}
814 </Box>
815 )
816 } else {
817 const pr = target.kind === 'phase' ? target.wf.partReports?.[target.phase] : undefined
818 body = (
819 <Box flexDirection="column" gap={1}>
820 {pr?.problems?.length
821 ? (
822 <Box key="attention" flexDirection="column">
823 <Box flexDirection="row" gap={1} alignItems="center">
824 <Svg source={iconSvg('warn', t.bad, 16)} alt="Needs your attention" width={16} height={16} />
825 <Text color={t.bad} bold>Needs your attention ({pr.problems.length})</Text>
826 </Box>
827 <Markdown key="pproblems" text={pr.problems.map(p => `- ${mdText(p.text)}`).join('\n')} />
828 </Box>
829 )
830 : null}
831 {Heading(c.els, `Agents (${scope.length})`, 'h-agents')}
832 {scope.map(k => (
833 <Box key={`pa-${k.id}`} flexDirection="column">
834 <Box flexDirection="row" gap={1} alignItems="center">
835 {StatusMark(c.els, t, k.status, 14)}
836 <Box flexGrow={1} flexShrink={1} overflow="hidden"><Button key={`psel:${k.id}`} plain label={k.label} onPress={() => c.acts.set({ sel: k.id })} /></Box>
837 <Box flexShrink={0}><Text color={t.muted}>{duration((k.endedAt ?? c.now) - k.startedAt)}</Text></Box>
838 </Box>
839 <Box paddingLeft={3}><Text color={t.muted}>{clip(reportOf(k)?.result ?? noReportReason(k), 200)}</Text></Box>
840 </Box>
841 ))}
842 {pr?.model ? <Text color={t.muted}>Part report by {aiLabel(pr, c.now)}</Text> : null}
843 </Box>
844 )
845 }
846 // the card: status, verdict and numbers in one glance; every view below shares it
847 const factScope = target.kind === 'node' ? { kind: 'node' as const, id: target.node.id } : { kind: 'phase' as const, wfId: target.wf.id, phase: target.phase }
848 const facts = reportFacts(c.all, factScope, c.now)
849 const result = head
850 ? reportOf(head)?.result ?? (head.status === 'running' ? 'Still working.' : noReportReason(head))
851 : target.kind === 'phase' ? target.wf.partReports?.[target.phase]?.result ?? `${counts(scope).done} of ${scope.length} agents done.` : ''
852 const problems = head ? (reportOf(head)?.problems?.length ?? 0) : target.kind === 'phase' ? (target.wf.partReports?.[target.phase]?.problems?.length ?? 0) : 0
853 const color = quiet ? t.warn : statusColor(t, status)
854 return (
855 <Box flexDirection="column" gap={1}>
856 {SummaryCard(c.els, t, { id: 'detail', big, statusWord: quiet ? 'Quiet' : STATUS_WORD[status], color, title, meta, result, tiles: tilesOf(t, facts, problems) })}
857 {quiet && head ? <Text color={t.warn}>No tool call for {duration(c.now - (head.lastToolAt ?? head.startedAt))}. Last: {head.activity ?? head.lastTool ?? 'unknown'}.</Text> : null}
858 {tabs}
859 {body}
860 </Box>
861 )
862}
863
864function Changes(c: Ctx, scope: AgentNode[], files: ReturnType<typeof fileRows>, names: Map<string, string>) {
865 const { Box, Text, Button, Svg, Code } = c.els
866 const { t, ui } = c
867 if (!files.length) return <Text color={t.muted}>No files were changed here.</Text>
868 if (ui.file) {
869 const row = files.find(f => f.path === ui.file)
870 const ids = new Set(scope.map(n => n.id))
871 const edits: EditRecord[] = c.data.diff?.path === ui.file ? c.data.diff.edits.filter(r => ids.has(r.agentId)) : []
872 const byId = new Map(c.all.map(n => [n.id, n]))
873 return (
874 <Box flexDirection="column" gap={1}>
875 <Box flexDirection="row" gap={2} alignItems="center">
876 <Button key="files" plain label="‹ All files" onPress={() => { c.acts.set({ file: null }); c.acts.openDiff(null) }} />
877 <Text bold>{names.get(ui.file) ?? ui.file}</Text>
878 {row ? <Text color={t.add}>+{num(row.added)} <Text color={t.del}>−{num(row.removed)}</Text></Text> : null}
879 </Box>
880 {c.data.diff?.path !== ui.file
881 ? <Text color={t.muted}>Loading the diff…</Text>
882 : edits.length === 0
883 ? <Text color={t.muted}>No saved edits for this file in this scope.</Text>
884 : edits.map(rec => {
885 const lines = lineDiff(rec.old, rec.new, rec.line)
886 const hunks = unifiedHunks(lines)
887 const who = byId.get(rec.agentId)
888 const added = lines.filter(l => l.op === '+').length
889 const removed = lines.filter(l => l.op === '-').length
890 return (
891 <Box key={`edit-${rec.id}`} flexDirection="column">
892 <Box flexDirection="row" gap={1} alignItems="center">
893 {Chip(c.els, t, who ? clip(who.label, 30) : rec.agentId.slice(0, 8), t.accent)}
894 <Text color={t.muted}>line {rec.line} · {clock(rec.at)}</Text>
895 <Text color={t.add}>+{added} <Text color={t.del}>−{removed}</Text></Text>
896 </Box>
897 {hunks.map((hunk, i) => <Code key={`hunk-${rec.id}-${i}`} source={hunk} format="diff" path={rec.path} wrap="wrap" />)}
898 </Box>
899 )
900 })}
901 </Box>
902 )
903 }
904 const max = Math.max(1, ...files.map(f => f.added + f.removed))
905 return (
906 <Box flexDirection="column">
907 {files.map(f => (
908 <Box key={`file-${f.path}`} flexDirection="row" gap={1} alignItems="center">
909 <Svg source={iconSvg('file', t.muted, 14)} alt="File" width={14} height={14} />
910 <Box flexGrow={1}><Button key={`file:${f.path}`} plain label={clip(names.get(f.path) ?? f.path, 50)} onPress={() => { c.acts.set({ file: f.path }); c.acts.openDiff(f.path) }} /></Box>
911 <Svg source={diffBarSvg(t, f.added, f.removed, max)} alt={`+${f.added} −${f.removed}`} width={60} height={6} />
912 <Box width={14}><Text color={t.add}>+{num(f.added)} <Text color={t.del}>−{num(f.removed)}</Text></Text></Box>
913 <Text color={t.muted}>{clip(f.by.map(b => b.node.label).join(', '), 30)}</Text>
914 </Box>
915 ))}
916 </Box>
917 )
918}
919hooks/desktop/state.ts 36 lines1import type { DeskUi } from '../../types'
2
3// The desktop view's own state: which tab, run, item and view are open, and how the
4// lists are filtered. It lives in $.state (key "desk"), written by Button presses.
5export const DESK_DEFAULT: DeskUi = {
6 tab: 'live', run: null, sel: null, view: 'overview', lens: 'tree', onlyRepo: true, file: null,
7 query: '', hfilter: 'all', olderOpen: false, flipped: [], zoom: 'story', traceOffset: 0,
8 confirm: '', note: '', patternsOpen: true,
9}
10
11const oneOf = <T extends string>(x: unknown, all: readonly T[], d: T): T => (all.includes(x as T) ? (x as T) : d)
12const str = (x: unknown) => (typeof x === 'string' ? x : null)
13
14// State from an older shape, or none, read with every field checked and defaulted.
15export function deskUi(raw: unknown): DeskUi {
16 const s = (raw ?? {}) as Partial<Record<keyof DeskUi, unknown>>
17 return {
18 tab: oneOf(s.tab, ['live', 'history'] as const, 'live'),
19 run: str(s.run),
20 sel: str(s.sel),
21 view: oneOf(s.view, ['overview', 'changes', 'output'] as const, 'overview'),
22 lens: oneOf(s.lens, ['tree', 'board', 'timeline', 'trace'] as const, 'tree'),
23 onlyRepo: s.onlyRepo !== false,
24 file: str(s.file),
25 query: typeof s.query === 'string' ? s.query : '',
26 hfilter: oneOf(s.hfilter, ['all', 'failed', 'changed'] as const, 'all'),
27 olderOpen: s.olderOpen === true,
28 flipped: Array.isArray(s.flipped) ? s.flipped.filter((x): x is string => typeof x === 'string') : [],
29 zoom: oneOf(s.zoom, ['story', 'steps', 'raw'] as const, 'story'),
30 traceOffset: typeof s.traceOffset === 'number' && Number.isFinite(s.traceOffset) ? Math.max(0, Math.floor(s.traceOffset)) : 0,
31 confirm: oneOf(s.confirm, ['', 'explain', 'patterns'] as const, ''),
32 note: typeof s.note === 'string' ? s.note : '',
33 patternsOpen: s.patternsOpen !== false,
34 }
35}
36hooks/view.tsx 2059 lines1import type { ClientModule, ClientSurface, JsonValue } from 'claude-code'
2
3import type { Act, AgentNode, Brief, EditRecord, PatternsDoc, TraceRow, TraceView, TraceZoom, ViewProps, Wheel } from '../types'
4import {
5 COLUMNS, LENSES, LENS_NAMES, aspects, boardLanes, columnOf, criticalPath, idleGaps, insights, lensesFor, modelFamily, rowFlags, spendByModel,
6 timelineRows, tokensUnder, type Column, type Flag, type Insight, type Lane, type Lens, type TimeRow,
7} from './lens'
8import { compareRuns, previousOf, verdict, type Metric } from './compare'
9import {
10 GROUPS, GROUP_NAMES, childrenOf, counts, dayLabel, diffCounts, fileRows, firstSentence, isSelectable, lineDiff,
11 noReportReason, overlaps, phaseWords, pipeline, plain, prettyModel, prettyType, readableResult, runTree, scopeNodes, shortPaths,
12 taskText, topItems, whereLabel, workSummary, type DiffLine, type FileRow, type Filter, type Group, type Item,
13 folderKey, joinNodes, runKey,
14} from './list'
15import { cellWidth, fit, kindGlyph, padEnd, padStart, themeNameOf, themeOf, type Theme } from './theme'
16import {
17 HISTORY_FILTERS, HISTORY_FILTER_NAMES, historyItems, historyStats, outcomeOf, runTokens, sparkline, type HistoryFilter,
18} from './history'
19import { kTokens, reportFacts, reportOf, type Facts } from './report'
20import { ZOOMS, ZOOM_NAMES } from './trace'
21import { ago } from './ai'
22
23type Tab = 'live' | 'history'
24type ViewName = 'agents' | 'changes' | 'output'
25type State = {
26 tab: Tab
27 // the run opened (a workflow, a cluster or a top-level subagent), or null for the run list
28 path: string | null
29 view: ViewName
30 // how the agents are drawn: their tree, a board by status, or a timeline
31 lens: Lens
32 sel: number
33 first: number
34 // the agents view's selection, kept while Changes or Output show its scope
35 agentSel: number
36 // what that selection was (a node id, or phase:<workflow>:<phase>), so any lens finds it again
37 focus: string | null
38 // the file whose diff the Changes view shows, or null for its file list
39 diffFile: string | null
40 filter: Filter
41 group: Group
42 flipped: string[]
43 showDone: string[]
44 tick: number
45 // The engine time last handed in, and the tick it arrived on: now = that + ticks since.
46 atSeen: number
47 tickAtSeen: number
48 // History shows only this repository's runs
49 onlyRepo: boolean
50 // the key overlay
51 help: boolean
52 // the last wheel move applied; -1 until the first props arrive
53 wheelSeen: number
54 // the insight `i` last jumped to; -1 before the first
55 insight: number
56 // History: which runs the page keeps, the search words, whether typing goes to
57 // the search line, and whether runs older than a week show
58 hfilter: HistoryFilter
59 query: string
60 searching: boolean
61 olderOpen: boolean
62 // the trace: how close it reads, what was last asked of the hooks and when,
63 // and the row to come back to after a diff
64 zoom: TraceZoom
65 traceAsk: string
66 traceAskTick: number
67 traceAt: number
68 // a short line in the footer (how to turn AI on, "press e again"), and the tick it was set
69 note: string
70 noteTick: number
71 // a second press within a few ticks writes the brief or the patterns again
72 confirm: '' | 'explain' | 'patterns'
73 confirmTick: number
74 // History: whether the patterns across runs show
75 patternsOpen: boolean
76 // Where the keys go, as on a remote: the content, or one of the two bars above it
77 // (the caption's views and lenses, the Live and History tabs). Arrows switch tabs
78 // only in a bar; ↑ on the first row goes up to it, ↓ comes back. `barAt` is the
79 // item the bar's cursor is on.
80 zone: Zone
81 barAt: number
82 // the agent picked in another lens, whose lane the trace opens on and marks;
83 // `traceJump` until the trace has come and the jump is made
84 traceFocus: string | null
85 traceJump: boolean
86 // the trace cut to one lane (its agent id), and the words searched in it
87 traceLane: string | null
88 tquery: string
89 // a run marked to compare with the next one m is pressed on; two runs side by side
90 mark: string | null
91 compare: [string, string] | null
92 // the last picture note shown
93 exportSeen: number
94}
95type Zone = 'content' | 'caption' | 'tabs'
96
97const TICK_MS = 500
98// every key the pane takes, for ? help
99const KEYS_HELP: [string, string][] = [
100 ['j k ↑ ↓', 'move, or scroll the text'], ['↑ on the top row', 'up to the bar: views and lenses, then Live · History'],
101 ['in a bar ← →', 'switch; ↓ or enter goes back down to the list'], ['PgUp PgDn', 'a page at a time'], ['g G', 'first, last'], ['enter', 'open'],
102 ['space ← →', 'fold or unfold in the tree; ← goes up to the parent'], ['v', 'view: tree, board, timeline; in a run, trace'],
103 ['1 2 a c o', 'straight to live, history; agents, changes, output'], ['b ⌫', 'back'],
104 ['i', 'next thing that needs a look'], ['f', 'filter: all, running, failed · history: all, failed, changed files'], ['s', 'group the run list'],
105 ['/', 'search history: names and outcomes; in a trace, its rows; enter keeps, ctrl+u clears'], ['o', 'show or hide history older than a week'],
106 ['r', 'this repo · all repos (history)'],
107 ['m', 'mark a run; m on a second run compares the two'], ['=', 'compare a run with its last run of the same work'],
108 ['trace n p', 'next, previous failure or quiet gap; with a search, its matches'], ['trace f', 'only the lane of the row you are on; f again for all'],
109 ['z', 'trace: story, steps, raw'], ['n p', 'next, previous file in a diff'],
110 ['x', 'save the run\'s timeline or trace as a picture (SVG, and PNG with rsvg-convert)'],
111 ['e', 'explain this run: a brief from Sonnet (ai: full); twice to write it again'],
112 ['g', 'history: patterns across runs from Sonnet (ai: full); twice to look again'],
113 ['p', 'history: show or hide the patterns'],
114 ['wheel', 'move, or scroll'], ['?', 'close this help'],
115]
116// its rows: a blank row, then the list
117const KEYS_HELP_COUNT = KEYS_HELP.length + 1
118const MIN_COLUMNS = 60
119const CARDS_PER_CELL = 4
120const DEFAULT_STATE: State = {
121 tab: 'live', path: null, view: 'agents', lens: 'tree', sel: 0, first: 0, agentSel: 0, focus: null, diffFile: null,
122 filter: 'all', group: 'none', flipped: [], showDone: [], tick: 0, atSeen: 0, tickAtSeen: 0, onlyRepo: true, help: false,
123 wheelSeen: -1, insight: -1, hfilter: 'all', query: '', searching: false, olderOpen: false,
124 zoom: 'story', traceAsk: '', traceAskTick: -100, traceAt: 0,
125 note: '', noteTick: -100, confirm: '', confirmTick: -100, patternsOpen: true,
126 zone: 'content', barAt: 0, traceFocus: null, traceJump: false, traceLane: null, tquery: '', mark: null, compare: null, exportSeen: 0,
127}
128const FILTERS: Filter[] = ['all', 'running', 'failed']
129// a note shows for 8 ticks (4 s); a confirm waits 6 ticks (3 s) for the second press
130const NOTE_TICKS = 8
131const CONFIRM_TICKS = 6
132const PATTERNS_TITLE = 'Patterns across runs'
133const VIEWS: ViewName[] = ['agents', 'changes', 'output']
134
135const arrayOf = <T,>(x: unknown): T[] => (Array.isArray(x) ? (x as T[]) : [])
136
137// Props and local state come from outside this module (the hooks, a previous
138// version of this view), so each field is checked and defaulted, never assumed.
139export function viewProps(raw: unknown): ViewProps {
140 const p = (raw ?? {}) as Partial<ViewProps>
141 const diff = p.diff && typeof p.diff.path === 'string' ? { path: p.diff.path, edits: arrayOf<EditRecord>(p.diff.edits) } : undefined
142 const w = p.wheel as Partial<Wheel> | undefined
143 return {
144 nodes: arrayOf(p.nodes), history: arrayOf(p.history), at: typeof p.at === 'number' ? p.at : 0, diff,
145 ...(typeof p.repo === 'string' ? { repo: p.repo } : {}),
146 theme: themeNameOf(p.theme),
147 wheel: { seq: typeof w?.seq === 'number' ? w.seq : 0, by: typeof w?.by === 'number' ? w.by : 0 },
148 ...(traceOf(p.trace) ? { trace: traceOf(p.trace) } : {}),
149 ai: p.ai === 'off' || p.ai === 'full' ? p.ai : 'cheap',
150 aiWeek: typeof p.aiWeek === 'number' ? p.aiWeek : 0,
151 explaining: arrayOf<string>(p.explaining).filter(x => typeof x === 'string'),
152 patternsBusy: p.patternsBusy === true,
153 ...(patternsOf(p.patterns) ? { patterns: patternsOf(p.patterns) } : {}),
154 ...(p.exported && typeof p.exported.seq === 'number' && typeof p.exported.text === 'string' ? { exported: { seq: p.exported.seq, text: p.exported.text } } : {}),
155 }
156}
157
158// Patterns from the hooks, when they have the shape the view draws.
159const patternsOf = (d: unknown): PatternsDoc | undefined => {
160 const x = d as Partial<PatternsDoc> | undefined
161 if (!x || !Array.isArray(x.cards) || typeof x.at !== 'number') return undefined
162 const cards = x.cards.filter(c => c && typeof c.title === 'string').map(c => ({ ...c, runs: arrayOf<{ id: string; label: string }>(c.runs) }))
163 return { cards, model: typeof x.model === 'string' ? x.model : 'Sonnet', at: x.at, ...(typeof x.tokens === 'number' ? { tokens: x.tokens } : {}) }
164}
165
166// A trace from the hooks, when it has the shape the view draws.
167const traceOf = (t: unknown): TraceView | undefined => {
168 const x = t as Partial<TraceView> | undefined
169 if (!x || typeof x.run !== 'string' || !Array.isArray(x.rows) || !Array.isArray(x.lanes)) return undefined
170 return { run: x.run, zoom: ZOOMS.includes(x.zoom as TraceZoom) ? (x.zoom as TraceZoom) : 'story', lanes: x.lanes, rows: x.rows as TraceRow[],
171 total: typeof x.total === 'number' ? x.total : x.rows.length, offset: typeof x.offset === 'number' ? x.offset : 0, missing: typeof x.missing === 'number' ? x.missing : 0,
172 ...(typeof x.storyBy === 'string' ? { storyBy: x.storyBy } : {}),
173 ...(typeof x.lane === 'string' ? { lane: x.lane } : {}),
174 marks: arrayOf<number>(x.marks).filter(n => typeof n === 'number'),
175 ...(typeof x.query === 'string' ? { query: x.query, hits: arrayOf<number>(x.hits).filter(n => typeof n === 'number') } : {}) }
176}
177
178export function viewState(raw: unknown): State {
179 const s = { ...DEFAULT_STATE, ...((raw ?? {}) as Partial<State>) }
180 const num = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
181 return {
182 ...s,
183 tab: s.tab === 'history' ? 'history' : 'live',
184 path: typeof s.path === 'string' ? s.path : null,
185 view: VIEWS.includes(s.view) ? s.view : 'agents',
186 lens: LENSES.includes(s.lens) ? s.lens : 'tree',
187 focus: typeof s.focus === 'string' ? s.focus : null,
188 diffFile: typeof s.diffFile === 'string' ? s.diffFile : null,
189 filter: FILTERS.includes(s.filter) ? s.filter : 'all',
190 group: GROUPS.includes(s.group) ? s.group : 'none',
191 flipped: arrayOf(s.flipped),
192 showDone: arrayOf(s.showDone),
193 sel: num(s.sel),
194 first: num(s.first),
195 agentSel: num(s.agentSel),
196 tick: num(s.tick),
197 onlyRepo: s.onlyRepo !== false,
198 help: s.help === true,
199 wheelSeen: typeof s.wheelSeen === 'number' ? s.wheelSeen : -1,
200 insight: typeof s.insight === 'number' ? s.insight : -1,
201 hfilter: HISTORY_FILTERS.includes(s.hfilter) ? s.hfilter : 'all',
202 query: typeof s.query === 'string' ? s.query : '',
203 searching: s.searching === true,
204 olderOpen: s.olderOpen === true,
205 zoom: ZOOMS.includes(s.zoom) ? s.zoom : 'story',
206 traceAsk: typeof s.traceAsk === 'string' ? s.traceAsk : '',
207 traceAskTick: typeof s.traceAskTick === 'number' ? s.traceAskTick : -100,
208 traceAt: num(s.traceAt),
209 note: typeof s.note === 'string' ? s.note : '',
210 noteTick: typeof s.noteTick === 'number' ? s.noteTick : -100,
211 confirm: s.confirm === 'explain' || s.confirm === 'patterns' ? s.confirm : '',
212 confirmTick: typeof s.confirmTick === 'number' ? s.confirmTick : -100,
213 patternsOpen: s.patternsOpen !== false,
214 zone: s.zone === 'caption' || s.zone === 'tabs' ? s.zone : 'content',
215 barAt: num(s.barAt),
216 traceFocus: typeof s.traceFocus === 'string' ? s.traceFocus : null,
217 traceJump: s.traceJump === true,
218 traceLane: typeof s.traceLane === 'string' ? s.traceLane : null,
219 tquery: typeof s.tquery === 'string' ? s.tquery : '',
220 mark: typeof s.mark === 'string' ? s.mark : null,
221 compare: Array.isArray(s.compare) && s.compare.length === 2 && s.compare.every(x => typeof x === 'string') ? [s.compare[0]!, s.compare[1]!] : null,
222 exportSeen: num(s.exportSeen),
223 }
224}
225
226const duration = (ms: number) => {
227 const s = Math.max(0, Math.floor(ms / 1000))
228 if (s < 60) return `${s}s`
229 const m = Math.floor(s / 60)
230 return m < 60 ? `${m}m ${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
231}
232// a ruler's relative time: 0:30, 4:00, 1:05:00
233const offset = (ms: number) => {
234 const s = Math.max(0, Math.round(ms / 1000))
235 const h = Math.floor(s / 3600)
236 const m = Math.floor((s % 3600) / 60)
237 return h ? `${h}:${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` : `${m}:${String(s % 60).padStart(2, '0')}`
238}
239const clock = (t: number) => new Date(t).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
240const clockSec = (t: number) => new Date(t).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit' })
241const baseName = (p: string) => p.split('/').filter(Boolean).pop() ?? p
242// 3,520 rather than 3520: counts read faster with separators
243const num = (n: number) => n.toLocaleString('en-US')
244
245// The status line's bar: ▰ filled, ▱ empty, rounded to the nearest cell.
246export function bar(done: number, total: number, width: number): { filled: string; empty: string; pct: number } {
247 const pct = total > 0 ? Math.round((done / total) * 100) : 0
248 const full = Math.min(width, Math.round((pct * width) / 100))
249 return { filled: '▰'.repeat(full), empty: '▱'.repeat(Math.max(0, width - full)), pct }
250}
251
252// A roll-up bar split by status, in cells: done, failed or stopped, then running.
253export function statusCells(c: { done: number; failed: number; stopped: number; running: number; total: number }, width: number) {
254 if (c.total === 0) return { done: 0, bad: 0, running: width }
255 const done = Math.round((c.done / c.total) * width)
256 const bad = Math.min(width - done, Math.max(c.failed + c.stopped > 0 ? 1 : 0, Math.round(((c.failed + c.stopped) / c.total) * width)))
257 return { done, bad, running: width - done - bad }
258}
259
260// Word wrap to a width in cells, so text scrolls and clips by whole lines.
261export function wrap(text: string, width: number): string[] {
262 const out: string[] = []
263 for (const para of text.split('\n')) {
264 if (!para.trim()) { out.push(''); continue }
265 let line = ''
266 for (const word of para.split(/\s+/)) {
267 if (!word) continue
268 if ((line ? cellWidth(line) + 1 : 0) + cellWidth(word) > width && line) { out.push(line); line = '' }
269 line = line ? `${line} ${word}` : cellWidth(word) > width ? fit(word, width) : word
270 }
271 if (line) out.push(line)
272 }
273 return out
274}
275
276export function step(n: number, from: number, dir: 1 | -1, ok: (i: number) => boolean): number {
277 for (let i = from + dir; i >= 0 && i < n; i += dir) if (ok(i)) return i
278 return from
279}
280
281// ── unified diff hunks for the engine's own diff drawing ──────────────────
282const CODE_LIMIT = 9000
283// One edit as unified-diff hunks, split so each stays under the Code element's size limit.
284export function unifiedHunks(lines: DiffLine[]): string[] {
285 const out: string[] = []
286 let chunk: DiffLine[] = []
287 let size = 0
288 const flush = () => {
289 if (chunk.length === 0) return
290 const oldNos = chunk.filter(l => l.op !== '+').map(l => l.oldNo ?? 0)
291 const newNos = chunk.filter(l => l.op !== '-').map(l => l.newNo ?? 0)
292 const firstOld = oldNos[0] ?? Math.max(0, (chunk[0]!.newNo ?? 1) - 1)
293 const firstNew = newNos[0] ?? Math.max(0, (chunk[0]!.oldNo ?? 1) - 1)
294 const head = `@@ -${firstOld},${oldNos.length} +${firstNew},${newNos.length} @@`
295 out.push([head, ...chunk.map(l => `${l.op === ' ' ? ' ' : l.op}${l.text}`)].join('\n'))
296 chunk = []
297 size = 0
298 }
299 for (const l of lines) {
300 const len = l.text.length + 2
301 if (size + len > CODE_LIMIT) flush()
302 chunk.push({ ...l, text: l.text.length > CODE_LIMIT - 100 ? l.text.slice(0, CODE_LIMIT - 100) : l.text })
303 size += len
304 }
305 flush()
306 return out
307}
308
309type Scope = { kind: 'node'; id: string } | { kind: 'phase'; wfId: string; phase: string }
310type FileItem = { row: FileRow }
311type DiffBlock = { rec: EditRecord; hunks: string[]; added: number; removed: number }
312
313// A row is a run of coloured pieces, laid out to an exact width in cells.
314type Seg = { t: string; c?: string; b?: boolean; bg?: string }
315type Row = { segs: Seg[]; bg?: string; hit?: Hit[] }
316type Hit = { x0: number; x1: number; act: () => void }
317const seg = (t: string, c?: string, b?: boolean): Seg => ({ t, c, b })
318const width = (segs: Seg[]) => segs.reduce((w, s) => w + cellWidth(s.t), 0)
319
320// Cuts pieces to a width (… on the last one kept), then pads with one piece of spaces.
321function lay(segs: Seg[], w: number): Seg[] {
322 const out: Seg[] = []
323 let used = 0
324 for (const s of segs) {
325 const cw = cellWidth(s.t)
326 if (used + cw <= w) { out.push(s); used += cw; continue }
327 const room = w - used
328 if (room > 0) { out.push({ ...s, t: fit(s.t, room) }); used += cellWidth(fit(s.t, room)) }
329 break
330 }
331 if (used < w) out.push({ t: ' '.repeat(w - used) })
332 return out
333}
334
335// What a selection is, as a key any lens can find again.
336const keyOf = (it: Item | undefined): string | null =>
337 it?.kind === 'node' ? it.node.id : it?.kind === 'phase' ? `phase:${JSON.stringify([it.wfId, it.phase])}` : null
338const scopeOfKey = (key: string | null, nodes: AgentNode[]): Scope | undefined => {
339 if (!key) return undefined
340 if (key.startsWith('phase:')) {
341 try {
342 const [wfId, phase] = JSON.parse(key.slice(6)) as [string, string]
343 return { kind: 'phase', wfId, phase }
344 } catch { return undefined }
345 }
346 return nodes.some(n => n.id === key) ? { kind: 'node', id: key } : undefined
347}
348
349const ACT_WORD: Record<Act, string> = { think: 'thinking', read: 'reading', write: 'writing', run: 'running', web: 'web' }
350const COLUMN_NAMES: Record<Column, [string, string]> = { running: ['RUNNING', 'RUNNING'], done: ['DONE', 'DONE'], failed: ['FAILED OR STOPPED', 'FAILED'] }
351
352let live: ClientSurface<State> | undefined
353
354const View: ClientModule<JsonValue, State> = (raw, surface) => {
355 const props = viewProps(raw)
356 const { Box, Text, Code } = surface.elements
357 const T: Theme = themeOf(props.theme)
358 const C = T.c
359 live = surface
360 const s: State = viewState(surface.state)
361 if (surface.state === undefined) {
362 surface.setState(s)
363 surface.every(TICK_MS, () => live?.state && live.setState({ ...live.state, tick: live.state.tick + 1 }))
364 }
365 if (surface.state !== undefined && s.atSeen !== props.at) surface.setState({ ...s, atSeen: props.at, tickAtSeen: s.tick })
366 const now = s.atSeen === props.at ? props.at + (s.tick - s.tickAtSeen) * TICK_MS : props.at
367 const pulse = s.tick % 2 === 0
368
369 // ── geometry, from the region as laid out now ─────────────────────────
370 const W = Math.max(20, surface.columns || 100)
371 const R = Math.max(8, surface.rows || 30)
372 const IW = W - 4 // inside the frame and its one-cell margins
373 const CH = R - 2 // rows between the top and bottom borders
374 const layout = W >= 140 ? 'split' : W >= 100 ? 'full' : 'compact'
375 const LW = layout === 'split' ? Math.floor(IW * 0.45) : IW
376 const RW = layout === 'split' ? IW - LW - 3 : IW
377
378 // History filtered to this repository: a run belongs where its top node ran.
379 const repoHistory = (() => {
380 if (!s.onlyRepo || !props.repo) return props.history
381 const byId = new Map(props.history.map(n => [n.id, n]))
382 const top = (n: AgentNode) => { let x = n; while (x.parentId && byId.has(x.parentId)) x = byId.get(x.parentId)!; return x }
383 const here = folderKey(props.repo)
384 return props.history.filter(n => runKey(top(n)) === here)
385 })()
386 const noRepoRuns = props.history.filter(n => !n.parentId && !runKey(n)).length
387 const isHistory = s.tab === 'history'
388 const nodes = isHistory ? repoHistory : props.nodes
389 const allNodes = joinNodes(props.nodes, props.history)
390 const run = s.path ? nodes.find(n => n.id === s.path) : undefined
391 const isInWorkflow = (n: AgentNode) => !!n.parentId && nodes.some(p => p.id === n.parentId && p.kind === 'workflow')
392 // a trace needs a run open; on the run list the lens falls back to the tree
393 const lens: Lens = s.lens === 'trace' && !run ? 'tree' : s.lens
394 const offered = lensesFor(!!run)
395 const nextLensOf = (l: Lens) => offered[(offered.indexOf(l) + 1) % offered.length] ?? 'tree'
396
397 // ── AI-written blocks: who wrote them, what it cost, how old they are ───────
398 const aiLabel = (b: { model?: string; tokens?: number; at?: number } | undefined): string =>
399 !b?.model ? '' : [b.model, ...(b.tokens ? [`${kTokens(b.tokens)} tokens`] : []), ...(b.at ? [ago(now - b.at)] : [])].join(' · ')
400 const isFull = props.ai === 'full'
401 // Patterns across runs, above the day groups: a heading, then per card its title, evidence,
402 // what to try and the runs it rests on (those rows open the run). Width: the list's.
403 const historyRunCount = props.history.filter(n => !n.parentId).length
404 const patternItems = (): Item[] => {
405 if (s.query || s.hfilter !== 'all') return []
406 const doc = props.patterns
407 const busy = !!props.patternsBusy
408 if (!doc && !busy && (!isFull || historyRunCount < 2)) return []
409 const note = busy ? `writing… (Sonnet)` : doc ? `${aiLabel(doc)}${isFull ? ' · g g writes them again' : ''}` : 'g asks Sonnet to look for patterns'
410 const head: Item = { kind: 'header', text: PATTERNS_TITLE, note, ...(doc ? { isOpen: s.patternsOpen } : {}) }
411 if (!doc || !s.patternsOpen) return [head]
412 const w = Math.max(20, (layout === 'split' ? LW : IW) - 6)
413 const out: Item[] = [head]
414 for (const c of doc.cards) {
415 out.push({ kind: 'pattern', tone: 'title', text: c.title })
416 if (c.evidence) for (const t of wrap(c.evidence, w)) out.push({ kind: 'pattern', tone: 'evidence', text: t })
417 if (c.try) wrap(`Try: ${c.try}`, w).forEach(t => out.push({ kind: 'pattern', tone: 'try', text: t }))
418 for (const r of c.runs) {
419 const node = props.history.find(n => n.id === r.id)
420 if (node) out.push({ kind: 'node', node, species: 'cat', depth: 1, guide: 'pattern' })
421 }
422 }
423 return out
424 }
425
426 // ── the three lenses over the same runs ───────────────────────────────
427 // the History page lists runs by day, filtered and searched; inside a run, its tree
428 const listFor = (flipped: readonly string[], showDone: readonly string[], at: AgentNode | undefined): Item[] =>
429 at ? runTree(at, nodes, s.filter, flipped, showDone)
430 : isHistory ? [...patternItems(), ...historyItems(nodes, now, { filter: s.hfilter, query: s.query, olderOpen: s.olderOpen })]
431 : topItems(nodes, s.filter, isHistory, now, s.group, flipped, showDone)
432 const agentItems: Item[] = listFor(s.flipped, s.showDone, run)
433 // the runs the run list shows, in its order, for the board and the timeline
434 const topRuns = topItems(nodes, s.filter, isHistory, now, 'none').flatMap(it => (it.kind === 'node' ? [it.node] : []))
435 const lanes: Lane[] = boardLanes(run, nodes, topRuns, s.filter)
436 const laneCells = lanes.map(l => COLUMNS.map(col => l.cards.filter(n => columnOf(n) === col)))
437 // a board's cards in reading order: lane by lane, column by column, the first few of each cell
438 const boardCards = laneCells.flatMap(cells => cells.flatMap(list => list.slice(0, CARDS_PER_CELL)))
439 const timeRows: TimeRow[] = timelineRows(run, nodes, topRuns, s.filter)
440 const timeCards = timeRows.flatMap(r => (r.kind === 'bar' ? [r.node] : []))
441 const cards = lens === 'board' ? boardCards : lens === 'timeline' ? timeCards : []
442
443 // ── what the screen is about ─────────────────────────────────────────
444 const view: ViewName = run ? s.view : 'agents'
445 const inAgents = view === 'agents'
446 // two runs side by side, when both are still kept
447 const pair = s.compare ? s.compare.map(id => allNodes.find(n => n.id === id)) : []
448 const comparing = pair.length === 2 && pair.every(Boolean)
449 const mode: 'help' | 'compare' | 'agents' | 'board' | 'timeline' | 'trace' | 'files' | 'diff' | 'output' = s.help ? 'help'
450 : comparing ? 'compare'
451 : inAgents ? (lens === 'tree' ? 'agents' : lens)
452 : view === 'output' ? 'output' : s.diffFile !== null ? 'diff' : 'files'
453 const asked = Math.min(Math.max(0, inAgents ? s.sel : s.agentSel), Math.max(0, agentItems.length - 1))
454 const snap = [asked, ...agentItems.map((_, i) => i).filter(i => i > asked), ...agentItems.map((_, i) => i).filter(i => i < asked).reverse()]
455 const agentSel = snap.find(i => isSelectable(agentItems[i])) ?? asked
456 const cardSel = Math.min(Math.max(0, s.sel), Math.max(0, cards.length - 1))
457 const focusItem = lens === 'tree' ? agentItems[agentSel] : undefined
458 const focusCard = lens !== 'tree' ? cards[cardSel] : undefined
459 // the key of what is selected in the agents view now
460 const focusKey = lens === 'tree' ? keyOf(focusItem) : focusCard?.id ?? null
461 const scope: Scope | undefined = inAgents
462 ? (scopeOfKey(focusKey, nodes) ?? (run ? { kind: 'node', id: run.id } : undefined))
463 : (scopeOfKey(s.focus, nodes) ?? (run ? { kind: 'node', id: run.id } : undefined))
464 const scopeList = scope ? scopeNodes(nodes, scope) : []
465 const scopeNode = scope?.kind === 'node' ? nodes.find(n => n.id === scope.id) : undefined
466 const files = fileRows(scopeList)
467 const names = shortPaths(files.map(f => f.path))
468 const scopeIds = new Set(scopeList.map(n => n.id))
469 const diffOpen = mode === 'diff'
470
471 const fileItems: FileItem[] = files.map(row => ({ row }))
472 const blocks: DiffBlock[] = (() => {
473 if (!diffOpen || props.diff?.path !== s.diffFile) return []
474 return props.diff.edits.filter(r => scopeIds.has(r.agentId)).map(rec => {
475 const lines = lineDiff(rec.old, rec.new, rec.line)
476 return { rec, hunks: unifiedHunks(lines), ...diffCounts(lines) }
477 })
478 })()
479
480 // what needs a look: inside a run, everything under it; on the run list, what is live
481 const shownInsights: Insight[] = run ? insights(scopeNodes(nodes, { kind: 'node', id: run.id }), nodes, now)
482 : !isHistory ? insights(props.nodes, props.nodes, now) : []
483 // the same insights on the rows they are about, inside a run
484 const flags: Map<string, Flag> = run ? rowFlags(scopeNodes(nodes, { kind: 'node', id: run.id }), nodes, now) : new Map()
485 // where a run's time went, on its timeline: the chain that set its end, and the idle time
486 const crit = run && lens === 'timeline' ? criticalPath(timeCards, now) : undefined
487 const onPath = new Set(crit?.ids ?? [])
488 const idle = run && lens === 'timeline' ? idleGaps(timeCards, now) : { gaps: [], ms: 0 }
489
490 // ── pieces ───────────────────────────────────────────────────────────
491 const outcome = (n: AgentNode) => n.summary ?? (n.kind === 'workflow' ? firstSentence(readableResult(n.result)) : firstSentence(n.result))
492 const elapsed = (n: Pick<AgentNode, 'status' | 'startedAt' | 'endedAt'>) => (n.status === 'running' ? now : n.endedAt ?? now) - n.startedAt
493 const statusIcon = (n: Pick<AgentNode, 'status'>) => (n.status === 'running' && !pulse ? T.runningAlt : T.status[n.status].icon)
494 const statusSegs = (n: Pick<AgentNode, 'status' | 'startedAt' | 'endedAt'>, extra?: string): Seg[] => {
495 const st = T.status[n.status]
496 return [seg(statusIcon(n), st.color), seg(' '), seg(st.word, st.color, true),
497 seg(' '), seg(duration(elapsed(n)), C.muted), ...(extra ? [seg(' '), seg(extra, C.muted)] : [])]
498 }
499 // a roll-up bar split by status: done, failed or stopped, still running
500 const rollSegs = (c: ReturnType<typeof counts>, w: number): Seg[] => {
501 const cells = statusCells(c, w)
502 return [seg(T.bar.full.repeat(cells.done), C.ok), seg(T.bar.full.repeat(cells.bad), C.bad), seg(T.bar.empty.repeat(cells.running), C.warn)]
503 }
504 const changeSegs = (rows: FileRow[]): Seg[] => {
505 if (rows.length === 0) return [seg('None', C.muted)]
506 const added = rows.reduce((a, r) => a + r.added, 0)
507 const removed = rows.reduce((a, r) => a + r.removed, 0)
508 return [seg(`${rows.length} file${rows.length === 1 ? '' : 's'}`, C.accent), seg(' '), seg(`+${num(added)}`, C.add), seg(' '), seg(`−${num(removed)}`, C.del)]
509 }
510 // tokens by model family: "Opus 120k (2) · Sonnet 40k (5)"
511 const spendSegs = (scope: AgentNode[]): Seg[] => spendByModel(scope).flatMap((m, i) => [
512 ...(i ? [seg(' · ', C.faint)] : []), seg(m.model, C.soft), seg(` ${kTokens(m.tokens)}`, C.ink), seg(` (${m.agents})`, C.faint)])
513 const nowSegs = (n: AgentNode): Seg[] => [seg('› ', C.accent), seg(n.activity ?? 'Thinking', C.ink), seg(` ${ACT_WORD[n.act ?? 'think']}`, C.faint)]
514 const quietFor = (n: AgentNode) => (n.status === 'running' ? now - (n.lastToolAt ?? n.startedAt) : 0)
515 // a cluster's aspects as chips: each member's short task with how it went
516 const chipSegs = (groupId: string, w: number): Seg[] => {
517 const list = aspects(nodes, groupId)
518 if (!list.length) return []
519 const each = Math.max(6, Math.floor(w / list.length) - 5)
520 const out: Seg[] = []
521 let used = 0
522 for (let i = 0; i < list.length; i++) {
523 const { node, text } = list[i]!
524 const piece: Seg[] = [...(i ? [seg(' ')] : []), seg(statusIcon(node), T.status[node.status].color), seg(' '), seg(fit(text, each), C.soft)]
525 const rest = list.length - i - 1
526 if (used + width(piece) + (rest ? 8 : 0) > w && i > 0) { out.push(seg(` +${list.length - i}`, C.faint)); break }
527 out.push(...piece)
528 used += width(piece)
529 }
530 return out
531 }
532 const overlapSegs = (groupId: string): Seg[] => {
533 const o = overlaps(scopeNodes(nodes, { kind: 'node', id: groupId }).filter(k => k.id !== groupId))[0]
534 if (!o) return []
535 const more = overlaps(scopeNodes(nodes, { kind: 'node', id: groupId }).filter(k => k.id !== groupId)).length - 1
536 return [seg(`${T.warn} `, C.warn), seg(`${baseName(o.path)} edited by ${o.by.length} members`, C.warn), seg(` (${o.by.map(b => b.label).join(', ')})`, C.muted),
537 ...(more > 0 ? [seg(` +${more} more file${more === 1 ? '' : 's'}`, C.faint)] : [])]
538 }
539
540 // ── the report: Result, Parts or Done, Decisions, Problems, Next, Changed, Totals ──
541 // The words are the model's; Changed and Totals come from the mod's own records.
542 type Section = [string, Seg[][]]
543 const pmSegs = (added: number, removed: number): Seg[] => [seg(`+${num(added)}`, C.add), seg(' '), seg(`−${num(removed)}`, C.del)]
544 const phaseStatus = (p: { running: number; failed: number; stopped: number }) =>
545 p.failed ? T.status.failed : p.running ? T.status.running : p.stopped ? T.status.killed : T.status.done
546 // one line per part: a workflow's phases, or a cluster's members
547 // `all` (the Output view) wraps each part's outcome under itself; the overview cuts it to one line
548 const partRows = (n: AgentNode, w: number, all = false): Seg[][] => {
549 const line = (glyph: { icon: string; color: string }, name: string, nameW: number, count: string, outcome: string, files: FileRow[]): Seg[][] => {
550 const added = files.reduce((a, f) => a + f.added, 0)
551 const removed = files.reduce((a, f) => a + f.removed, 0)
552 const pm = files.length ? pmSegs(added, removed) : []
553 const head: Seg[] = [seg(padEnd(glyph.icon, 2), glyph.color), seg(' '), seg(padEnd(fit(name, nameW), nameW), C.ink, true), ...(count ? [seg(` ${count}`, C.muted)] : [])]
554 const room = Math.max(8, w - width(head) - 2 - (pm.length ? width(pm) + 2 : 0))
555 const lines = all ? wrap(outcome, room) : [fit(outcome, room)]
556 return lines.map((said, i) => i === 0
557 ? [...head, seg(' '), seg(said, C.soft), seg(' '.repeat(Math.max(0, room - cellWidth(said)))), ...(pm.length ? [seg(' '), ...pm] : [])]
558 : [seg(' '.repeat(width(head) + 2)), seg(said, C.soft)])
559 }
560 if (n.kind === 'workflow') {
561 const ps = pipeline(n, nodes)
562 const nameW = Math.min(14, Math.max(4, ...ps.map(p => cellWidth(p.phase))))
563 return ps.flatMap(p => line(phaseStatus(p), p.phase, nameW, padStart(`${p.total} agent${p.total === 1 ? '' : 's'}`, 9),
564 n.partReports?.[p.phase]?.result ?? phaseWords(p), fileRows(scopeNodes(nodes, { kind: 'phase', wfId: n.id, phase: p.phase }))))
565 }
566 if (n.kind === 'group') {
567 const kids = childrenOf(nodes, n.id).sort((a, b) => a.startedAt - b.startedAt)
568 const nameW = Math.min(30, Math.max(8, ...kids.map(k => cellWidth(k.label))))
569 return kids.flatMap(k => line(T.status[k.status], k.label, nameW, '', outcomeOf(k) || noReportReason(k), fileRows(scopeNodes(nodes, { kind: 'node', id: k.id }))))
570 }
571 return []
572 }
573 const changedRows = (f: Facts, w: number, all: boolean): Seg[][] => {
574 const short = shortPaths(f.files.map(x => x.path))
575 if (all) {
576 const nameW = Math.min(w - 16, Math.max(...f.files.map(x => cellWidth(short.get(x.path) ?? x.path))))
577 return f.files.map(x => [seg(padEnd(fit(short.get(x.path) ?? x.path, nameW), nameW), C.ink), seg(' '), ...pmSegs(x.added, x.removed)])
578 }
579 const out: Seg[] = []
580 let shown = 0
581 for (const x of f.files) {
582 const piece: Seg[] = [...(shown ? [seg(' · ', C.faint)] : []), seg(short.get(x.path) ?? x.path, C.ink), seg(' '), ...pmSegs(x.added, x.removed)]
583 const rest = f.files.length - shown - 1
584 if (width(out) + width(piece) + (rest ? 10 : 0) > w && shown) break
585 out.push(...piece)
586 shown++
587 }
588 if (shown < f.files.length) out.push(seg(` · +${f.files.length - shown} more`, C.faint))
589 return [out]
590 }
591 const plural = (k: number, one: string) => `${k} ${one}${k === 1 ? '' : 's'}`
592 const totalsRows = (f: Facts, w: number): Seg[][] => {
593 const parts: Seg[][] = [
594 ...(f.phases ? [[seg(plural(f.phases, 'phase'), C.soft)]] : []),
595 [seg(plural(f.agents, 'agent'), C.soft)],
596 [seg(plural(f.files.length, 'file'), C.soft)],
597 ...(f.files.length ? [pmSegs(f.added, f.removed)] : []),
598 [seg(duration(f.ms), C.soft)],
599 ...(f.tokens ? [[seg(`${kTokens(f.tokens)} tokens`, C.soft)]] : []),
600 ]
601 const lines: Seg[][] = [[]]
602 for (const p of parts) {
603 const cur = lines[lines.length - 1]!
604 const piece = [...(cur.length ? [seg(' · ', C.faint)] : []), ...p]
605 if (cur.length && width(cur) + width(piece) > w) lines.push([...p])
606 else cur.push(...piece)
607 }
608 return lines
609 }
610 // `all`: the Output view's full block; otherwise lists and long text are capped for the
611 // overview; `brief` keeps only Result, Problems, Next and Totals, for a pane short of rows
612 const reportSections = (sc: Scope, valueW: number, all: boolean, brief = false): Section[] => {
613 const node = sc.kind === 'node' ? nodes.find(x => x.id === sc.id) : undefined
614 const wf = sc.kind === 'phase' ? nodes.find(x => x.id === sc.wfId) : undefined
615 const r = sc.kind === 'phase' ? wf?.partReports?.[sc.phase] : node ? reportOf(node) : undefined
616 const f = reportFacts(nodes, sc, now)
617 const text = (t: string, c: string): Seg[][] => wrap(t, valueW).slice(0, all ? 99 : brief ? 2 : 3).map(l => [seg(l, c)])
618 const bullets = (items: { text: string; source?: string }[], c: string): Seg[][] => items.slice(0, 3).flatMap(it =>
619 wrap(`${it.text}${it.source ? ` [${it.source}]` : ''}`, valueW - 3).slice(0, all ? 99 : 2).map((l, i) => [seg(i ? ' ' : '• ', C.faint), seg(l, c)]))
620 const out: Section[] = []
621 const aiOff = props.ai === 'off' && !r?.result
622 const fallback = aiOff ? 'AI is off: no written report. Set ai to cheap or full in /config.'
623 : sc.kind === 'phase' ? 'No part report yet.' : node ? noReportReason(node) : ''
624 out.push(['Result', text(r?.result ?? fallback, aiOff ? C.muted : C.ink)])
625 // with AI off the failures still show, from the records
626 const recordProblems = aiOff ? scopeNodes(nodes, sc).filter(n => n.kind === 'agent' && (n.status === 'failed' || n.status === 'killed'))
627 .map(n => ({ text: `${n.label} ${n.status === 'failed' ? 'failed' : 'was stopped'}.` })) : []
628 if (brief) {
629 if (r?.problems?.length) out.push(['Problems', r.problems.slice(0, 2).map(p => [seg('• ', C.faint), seg(fit(`${p.text}${p.source ? ` [${p.source}]` : ''}`, valueW - 3), C.warn)])])
630 if (r?.next) out.push(['Next', [[seg(fit(r.next, valueW), C.soft)]]])
631 out.push(['Totals', totalsRows(f, valueW)])
632 return out
633 }
634 const parts = node ? partRows(node, valueW, all) : []
635 if (parts.length) {
636 const cap = all ? parts.length : 6
637 out.push(['Parts', [...parts.slice(0, cap), ...(parts.length > cap ? [[seg(`+${parts.length - cap} more parts`, C.faint)]] : [])]])
638 } else if (r?.done?.length) out.push(['Done', bullets(r.done.map(t => ({ text: t })), C.soft)])
639 if (r?.decisions?.length) out.push(['Decisions', bullets(r.decisions, C.soft)])
640 if (r?.problems?.length) out.push(['Problems', bullets(r.problems, C.warn)])
641 else if (recordProblems.length) out.push(['Problems', bullets(recordProblems, C.warn)])
642 if (r?.next) out.push(['Next', text(r.next, C.soft)])
643 if (f.files.length) out.push(['Changed', changedRows(f, valueW, all)])
644 out.push(['Totals', totalsRows(f, valueW)])
645 return out
646 }
647 const sectionRows = (secs: Section[]): Row[] =>
648 secs.flatMap(([name, lines]) => lines.map((l, i) => ({ segs: [seg(padEnd(i ? '' : name, 11), C.muted), ...l] })))
649 // who wrote a report, beside its heading
650 const writtenBy = (sc: Scope): string => {
651 const r = sc.kind === 'phase' ? nodes.find(x => x.id === sc.wfId)?.partReports?.[sc.phase] : nodes.find(x => x.id === sc.id)?.report
652 if (r?.model) return aiLabel(r)
653 return props.ai === 'off' ? 'AI is off · from records' : 'from what the run kept'
654 }
655
656 // ── the overview of the selection, as rows of a given width ───────────
657 const overview = (w: number, dense: 'compact' | 'brief' | 'full'): Row[] => {
658 if (!scope) return []
659 const title = scope.kind === 'phase' ? `${scope.phase} phase` : scopeNode?.label ?? ''
660 const when = (n: AgentNode) => (isHistory ? clock(n.startedAt) : undefined)
661 let meta: Seg[] = []
662 let second: Seg[] = []
663 let rows: [string, Seg[]][] = []
664 let points: string[] = []
665 if (scope.kind === 'phase') {
666 const c = counts(scopeList)
667 const wf = nodes.find(n => n.id === scope.wfId)
668 const start = Math.min(...scopeList.map(k => k.startedAt))
669 const end = c.running ? now : Math.max(...scopeList.map(k => k.endedAt ?? now))
670 meta = statusSegs({ status: c.running ? 'running' : c.failed ? 'failed' : 'done', startedAt: start, endedAt: end })
671 second = [...rollSegs(c, 12), seg(` ${c.total - c.running} of ${c.total} agents`, C.muted)]
672 const spend = spendSegs(scopeList)
673 rows = [['Agents', [seg(phaseWords({ phase: scope.phase, ...c }), C.ink)]], ['Changes', changeSegs(files)], ...(spend.length ? [['Spend', spend] as [string, Seg[]]] : []), ['Part of', [seg(wf?.label ?? 'workflow', C.soft)]]]
674 } else if (scopeNode && scopeNode.kind !== 'agent') {
675 const n = scopeNode
676 const kids = childrenOf(nodes, n.id)
677 const c = counts(kids)
678 const busy = kids.find(x => x.status === 'running')
679 meta = statusSegs(n, when(n))
680 second = [...rollSegs(c, 12), seg(` ${c.total - c.running} of ${c.total} ${n.kind === 'group' ? 'members' : 'agents'}`, C.muted)]
681 const phases: Seg[] = pipeline(n, nodes).flatMap((p, i) => [...(i ? [seg(' → ', C.faint)] : []), seg(p.phase, C.ink), seg(` ${phaseWords(p)}`, C.muted)])
682 const inline = mode === 'agents' && n.kind === 'group' && (dense !== 'full' || layout !== 'split')
683 const overlap = n.kind === 'group' && !inline ? overlapSegs(n.id) : []
684 rows = [
685 n.kind === 'group' ? ['Agents', [seg(phaseWords({ phase: '', ...c }), C.ink)]] : ['Phases', phases],
686 ...(n.kind === 'group' && !inline ? [['Aspects', chipSegs(n.id, w - 11)] as [string, Seg[]]] : []),
687 ...(overlap.length ? [['Overlap', overlap] as [string, Seg[]]] : []),
688 ['Changes', changeSegs(files)],
689 ...(spendSegs(scopeList).length ? [['Spend', spendSegs(scopeList)] as [string, Seg[]]] : []),
690 n.status === 'running' && busy ? ['Now', [...nowSegs(busy), seg(` ${busy.label}`, C.muted)]] : ['Outcome', [seg(outcome(n) || '—', C.ink)]],
691 ]
692 points = n.points ?? []
693 } else if (scopeNode) {
694 const n = scopeNode
695 const inWf = isInWorkflow(n)
696 meta = statusSegs(n, when(n))
697 second = [seg([prettyType(n, inWf), prettyModel(n.model), inWf && n.phase ? `${n.phase} phase` : n.where && n.where !== 'main' ? whereLabel(n.where) : ''].filter(Boolean).join(' · '), C.muted)]
698 const quiet = quietFor(n)
699 rows = [
700 n.status === 'running' ? ['Now', [...nowSegs(n), ...(quiet >= 120_000 ? [seg(` ${T.warn} quiet ${duration(quiet)}`, C.warn)] : [])]] : ['Outcome', [seg(outcome(n) || noReportReason(n), C.ink)]],
701 ['Changes', changeSegs(files)],
702 ['Work', [seg(`${workSummary(n)}${n.tokens ? ` · ${Math.round(n.tokens / 1000)}k tokens` : ''}`, C.soft)]],
703 ]
704 points = n.points ?? []
705 }
706 // finished work shows its report: the model's words, then what changed and the totals from records
707 const isFinished = scope.kind === 'phase' ? counts(scopeList).running === 0 && scopeList.length > 0 : !!scopeNode && scopeNode.status !== 'running'
708 const secs = isFinished ? reportSections(scope, w - 11, false, dense === 'brief') : undefined
709 const out: Row[] = []
710 if (dense === 'compact') {
711 const changed = files.length ? changeSegs(files) : []
712 const titleW = Math.max(8, w - width(changed) - 3)
713 const shownTitle = fit(title, titleW)
714 out.push({ segs: [seg(shownTitle, C.ink, true), seg(' '.repeat(Math.max(3, w - cellWidth(shownTitle) - width(changed)))), ...changed] })
715 const what = scope.kind === 'node' && scopeNode?.kind === 'agent' ? second : rows[0]?.[1] ?? []
716 out.push({ segs: [...meta, ...(what.length ? [seg(' · ', C.faint), ...what] : [])] })
717 const result = secs?.find(([k]) => k === 'Result')?.[1][0]
718 const [name, value] = result ? ['Result', result] : rows.find(([k]) => k === 'Now' || k === 'Outcome') ?? rows[0] ?? ['', []]
719 out.push({ segs: [seg(padEnd(name, 10), C.muted), ...value] })
720 return out
721 }
722 for (const line of wrap(title, w).slice(0, dense === 'brief' ? 1 : 2)) out.push({ segs: [seg(line, C.ink, true)] })
723 out.push({ segs: meta })
724 if (dense !== 'brief') { out.push({ segs: second }); out.push({ segs: [] }) }
725 if (secs) {
726 // a parent keeps its one status line ("6 done"); the report follows
727 const status = scope.kind === 'phase' || scopeNode?.kind !== 'agent' ? rows.filter(([k]) => k === 'Agents' || k === 'Phases' || k === 'Spend') : []
728 for (const [name, value] of status) out.push({ segs: [seg(padEnd(name, 11), C.muted), ...value] })
729 out.push(...sectionRows(secs))
730 if (scopeNode?.kind === 'agent') out.push(...rows.filter(([k]) => k === 'Work').map(([name, value]) => ({ segs: [seg(padEnd(name, 11), C.muted), ...value] })))
731 return out
732 }
733 for (const [name, value] of rows) {
734 const valueW = w - 11
735 // a long sentence wraps under its value column
736 if (value.length === 1 && cellWidth(value[0]!.t) > valueW) {
737 wrap(value[0]!.t, valueW).slice(0, 3).forEach((t, i) => out.push({ segs: [seg(padEnd(i ? '' : name, 11), C.muted), seg(t, value[0]!.c)] }))
738 } else out.push({ segs: [seg(padEnd(name, 11), C.muted), ...value] })
739 }
740 if (layout === 'split' && points.length) {
741 out.push({ segs: [] })
742 out.push({ segs: [seg('Key points', C.accent, true)] })
743 for (const p of points) wrap(p, w - 3).forEach((t, i) => out.push({ segs: [seg(i ? ' ' : ' • ', C.faint), seg(t, C.soft)] }))
744 }
745 return out
746 }
747
748 // ── output text ──────────────────────────────────────────────────────
749 const outRows = (w: number): Row[] => {
750 if (mode !== 'output') return []
751 const out: Row[] = []
752 const para = (t: string, c?: string, b?: boolean) => wrap(t, w).forEach(text => out.push({ segs: [seg(text, c, b)] }))
753 const gap = () => out.push({ segs: [] })
754 // finished work opens with its whole report; the agents' own words follow
755 const reportBlock = (sc: Scope) => {
756 out.push({ segs: [seg('Report', C.accent, true), seg(` ${writtenBy(sc)}`, C.faint)] })
757 out.push(...sectionRows(reportSections(sc, w - 11, true)))
758 gap()
759 }
760 if (scope?.kind === 'phase') {
761 if (scopeList.length && counts(scopeList).running === 0) reportBlock(scope)
762 para(`What the ${scope.phase} phase reported`, C.accent, true)
763 for (const k of scopeList) {
764 gap()
765 para(k.label, C.ink, true)
766 para(k.summary ?? (firstSentence(k.result, 300) || noReportReason(k)), C.soft)
767 }
768 return out
769 }
770 const n = scopeNode
771 if (!n) return out
772 // the run's brief, when it has one or one is being written; else how to get one
773 if (run && n.id === run.id) {
774 const writing = props.explaining?.includes(run.id)
775 const b: Brief | undefined = run.explain
776 if (b || writing) {
777 out.push({ segs: [seg('Explain', C.accent, true), seg(` ${writing ? 'writing… (Sonnet)' : aiLabel(b)}`, C.faint)] })
778 if (b) {
779 const LBL = 18
780 const vw = Math.max(10, w - LBL)
781 const line = (name: string, t: string, c: string) => wrap(t, vw).forEach((x, i) => out.push({ segs: [seg(padEnd(i ? '' : name, LBL), C.muted), seg(x, c)] }))
782 const list = (name: string, items: string[], c: string) => items.forEach((t, k) => wrap(t, vw - 3).forEach((x, i) =>
783 out.push({ segs: [seg(padEnd(k === 0 && i === 0 ? name : '', LBL), C.muted), seg(i ? ' ' : '• ', C.faint), seg(x, c)] })))
784 if (b.goal) line('Goal', b.goal, C.ink)
785 if (b.who?.length) list('Who did what', b.who, C.soft)
786 if (b.connected) line('How it connected', b.connected, C.soft)
787 if (b.outcome) line('Outcome', b.outcome, C.ink)
788 if (b.open?.length) list('Open issues', b.open, C.warn)
789 }
790 gap()
791 } else if (isFull) {
792 out.push({ segs: [seg('e', C.soft, true), seg(' asks Sonnet to explain how this run hung together.', C.faint)] })
793 gap()
794 }
795 }
796 const isDone = n.status !== 'running'
797 if (isDone && scope) reportBlock(scope)
798 if (!isDone && n.summary) { para('Outcome', C.accent, true); para(n.summary, C.ink) }
799 if (!isDone && n.points?.length) { gap(); para('Key points', C.accent, true); n.points.forEach(p => para(`• ${p}`, C.soft)) }
800 if (n.kind !== 'agent') {
801 if (!isDone) gap()
802 para(n.kind === 'group' ? 'Members' : 'Agents', C.accent, true)
803 for (const k of childrenOf(nodes, n.id)) para(`${k.phase ? `${k.phase} · ` : ''}${k.label} — ${k.summary ?? (firstSentence(k.result, 160) || noReportReason(k))}`, C.soft)
804 }
805 const report = n.kind === 'workflow' ? readableResult(n.result) : n.result
806 if (report) {
807 gap(); para(n.kind === 'workflow' ? 'Final result' : 'Full report', C.accent, true)
808 for (const line of report.split('\n')) para(plain(line) || ' ', C.muted)
809 }
810 if (!isDone && !report && !n.summary) para('Still working — the report appears here when it finishes.', C.muted)
811 const task = taskText(n.prompt)
812 if (task) { gap(); para('Task it was given', C.accent, true); para(task, C.muted) }
813 return out
814 }
815
816 // ── two runs side by side: totals, phases, spend by model, files ──────
817 const compareRows = (w: number): Row[] => {
818 if (mode !== 'compare') return []
819 const [a, b] = pair as [AgentNode, AgentNode]
820 const cmp = compareRuns(allNodes, a, b, now)
821 const out: Row[] = []
822 const gap = () => out.push({ segs: [] })
823 const fmt = (m: Metric, v: number | undefined) => (v === undefined ? '—' : m.measure === 'ms' ? duration(v) : m.measure === 'tokens' ? kTokens(v) : num(v))
824 const nameW = Math.min(16, Math.max(8, ...[...cmp.totals, ...cmp.phases, ...cmp.models].map(m => cellWidth(m.name)))) + 2
825 const colW = Math.max(9, Math.min(14, Math.floor((w - nameW - 18) / 2)))
826 const change = (m: Metric): Seg => {
827 const v = verdict(m)
828 if (m.a === undefined || m.b === undefined) return seg(m.a === undefined ? 'only in B' : 'only in A', C.faint)
829 if (m.a === m.b) return seg('same', C.faint)
830 const d = m.b - m.a
831 const pct = m.a ? ` (${d > 0 ? '+' : ''}${Math.round((d / m.a) * 100)}%)` : ''
832 const abs = m.measure === 'ms' ? duration(Math.abs(d)) : m.measure === 'tokens' ? kTokens(Math.abs(d)) : num(Math.abs(d))
833 return seg(`${d > 0 ? '+' : '−'}${abs}${pct}`, v === 'better' ? C.ok : v === 'worse' ? C.bad : C.muted)
834 }
835 const table = (title: string, list: Metric[]) => {
836 if (!list.length) return
837 out.push({ segs: [seg(padEnd(title, nameW), C.accent, true), seg(padStart('A', colW), C.muted, true), seg(padStart('B', colW), C.muted, true), seg(' change', C.muted, true)] })
838 for (const m of list) out.push({ segs: [seg(padEnd(fit(m.name, nameW - 2), nameW), C.soft), seg(padStart(fmt(m, m.a), colW), C.ink), seg(padStart(fmt(m, m.b), colW), C.ink), seg(' '), change(m)] })
839 gap()
840 }
841 const who = (k: string, n: AgentNode) => [seg(`${k} `, C.accent, true), seg(statusIcon(n), T.status[n.status].color), seg(' '), seg(n.label, C.ink, true), seg(` ${dayLabel(n.startedAt, now)} ${clock(n.startedAt)}`, C.muted)]
842 out.push({ segs: who('A', a) }, { segs: who('B', b) })
843 gap()
844 table('Totals', cmp.totals)
845 table('Phases', cmp.phases)
846 table('Tokens by model', cmp.models)
847 const files = (title: string, list: string[]) => {
848 if (!list.length) return
849 const short = shortPaths(list)
850 out.push({ segs: [seg(title, C.accent, true), seg(` ${list.length}`, C.faint)] })
851 wrap(list.map(p => short.get(p) ?? p).join(' · '), w - 3).slice(0, 6).forEach(l => out.push({ segs: [seg(' '), seg(l, C.soft)] }))
852 }
853 files('Files only A changed', cmp.files.onlyA)
854 files('Files only B changed', cmp.files.onlyB)
855 files('Files both changed', cmp.files.both)
856 if (!cmp.files.onlyA.length && !cmp.files.onlyB.length && !cmp.files.both.length) out.push({ segs: [seg('Neither run changed files.', C.muted)] })
857 return out
858 }
859
860 // ── insights: one row on narrow panes, up to two on wide ones ─────────
861 const insightRows = (w: number): Row[] => {
862 if (!shownInsights.length || mode === 'help') return []
863 const piece = (it: Insight, i: number): Seg[] => {
864 const on = i === s.insight
865 const mark = it.tone === 'bad' ? T.status.failed.icon : it.tone === 'warn' ? T.warn : '·'
866 const color = it.tone === 'bad' ? C.bad : it.tone === 'warn' ? C.warn : C.soft
867 return [seg(`${mark} `, color), seg(it.text, on ? C.accent : color, on)]
868 }
869 const label = [seg(layout === 'compact' ? '' : 'Needs a look ', C.muted, true)]
870 const rows: Row[] = []
871 let cur: Seg[] = [...label]
872 let hits: Hit[] = []
873 const maxRows = layout === 'compact' ? 1 : 2
874 for (let i = 0; i < shownInsights.length; i++) {
875 const p = [...(width(cur) > width(label) ? [seg(' ')] : []), ...piece(shownInsights[i]!, i)]
876 const rest = shownInsights.length - i
877 const tail = rows.length + 1 === maxRows && rest > 1 ? 14 : 0
878 if (width(cur) + width(p) + tail > w && width(cur) > width(label)) {
879 if (rows.length + 1 === maxRows) { cur.push(seg(` +${rest} more · i`, C.faint)); break }
880 rows.push({ segs: cur, hit: hits })
881 cur = [seg(' '.repeat(width(label)))]
882 hits = []
883 i--
884 continue
885 }
886 const x0 = width(cur)
887 const target = shownInsights[i]!
888 const idx = i
889 hits.push({ x0, x1: x0 + width(p), act: () => jumpTo(target.target, idx) })
890 cur.push(...p)
891 }
892 rows.push({ segs: cur, hit: hits })
893 return rows
894 }
895
896 // ── fixed blocks: top rows, the body, bottom rows ─────────────────────
897 const isReading = mode === 'output' || mode === 'diff' || mode === 'help' || mode === 'trace' || mode === 'compare'
898 // the History page's numbers on top, and the search line while there is one
899 const isHistoryPage = isHistory && !run && lens === 'tree'
900 // runs that match, not the runs a pattern card points at
901 const matchCount = isHistoryPage ? agentItems.filter(it => it.kind === 'node' && it.guide !== 'pattern').length : 0
902 const historyHeader = (): Row[] => {
903 const st = historyStats(nodes, now)
904 const sp = sparkline(st.perDay)
905 // the numbers first; then what the AI layer spent; the sparkline's caption only where it fits
906 const base: Seg[] = [seg(`${st.runs}`, C.ink, true), seg(` run${st.runs === 1 ? '' : 's'}`, C.muted), seg(' '),
907 seg(`${st.okPct}%`, C.ink, true), seg(' finished OK', C.muted), seg(' '),
908 seg(kTokens(st.tokens), C.ink, true), seg(' tokens', C.muted), seg(' '), seg(sp.glyphs, C.accent)]
909 // the sparkline's scale keeps its place (a chart needs its extremes); AI spend fits around it
910 const spent = props.aiWeek ?? 0
911 const longCap = ` runs per day, last 7 days · 0 to ${sp.max}`
912 const shortCap = ` per day, 7 days · 0 to ${sp.max}`
913 const longAi = spent ? ` AI: ${kTokens(spent)} tokens this week` : ''
914 const shortAi = spent ? ` AI ${kTokens(spent)} this week` : ''
915 const [cap, ai] = ([[longCap, longAi], [shortCap, longAi], [shortCap, shortAi], [shortCap, ''], ['', '']] as [string, string][])
916 .find(([c, a]) => width(base) + cellWidth(c) + cellWidth(a) <= IW) ?? ['', '']
917 const rows: Row[] = [{ segs: [...base, ...(cap ? [seg(cap, C.faint)] : []), ...(ai ? [seg(ai, C.faint)] : [])] }]
918 if (s.searching || s.query) {
919 rows.push({
920 segs: [seg('/ ', C.accent, true), seg(s.query, C.ink, true), ...(s.searching ? [seg(T.cursor, C.accent)] : []),
921 seg(` ${matchCount} match${matchCount === 1 ? '' : 'es'}`, C.muted),
922 seg(s.searching ? ' enter keep · ctrl+u clear · ⌫ delete' : ' / edit · ctrl+u clear', C.faint)],
923 })
924 }
925 return rows
926 }
927 const strip = isHistoryPage && mode !== 'help' ? historyHeader() : insightRows(IW)
928 const fullOverview = (() => {
929 // History's rows carry each run's result; a run's report shows beside them from 140 columns
930 if (layout === 'split' || isReading || isHistoryPage) return []
931 if (layout === 'compact') return overview(IW, 'compact')
932 // the whole report when the list keeps 10 rows; else its brief form; else three lines
933 const rowsLeft = (o: Row[]) => CH - 1 - 1 - (strip.length ? strip.length + 1 : 0) - 1 - 2 - (o.length + 1)
934 const full = overview(IW, 'full')
935 if (rowsLeft(full) >= 10) return full
936 const brief = overview(IW, 'brief')
937 return rowsLeft(brief) >= 8 ? brief : overview(IW, 'compact')
938 })()
939 const TOP = 1 + 1 + (strip.length ? strip.length + 1 : 0) + (fullOverview.length ? fullOverview.length + 1 : 0) + 1 // tabs, rule, insights + rule, overview + rule, caption
940 const BOTTOM = 2 // rule, keys
941 const bodyRows = Math.max(1, CH - TOP - BOTTOM)
942 const isSplit = layout === 'split' && (mode === 'agents' || mode === 'files' || mode === 'board' || mode === 'timeline')
943 const listW = isSplit ? LW : IW
944 const output = outRows(IW)
945 const comparison = compareRows(IW)
946 // the views that scroll as text, a page at a time
947 const isText = mode === 'output' || mode === 'compare' || mode === 'help'
948
949 // ── the board and the timeline: rows built at a width, cards placed on them ──
950 type Body = { rows: Row[]; cardRow: number[] }
951 const cursorSeg = (on: boolean) => seg(on ? T.cursor : ' ', C.accent)
952 const cell2 = (g: string, c?: string) => seg(padEnd(g, 2), c)
953 const emptyRows = (w: number): Row[] => [
954 { segs: [] },
955 { segs: [seg(' '), seg(isHistory ? T.words.emptyHistory : T.words.emptyLive, C.ink)] },
956 { segs: [] },
957 ...wrap(isHistory ? 'Finished subagents and workflows are kept here across sessions.' : T.words.emptyHint, w - 2).map(t => ({ segs: [seg(' '), seg(t, C.muted)] })),
958 ]
959 const cardMeta = (n: AgentNode): Seg[] => {
960 const quiet = quietFor(n)
961 if (n.status === 'running' && quiet >= 120_000) return [seg(`${T.warn} quiet ${duration(quiet)}`, C.warn)]
962 const rows = fileRows([n])
963 const added = rows.reduce((a, r) => a + r.added, 0)
964 const removed = rows.reduce((a, r) => a + r.removed, 0)
965 return [seg(duration(elapsed(n)), C.muted), ...(rows.length ? [seg(' '), seg(`+${added}`, C.add), seg(' '), seg(`−${removed}`, C.del)] : []),
966 ...(n.status === 'running' && n.activity ? [seg(` ${n.activity}`, C.faint)] : [])]
967 }
968 const boardBody = (w: number): Body => {
969 const rows: Row[] = []
970 const cardRow: number[] = []
971 const gap = 2
972 const colW = Math.floor((w - gap * 2) / 3)
973 const narrow = colW < 22
974 const totals = COLUMNS.map((_, ci) => laneCells.reduce((a, cells) => a + cells[ci]!.length, 0))
975 rows.push({
976 segs: COLUMNS.flatMap((col, ci) => {
977 const color = col === 'running' ? T.status.running.color : col === 'done' ? T.status.done.color : T.status.failed.color
978 return [...(ci ? [seg(' '.repeat(gap))] : []), seg(padEnd(fit(`${COLUMN_NAMES[col][narrow ? 1 : 0]} ${totals[ci]}`, colW), colW), color, true)]
979 }),
980 })
981 if (!lanes.length) return { rows: [...rows, ...emptyRows(w)], cardRow }
982 let k = 0
983 lanes.forEach((lane, li) => {
984 const cells = laneCells[li]!
985 const c = counts(lane.cards)
986 const glyph = lane.kind === 'cluster' ? T.kind.group : lane.kind === 'workflow' ? T.kind.workflow : ''
987 rows.push({ segs: [] })
988 rows.push({ segs: [...(glyph ? [seg(`${glyph} `)] : []), seg(lane.title, C.ink, true), seg(' '), ...rollSegs(c, 6), seg(` ${c.total - c.running}/${c.total}`, C.muted)] })
989 const idx = cells.map(list => list.slice(0, CARDS_PER_CELL).map(() => k++))
990 const depth = Math.min(CARDS_PER_CELL, Math.max(...cells.map(l => l.length)))
991 for (let r = 0; r < depth; r++) {
992 const line1: Seg[] = []
993 const line2: Seg[] = []
994 const hits: Hit[] = []
995 let x = 0
996 cells.forEach((list, ci) => {
997 if (ci) { line1.push(seg(' '.repeat(gap))); line2.push(seg(' '.repeat(gap))); x += gap }
998 const n = list[r]
999 if (!n) { line1.push(seg(' '.repeat(colW))); line2.push(seg(' '.repeat(colW))); x += colW; return }
1000 const at = idx[ci]![r]!
1001 const on = mode === 'board' && at === sel
1002 const bg = on ? C.selBg : undefined
1003 line1.push(...lay([cursorSeg(on), seg(statusIcon(n), T.status[n.status].color), seg(' '), seg(n.label, on ? C.ink : C.soft, on)], colW).map(sg => ({ ...sg, bg })))
1004 line2.push(...lay([seg(' '), ...cardMeta(n)], colW).map(sg => ({ ...sg, bg })))
1005 hits.push({ x0: x, x1: x + colW, act: () => pickCard(at) })
1006 cardRow[at] = rows.length
1007 x += colW
1008 })
1009 rows.push({ segs: line1, hit: hits })
1010 rows.push({ segs: line2, hit: hits })
1011 }
1012 const extra = cells.map(list => Math.max(0, list.length - CARDS_PER_CELL))
1013 if (extra.some(Boolean)) {
1014 rows.push({ segs: cells.flatMap((_, ci) => [...(ci ? [seg(' '.repeat(gap))] : []), seg(padEnd(extra[ci] ? ` +${extra[ci]} more` : '', colW), C.faint)]) })
1015 }
1016 })
1017 return { rows, cardRow }
1018 }
1019 const timeBody = (w: number): Body => {
1020 const rows: Row[] = []
1021 const cardRow: number[] = []
1022 if (!timeCards.length) return { rows: [{ segs: [] }, ...emptyRows(w)], cardRow }
1023 const labelW = Math.min(32, Math.max(16, Math.floor(w * 0.34)))
1024 const durW = 8
1025 const barW = Math.max(10, w - labelW - durW - 2)
1026 const t0 = Math.min(...timeCards.map(n => n.startedAt))
1027 const t1 = Math.max(t0 + 1000, ...timeCards.map(n => n.endedAt ?? now))
1028 const span = t1 - t0
1029 const xOf = (t: number) => Math.round(((t - t0) / span) * barW)
1030 // the ruler: times since the first start, spaced so labels never touch
1031 const ruler = new Array<string>(barW).fill(' ')
1032 const ticks = Math.max(1, Math.min(5, Math.floor(barW / 12)))
1033 for (let i = 0; i <= ticks; i++) {
1034 const label = offset((i / ticks) * span)
1035 const x = Math.round((i / ticks) * (barW - 1))
1036 const at = Math.max(0, Math.min(barW - label.length, i === ticks ? x - label.length + 1 : x))
1037 for (let j = 0; j < label.length; j++) ruler[at + j] = label[j]!
1038 }
1039 // left of the ruler, inside a run: how long the critical path took, and the idle share
1040 const pathNote = crit ? `━ critical ${duration(crit.ms)}${idle.ms && crit.span ? ` · idle ${Math.round((idle.ms / crit.span) * 100)}%` : ''}` : ''
1041 rows.push({ segs: [seg(padEnd(fit(pathNote, labelW), labelW), C.accent), seg(' '), seg(ruler.join(''), C.faint), seg(' '.repeat(durW + 1))] })
1042 // idle stretches shade every row with dots, as a column
1043 const idleAt = new Array<boolean>(barW).fill(false)
1044 for (const g of idle.gaps) for (let x = Math.max(0, xOf(g.from)); x < Math.min(barW, xOf(g.to)); x++) idleAt[x] = true
1045 let k = 0
1046 for (const r of timeRows) {
1047 if (r.kind === 'label') { rows.push({ segs: [seg(' '), seg(r.text, C.muted, true)] }); continue }
1048 const n = r.node
1049 const at = k++
1050 const on = mode === 'timeline' && at === sel
1051 const color = T.status[n.status].color
1052 const x0 = Math.min(barW - 1, xOf(n.startedAt))
1053 const x1 = Math.max(x0 + 1, Math.min(barW, xOf(n.endedAt ?? now)))
1054 const isLive = n.status === 'running'
1055 // with a critical path, its bars are heavy and the others light
1056 const isCrit = onPath.has(n.id)
1057 const line = !crit || isCrit ? '━' : '─'
1058 const quiet = quietFor(n)
1059 const flag = flags.get(n.id)
1060 const tag = isLive ? (quiet >= 120_000 ? ` quiet ${duration(quiet)}` : ' running') : flag ? ` ${flag.text}` : ''
1061 const tagShown = tag && barW - x1 >= cellWidth(tag) ? tag : ''
1062 const tagColor = isLive ? (quiet >= 120_000 ? C.warn : C.faint) : flag?.tone === 'warn' ? C.warn : C.faint
1063 // the bar's cells: idle dots, the bar, then its tag
1064 const cells: Seg[] = []
1065 const put = (t: string, c?: string) => { const last = cells[cells.length - 1]; if (last && last.c === c) last.t += t; else cells.push({ t, c }) }
1066 for (let x = 0; x < x0; x++) put(idleAt[x] ? '·' : ' ', idleAt[x] ? C.rule : undefined)
1067 put(isLive ? `${line.repeat(Math.max(0, x1 - x0 - 1))}▸` : line.repeat(x1 - x0), color)
1068 if (tagShown) put(tagShown, tagColor)
1069 for (let x = x1 + cellWidth(tagShown); x < barW; x++) put(idleAt[x] ? '·' : ' ', idleAt[x] ? C.rule : undefined)
1070 const label = lay([cursorSeg(on), seg(' '), cell2(statusIcon(n), color), seg(' '), seg(n.label, on || isLive || isCrit ? C.ink : C.soft, on || isCrit)], labelW)
1071 rows.push({
1072 bg: on ? C.selBg : undefined,
1073 segs: [...label, seg(' '), ...cells, seg(' '), seg(padStart(duration(elapsed(n)), durW), C.muted)],
1074 hit: [{ x0: 0, x1: w, act: () => pickCard(at) }],
1075 })
1076 cardRow[at] = rows.length - 1
1077 }
1078 return { rows, cardRow }
1079 }
1080
1081 // ── selection, scrolling ─────────────────────────────────────────────
1082 const isLens = mode === 'board' || mode === 'timeline'
1083 // the trace the hooks sent for this run at this zoom; its rows are a window of all of them
1084 // (cut to the lane asked for; while a search is typed the rows stay, and only the hits wait)
1085 const tq = s.tquery.trim().slice(0, 80)
1086 const tv: TraceView | undefined = mode === 'trace' && run && props.trace?.run === run.id && props.trace.zoom === s.zoom && (props.trace.lane ?? null) === s.traceLane ? props.trace : undefined
1087 // the rows n and p step through: the search's hits while there is a search, else what needs a look
1088 const traceStops = tv ? (tq ? (tv.query === tq ? tv.hits ?? [] : []) : tv.marks ?? []) : []
1089 // under the trace: two pinned rows above (legend, lanes), a rule and two detail rows below
1090 const traceWin = mode === 'trace' ? Math.max(1, bodyRows - 5) : 0
1091 const count = mode === 'agents' ? agentItems.length : mode === 'files' ? fileItems.length : mode === 'diff' ? blocks.length
1092 : mode === 'output' ? output.length : mode === 'compare' ? comparison.length : mode === 'help' ? KEYS_HELP_COUNT : isLens ? cards.length : mode === 'trace' ? tv?.total ?? 0 : 0
1093 const okRow = (i: number) => (mode === 'agents' ? isSelectable(agentItems[i]) : true)
1094 const rawSel = mode === 'agents' ? agentSel : isLens ? cardSel : s.sel
1095 const sel = isText ? 0 : Math.min(Math.max(0, rawSel), Math.max(0, count - 1))
1096 const body: Body | undefined = mode === 'board' ? boardBody(listW) : mode === 'timeline' ? timeBody(listW) : undefined
1097 // the diff scrolls by edit block, the selected one at the top; the board and the
1098 // timeline by rows under their pinned first row, keeping the selected card in sight
1099 let first = isText ? s.first : mode === 'diff' ? sel : Math.min(s.first, sel)
1100 if (body) {
1101 const win = Math.max(1, bodyRows - 1)
1102 const total = body.rows.length - 1
1103 const top = (body.cardRow[sel] ?? 1) - 1
1104 const tall = mode === 'board' ? 2 : 1
1105 first = Math.min(s.first, total)
1106 if (top < first) first = Math.max(0, top - (mode === 'board' ? 2 : 0))
1107 if (top + tall > first + win) first = top + tall - win
1108 first = Math.max(0, Math.min(first, Math.max(0, total - win)))
1109 } else {
1110 const win = mode === 'trace' ? traceWin : bodyRows
1111 if (!isText && mode !== 'diff' && sel >= first + win) first = sel - win + 1
1112 if (mode !== 'diff') first = Math.max(0, Math.min(first, Math.max(0, count - win)))
1113 }
1114
1115 // ── actions ──────────────────────────────────────────────────────────
1116 const set = (patch: Partial<State>) => surface.setState({ ...s, sel, first, ...patch })
1117 const toggleIn = (list: string[], key: string) => (list.includes(key) ? list.filter(k => k !== key) : [...list, key])
1118 const closeDiff = () => { surface.post({ type: 'diff', path: null }); return { diffFile: null } }
1119 const topOf = (n: AgentNode) => { let x = n; while (x.parentId) { const p = nodes.find(m => m.id === x.parentId); if (!p) break; x = p } return x }
1120 const cardsFor = (at: AgentNode | undefined, l: Lens) => {
1121 if (l === 'board') return boardLanes(at, nodes, topRuns, s.filter).flatMap(lane => COLUMNS.flatMap(col => lane.cards.filter(n => columnOf(n) === col).slice(0, CARDS_PER_CELL)))
1122 return timelineRows(at, nodes, topRuns, s.filter).flatMap(r => (r.kind === 'bar' ? [r.node] : []))
1123 }
1124 // where a key sits in a lens's list, so a selection survives a change of view
1125 const indexIn = (key: string | null, at: AgentNode | undefined, l: Lens, flipped = s.flipped, showDone = s.showDone): number => {
1126 // a trace comes back to the row it was left on
1127 if (l === 'trace') return s.traceAt
1128 if (!key) return -1
1129 if (l === 'tree') return listFor(flipped, showDone, at).findIndex(it => keyOf(it) === key)
1130 return cardsFor(at, l).findIndex(n => n.id === key)
1131 }
1132 const go = (v: ViewName, extra: Partial<State> = {}) => {
1133 if (!run || v === view) return
1134 const keep = inAgents ? { agentSel: sel, focus: focusKey } : {}
1135 const leaving = diffOpen ? closeDiff() : {}
1136 const back = v === 'agents' ? Math.max(0, indexIn(s.focus, run, lens)) : 0
1137 set({ ...keep, ...leaving, ...extra, view: v, sel: v === 'agents' ? (inAgents ? sel : back) : 0, first: 0 })
1138 }
1139 const openRun = (n: AgentNode, v: ViewName = 'agents', focus: string | null = null) =>
1140 set({ path: n.id, view: v, sel: v === 'agents' ? Math.max(0, indexIn(focus, n, lens)) : 0, first: 0, agentSel: 0, focus, diffFile: null })
1141 const openDiff = (path: string) => { surface.post({ type: 'diff', path }); set({ diffFile: path, sel: 0, first: 0 }) }
1142 // Opens what hides a node in a run's tree: its phase, the done agents folded
1143 // under it, and any parent that was closed.
1144 const reveal = (n: AgentNode, at: AgentNode) => {
1145 let flipped = [...s.flipped]
1146 let showDone = [...s.showDone]
1147 for (let x: AgentNode | undefined = nodes.find(m => m.id === n.parentId); x; x = nodes.find(m => m.id === x!.parentId)) {
1148 flipped = flipped.filter(k => k !== `o:${x.id}`)
1149 if (x.id === at.id) break
1150 }
1151 const wf = nodes.find(m => m.id === n.parentId && m.kind === 'workflow')
1152 if (wf && n.phase) {
1153 const key = `${wf.id}:${n.phase}`
1154 const phaseRow = listFor(flipped, showDone, at).find(it => it.kind === 'phase' && it.key === key)
1155 if (phaseRow?.kind === 'phase' && !phaseRow.isOpen) flipped = toggleIn(flipped, key)
1156 const shown = listFor(flipped, showDone, at).some(it => it.kind === 'node' && it.node.id === n.id)
1157 if (!shown && !showDone.includes(key)) showDone = [...showDone, key]
1158 }
1159 return { flipped, showDone }
1160 }
1161 // From a change, or an insight, to the agent it names, in the tree.
1162 const showInTree = (id: string, extra: Partial<State> = {}) => {
1163 const n = nodes.find(x => x.id === id)
1164 if (!n) return
1165 const at = topOf(n)
1166 if (!run && at.id === n.id && n.kind === 'agent') {
1167 const i = listFor(s.flipped, s.showDone, undefined).findIndex(it => it.kind === 'node' && it.node.id === id)
1168 return set({ ...extra, lens: 'tree', sel: Math.max(0, i) })
1169 }
1170 const { flipped, showDone } = reveal(n, at)
1171 const i = listFor(flipped, showDone, at).findIndex(it => it.kind === 'node' && it.node.id === id)
1172 if (diffOpen) surface.post({ type: 'diff', path: null })
1173 surface.setState({ ...s, ...extra, path: at.id, view: 'agents', lens: 'tree', diffFile: null, flipped, showDone, sel: Math.max(0, i), agentSel: Math.max(0, i), first: Math.max(0, i - 2) })
1174 }
1175 const jumpTo = (id: string | undefined, idx: number) => {
1176 if (!id) return set({ insight: idx })
1177 if (isLens && inAgents) {
1178 const i = cards.findIndex(c => c.id === id)
1179 if (i >= 0) return set({ sel: i, insight: idx })
1180 }
1181 showInTree(id, { insight: idx })
1182 }
1183 const toggleFold = (it: Item | undefined, open?: boolean) => {
1184 if (it?.kind === 'node' && it.fold && open !== it.fold.isOpen) return set({ flipped: toggleIn(s.flipped, it.fold.key) }), true
1185 if (it?.kind === 'phase' && open !== it.isOpen) return set({ flipped: toggleIn(s.flipped, it.key) }), true
1186 if (it?.kind === 'more' && open !== false) return set({ showDone: toggleIn(s.showDone, it.key) }), true
1187 return false
1188 }
1189 const activateCard = (i: number) => {
1190 const n = cards[i]
1191 if (!n) return
1192 if (!run) return openRun(topOf(n), 'agents', n.id)
1193 set({ agentSel: i, focus: n.id, view: 'output', sel: 0, first: 0 })
1194 }
1195 const pickCard = (i: number) => (i === sel ? activateCard(i) : set({ sel: i }))
1196 // a trace row, when the window the hooks sent holds it
1197 const traceRow = (i: number): TraceRow | undefined => (tv && i >= tv.offset && i < tv.offset + tv.rows.length ? tv.rows[i - tv.offset] : undefined)
1198 const laneId = (r: TraceRow | undefined) => (r && tv ? tv.lanes[r.lane]?.id : undefined)
1199 // Enter on a trace row: an edit opens its diff; any other row shows its agent in the tree
1200 const activateTrace = (i: number) => {hooks/desktop/sprites.ts 701 lines1// Pixel art for the desktop view: one set per playful theme, each a palette and text
2// grids ("." is clear), drawn as SVG squares with crisp edges. Every set fills the
3// same slots, so the view asks for "done" or "workflow" and each theme answers with
4// its own picture: kitty's cats, fish on lines, dogs on walks. Each state reads by
5// its outline at 16 px; failed and quiet lie down where the sprite has a body to lay.
6export const SLOTS = ['walk0', 'walk1', 'walk2', 'walk3', 'done', 'failed', 'stopped', 'quiet', 'workflow', 'group', 'scene'] as const
7export type Slot = typeof SLOTS[number]
8export type ArtSet = { pal: Record<string, string>; pix: Record<Slot, readonly string[]> }
9export type ArtName = 'kitty' | 'fish' | 'dog'
10
11// Cats: generated from the approved mockup's pixel data; workflow is yarn, a cluster a pile.
12const KITTY: ArtSet = {
13 pal: {"o": "#2e1f2b", "a": "#ffd09a", "b": "#f59a4f", "c": "#c96a2e", "d": "#a8522a", "H": "#d5dae3", "G": "#9fa8ba", "I": "#6e778b", "M": "#86799e", "N": "#5a4d70", "n": "#3f3550", "w": "#fff6ea", "x": "#e3cdb8", "p": "#f4899a", "q": "#d9667b", "g": "#ffffff", "z": "#8fb0f0", "Z": "#5f86d8", "B": "#f6eedc", "C": "#d8cbb0", "R": "#e5485a", "T": "#7cc8f4", "l": "#ffb3c0", "y": "#ec6a80", "Y": "#b94860", "L": "#bdb6d3", "K": "#8e87a9", "k": "#5a5373", "s": "#3a3449", "r": "#ffcf4d"},
14 pix: {
15 walk0: [
16 '................',
17 '.........o....o.',
18 '........oco..oco',
19 '.o......opoooapo',
20 'oco.....oabaaaco',
21 'oco.....oabbbgco',
22 'oao..ooooabbboco',
23 'oabooadadabbwxpo',
24 '.obbdabbbbbcxxo.',
25 '..obbbbbbbbcoo..',
26 '..oabbbbbcbco...',
27 '..occbcbbcoco...',
28 '..ocoococcooco..',
29 '.oco..occo.oco..',
30 '..w....ww...ow..',
31 '................',
32 ],
33 walk1: [
34 '..............o.',
35 '.........o...oco',
36 '........oaooooco',
37 '.o......opbaaapo',
38 'oco.....oabbbgco',
39 'oco.....oabbboco',
40 'oao.oooooabbbwpo',
41 'oaboaadadabcxxxo',
42 '.obbdbbbbbbcooo.',
43 '..obbbbbbbbco...',
44 '...ocbbbbbbco...',
45 '...ococccbbco...',
46 '...ococooobco...',
47 '...ococo.occo...',
48 '....w.w...ww....',
49 '................',
50 ],
51 walk2: [
52 '................',
53 '.........o....o.',
54 '........oco..oco',
55 '.o......opoooapo',
56 'oco.....oabaaaco',
57 'oco.....oabbbgco',
58 'oao..ooooabbboco',
59 'oabooadadabbwxpo',
60 '.obbdabbbbbcxxo.',
61 '..obbbbbbbbcoo..',
62 '..oabbbbbbcco...',
63 '...obccccbco....',
64 '...oacoooobo....',
65 '...occo..occo...',
66 '....ww....ww....',
67 '................',
68 ],
69 walk3: [
70 '..............o.',
71 '.........o...oco',
72 '........oaooooco',
73 '.o......opbaaapo',
74 'oco.....oabbbgco',
75 'oco.....oabbboco',
76 'oao.oooooabbbwpo',
77 'oaboaadadabcxxxo',
78 '.obbdbbbbbbcooo.',
79 '..obbbbbbbbco...',
80 '...ocbbbbcbco...',
81 '...ococcbcoco...',
82 '...ococoococo...',
83 '...ococoococo...',
84 '....w.w..w.ow...',
85 '................',
86 ],
87 done: [
88 '..o.......o.....',
89 '.oao.....oco....',
90 '.oapooooopco....',
91 '.oabbadaaaco....',
92 '.oabbbddbbco....',
93 '.oabbbbbbbco....',
94 '.oabobbbobco..o.',
95 '.oaobobobobo.oco',
96 '..obwwpwwbbbooco',
97 '...obowobbbdboco',
98 '....obbbbbbbdbco',
99 '....oabobbbbbbco',
100 '....owwbwwbbbcco',
101 '....owwbwwbccco.',
102 '....oxxcxxccoo..',
103 '.....ooooooo....',
104 ],
105 failed: [
106 '................',
107 '................',
108 '................',
109 '................',
110 '................',
111 '..o.....o.......',
112 '.oao...oco......',
113 '.oaBCBoaao......',
114 '.oapRCapbbooo...',
115 '.oabBbbbbbdado..',
116 '.oabbbbdbbbbbbo.',
117 '.ooobboobbbbbbco',
118 '..obbpbbbbbbbcco',
119 '.ToccbbbbccccccT',
120 'TT.occdccdccdc..',
121 '.......oooo.....',
122 ],
123 stopped: [
124 '.r..............',
125 '.roo.....oo...o.',
126 '.oaaoooooaco.oao',
127 '.rapbaaaaapooacc',
128 '.oabbbbbbbcooaco',
129 '.oabbbbbbbcooaco',
130 '.oabgbbbgbcooaco',
131 '.oabobbbobbooaco',
132 '.oabbwpwbbbbacco',
133 '..obwwpwwbbbbco.',
134 '...obbobbbbbbco.',
135 '...oabbbbbbbbco.',
136 '...owwbbbwwbcco.',
137 '...owxbbbwwcco..',
138 '...oxxoccxxco...',
139 '....oo.ooooo....',
140 ],
141 quiet: [
142 '................',
143 '..........zzz...',
144 '............z...',
145 '...........z....',
146 '..........zzz.ZZ',
147 '..o.....o......Z',
148 '.oao...oco....ZZ',
149 '.oaboooaao......',
150 '.oapbaapbbooo...',
151 '.oabbbbbbbdado..',
152 '.oabbbbdbbbbbbo.',
153 '.ooobboobbbbbbco',
154 '..obbpbbbbbbbcco',
155 '...occbbbbccccc.',
156 '...occdccdccdc..',
157 '.......oooo.....',
158 ],
159 workflow: [
160 '................',
161 '.....ooooo......',
162 '....olllllo.....',
163 '...ollyylyyo....',
164 '..ollyylyyyyo...',
165 '.ollyylyyylyYo..',
166 '.olyylyyylyyYo..',
167 '.olylyyylyyyYo..',
168 '.olyyyylyyyYYo..',
169 '.olyyylyyyYyYo..',
170 '.olyyyyyyYyYYo..',
171 '..oyYyyyYyYYo...',
172 '...oyYyYyYYo....',
173 '....oYYYYYo.yy..',
174 '.....ooooo....yy',
175 '................',
176 ],
177 group: [
178 '................',
179 '.o...o....o...o.',
180 'oHoooIo..oMooono',
181 'opGHHpo..opNMMpo',
182 'oHGGGGo..orNNNro',
183 'oHooGooooMoNNNoo',
184 'oHGGpIo..oNNpnno',
185 '.oIIIoo..oonnno.',
186 '..oooaoooocooo..',
187 '....oabaaaco....',
188 '....oabbbbbo....',
189 '...oaaobbboco...',
190 '...oaobobocoo...',
191 '....ocwwpxco....',
192 '.....ooccoo.....',
193 '.......oo.......',
194 ],
195 scene: [
196 '.....................................................ZZZZZ......',
197 '........................................................Z.......',
198 '.......................................................Z........',
199 '......................................................Z.........',
200 '.....................................................ZZZZZ......',
201 '................................................................',
202 '................................................ZZZZ............',
203 '..................................................Z.............',
204 '.................................................Z..............',
205 '................................................ZZZZ............',
206 '................................................................',
207 '..............o...........o.................zzz.................',
208 '.............oapoo.....oopco.................z..................',
209 '.............oappaoooooappco................zzz.................',
210 '.............oabbbbaaaaabbco.oooooooooo.........................',
211 '.............oabbbbbbbbbbbbooaadaadaaaaooo......................',
212 '.............oabbbbbbbbbbbbbaadbbbbdbbdbaaooo...................',
213 '.............oabbbbbbbbbbbbbbbbbbbbbbbbdbbbaao..................',
214 '............oaabbbbbbbbbbbbbbbbdbbbdbbbbbbdbbco.................',
215 '............oabobbbbbbbbbobbbbbbbbbbbbbbbbbbbbco................',
216 '............oabbooobbbooobbbbbbbbbbbbbbbbbbbbcco................',
217 '.............obbbbwwwwwbbbbbbbbbbbbbbbbbbbbbbbcco...............',
218 '.............ocbbwwwpwwwbbbbbbbbbbbbbbbbbbbbbbco................',
219 '..............ocbwwowowwbbbbbbbbbbbbbbbbbbbbbbcco...............',
220 '...............oobwwwwwbbbbbbbbbbbbbbbbbbbbbcccco...............',
221 '................oabbbbbbbbbbbbbbbbbbbbbbbccccccco...............',
222 '......oooooooooocccbbbbbbbbbbbbbbbbbbbbcccccccoooooooooooo......',
223 '.....oLLLLLLLLLLoccccccccccccccccccccccccccoooLLLLLLLLLLLLo.....',
224 '....oLLKKKKKKKKKKoococdcccccdcccccdcccccdoocKKKKKKKKKKKKKKko....',
225 '....oLKKKLLkKLLkKLLkKooooooooooooooooooooLLkKLLkKLLkKLLkKKKo....',
226 '...oLLKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKko...',
227 '...oLKKLLkKLLkKLLkKLLkKLLkKLLkKLLkKLLkKLLkKLLkKLLkKLLkKLLkKKo...',
228 '..oLLKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKko..',
229 '..oLKLLkKLLkKLLkKLLkKLLkKLLkKLLkKLLkKLLkKLLkKLLkKLLkKLLkKLLkKo..',
230 '.okkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkko.',
231 '..oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo..',
232 '...sssssssssssssssssssssssssssssssssssssssssssssssssssssssssss..',
233 '................................................................',
234 '................................................................',
235 '................................................................',
236 ],
237 },
238}
239
240// Fish: swimming, caught on the line, bones (it got away), a bobber (line reeled in),
241// asleep with bubbles; a rowboat for a workflow, a school for a cluster; a night lake.
242const FISH: ArtSet = {
243 pal: {"o": "#1d2b3a", "b": "#3f9fd6", "c": "#2a78ad", "a": "#c4e8f8", "f": "#f29b38", "d": "#c9701f", "g": "#ffffff", "z": "#bfe8ff", "Z": "#6fb7e6", "h": "#b8c0c9", "l": "#8c96a3", "r": "#e5484d", "w": "#fff6ea", "x": "#e0d2c0", "W": "#a8703f", "V": "#6e4424", "B": "#f3ead8", "S": "#4a8fc2", "s": "#2f6c9e", "k": "#d6f0ff", "y": "#ffcf4d", "Y": "#f0a830"},
244 pix: {
245 walk0: [
246 '..............Z.',
247 '................',
248 '...............z',
249 '........oooo....',
250 '.......offffo...',
251 '.....ooobbbboo..',
252 'oo..obbbcbbbbbo.',
253 'ofo.obbcbbbbgobo',
254 'offoobbcbbbbbbbo',
255 'offoobbcbbbbbboo',
256 'ofo.oaacaaaaaao.',
257 'oo...oaaaaaaoo..',
258 '......offoooo...',
259 '.......oo.......',
260 '................',
261 '................',
262 ],
263 walk1: [
264 '.............z..',
265 '...............z',
266 '................',
267 '........oooo....',
268 '.......offffo...',
269 'o....ooobbbboo..',
270 'ofo.obbbcbbbbbo.',
271 'offoobbcbbbbgobo',
272 'offfobbcbbbbbbbo',
273 '.oooobbcbbbbbboo',
274 '....oaacaaaaaao.',
275 '.....oaaaaaaoo..',
276 '......offoooo...',
277 '.......oo.......',
278 '................',
279 '................',
280 ],
281 walk2: [
282 '...............Z',
283 '................',
284 '..............z.',
285 '........oooo....',
286 '.......offffo...',
287 '.....ooobbbboo..',
288 'oo..obbbcbbbbbo.',
289 'ofo.obbcbbbbgobo',
290 'offoobbcbbbbbbbo',
291 'offoobbcbbbbbboo',
292 'ofo.oaacaaaaaao.',
293 'oo...oaaaaaaoo..',
294 '......offoooo...',
295 '.......oo.......',
296 '................',
297 '................',
298 ],
299 walk3: [
300 '...............z',
301 '..............z.',
302 '................',
303 '........oooo....',
304 '.......offffo...',
305 '.....ooobbbboo..',
306 '....obbbcbbbbbo.',
307 '....obbcbbbbgobo',
308 '.oooobbcbbbbbbbo',
309 'offfobbcbbbbbboo',
310 'offooaacaaaaaao.',
311 'ofo..oaaaaaaoo..',
312 'o.....offoooo...',
313 '.......oo.......',
314 '................',
315 '................',
316 ],
317 done: [
318 '...............l',
319 '...y...........l',
320 '..yYy..........l',
321 '...y...........l',
322 '...............l',
323 '........oooo...l',
324 '.......offffo..l',
325 'o....ooobbbboo.l',
326 'ofo.obbbcbbbbbol',
327 'offoobbcbbbbgobh',
328 'offfobbcbbbbbbbh',
329 '.oooobbcbbbbbboo',
330 '....oaacaaaaaao.',
331 '.....oaaaaaaoo..',
332 '......offoooo...',
333 '.......oo.......',
334 ],
335 failed: [
336 '................',
337 '................',
338 '................',
339 '...o.o.o..oooo..',
340 'o.oBoBoBo.oBBBo.',
341 'BooBoBoBooBBBBBo',
342 'BBoBoBoBooBoBBBo',
343 'BBBBBBBBBBBBBBBo',
344 'BBoBoBoBooBBBBBo',
345 'BooBoBoBooBBBBo.',
346 'o.oBoBoBo.oBBo..',
347 '...o.o.o..ooo...',
348 '................',
349 '................',
350 '................',
351 '................',
352 ],
353 stopped: [
354 '................',
355 '.......o........',
356 '.......o........',
357 '......ooo.......',
358 '.....orrro......',
359 '....orgrrro.....',
360 '....orrrrro.....',
361 '....orrrrro.....',
362 '....owwwwwo.....',
363 '....owwwwxo.....',
364 '.....owwxo......',
365 '.kk...ooo...kk..',
366 'kSSkkSSSSSkkSSkk',
367 'SSSSSSSSSSSSSSSS',
368 'SsSSSSsSSSSSsSSS',
369 '................',
370 ],
371 quiet: [
372 '............ZZZ.',
373 '..............Z.',
374 '..........Z..Z..',
375 '............ZZZ.',
376 '................',
377 '........oooo....',
378 '.......offffo...',
379 '.....ooobbbboo..',
380 'oo..obbbcbbbbbo.',
381 'ofo.obbcbbbooobo',
382 'offoobbcbbbbbbbo',
383 'offoobbcbbbbbboo',
384 'ofo.oaacaaaaaao.',
385 'oo...oaaaaaaoo..',
386 '......offoooo...',
387 '.......oo.......',
388 ],
389 workflow: [
390 '................',
391 '.............W..',
392 '............W..l',
393 '...........W...l',
394 '..........W....l',
395 '..........W....l',
396 '.........W.....l',
397 '........W......l',
398 '.......W.......l',
399 'ooooooWooooooo.r',
400 '.oWWWWWWWWWWo..w',
401 '..oVVVVVVVVo....',
402 '...oooooooo.....',
403 '.kk..k...kk...kk',
404 'SSSSSSSSSSSSSSSS',
405 'SsSSSSSsSSSSSsSS',
406 ],
407 group: [
408 '................',
409 '..........ooo...',
410 '........oobbobo.',
411 '........obbbbbo.',
412 '........ooaaao..',
413 '..........ooo...',
414 '...ooo..........',
415 '.ooffofo........',
416 '.offfffo........',
417 '.ooyyyo.........',
418 '...ooo....ooo...',
419 '........oobbobo.',
420 '........obbbbbo.',
421 '........ooaaao..',
422 '..........ooo...',
423 '................',
424 ],
425 scene: [
426 '................................................................',
427 '..............................y.................................',
428 '......yyy...............................................y.......',
429 '.....yy.............y...........................................',
430 '....yy......................................y...................',
431 '....yy..............................W...........................',
432 '....yy............................WW.l..........................',
433 '.....yy.........................WW...l..........................',
434 '......yyy......................W.....ly.........................',
435 '.............................WW......l......................y...',
436 '..............y............WW........l..........................',
437 '..........................W..........l..........................',
438 '........................WW............l.........................',
439 '......................WW..............l.................ZZZZ....',
440 '....................WW................l...................Z.....',
441 '...................W..................l..................Z......',
442 '.................WW...................l.................ZZZZ....',
443 '...............WW.....................l.........................',
444 '.............VV........................l............ZZZ.........',
445 'oooooooooooooooooooooo.................o..............Z.........',
446 'WWWWWWWWWWWWWWWWWWWWWW................ooo............Z..........',
447 'VVVVVVVVVVVVVVVVVVVVVV................oro...........ZZZ.........',
448 'oooooooooooooooooooooo................owo.......................',
449 'kkSVWSkkSSSSkkSSSVWkSSSSkkSSSSkkSSSSkk.o.SkkSSSSkkSSSSkkzSSSkkSS',
450 'SSSVWSSSSSSSSSSSSVWSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSooooSSSSSSSSSS',
451 'SSSVWSSSSSSSSSSSSVWSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSoffffozSSSSSSSS',
452 'SSSVWSSSSSSSSSSSSVWSSSSSSSSSSSSSSSSSSSSSSSSSSSSooobbbbooSSSSSSSS',
453 'SSSVWSSSSSSSkSSSSVWSSkSSSSSSSSkSSSSSSSSkSSooSSobbbcbbbbbokSSSSSS',
454 'SSSVWSSSSSSSSSSSSVWSSSSSSSSSSSSSSSSSSSSSSSofoSobbcbbboooboSSSSSS',
455 'SSSVWSSSSSSSSSSSSVWSSSSSSSSSSSSSSSSSSSSSSSoffoobbcbbbbbbboSSSSSS',
456 'SSSVWSSSSSSSSSSSSVWSSSSSSSSSSSSSSSSSSSSSSSoffoobbcbbbbbbooSSSSSS',
457 'SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSofoSoaacaaaaaaoSSSSSSS',
458 'ssssssssssssssssssssssssssssssssssssssssssoosssoaaaaaaoossssssss',
459 'ssssssssssssssssssssssssssssssssssssssssssssssssoffoooosssssssss',
460 'sssssssssssssssssssssssssssssssssssssssssssssssssoosssssssssssss',
461 'ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss',
462 'ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss',
463 'ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss',
464 'ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss',
465 'ssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss',
466 ],
467 },
468}
469
470// Dogs: trotting, a happy sit, lying down with a tear, told to sit, asleep; a tennis
471// ball for a workflow, the pack's paw print for a cluster; asleep by the dog house.
472const DOG: ArtSet = {
473 pal: {"o": "#2e1f1b", "b": "#d99a52", "a": "#f2c98e", "c": "#a8662f", "d": "#7a4520", "w": "#fff6ea", "x": "#e3cdb8", "p": "#f4899a", "r": "#e5484d", "y": "#ffcf4d", "T": "#c8e04a", "U": "#94ad28", "g": "#ffffff", "z": "#8fb0f0", "Z": "#5f86d8", "R": "#c2413b", "Q": "#8f2b27", "W": "#c08850", "V": "#8a5a30", "k": "#3a2a24", "G": "#6bb34a", "F": "#4d8c35", "L": "#bdb6d3", "K": "#8e87a9"},
474 pix: {
475 walk0: [
476 '................',
477 '.........ooo....',
478 '........obbbo...',
479 '.......odbbbboo.',
480 '.......oddbobbbo',
481 'oo.....oddbbbwwo',
482 'obo.....odbbwwoo',
483 'oo.ooooooobbwpo.',
484 'obooabbbborrry..',
485 '.obbbbbbbbbbwo..',
486 '..obbbbbbbbbwo..',
487 '..obbbbbbbbbo...',
488 '..obco..obco....',
489 '..oco...oco.....',
490 '..ow.....ow.....',
491 '................',
492 ],
493 walk1: [
494 '................',
495 '.........ooo....',
496 '........obbbo...',
497 '.......odbbbboo.',
498 '.......oddbobbbo',
499 '.oo....oddbbbwwo',
500 'obo.....odbbwwoo',
501 'oo.ooooooobbwpo.',
502 'obooabbbborrry..',
503 '.obbbbbbbbbbwo..',
504 '..obbbbbbbbbwo..',
505 '..obbbbbbbbbo...',
506 '...obco.obco....',
507 '....oco.oco.....',
508 '....ow..ow......',
509 '................',
510 ],
511 walk2: [
512 '................',
513 '.........ooo....',
514 '........obbbo...',
515 '.......odbbbboo.',
516 '.......oddbobbbo',
517 'oo.....oddbbbwwo',
518 'obo.....odbbwwoo',
519 'oo.ooooooobbwpo.',
520 'obooabbbborrry..',
521 '.obbbbbbbbbbwo..',
522 '..obbbbbbbbbwo..',
523 '..obbbbbbbbbo...',
524 '.obco....obco...',
525 'oco......oco....',
526 'ow........ow....',
527 '................',
528 ],
529 walk3: [
530 '................',
531 '.........ooo....',
532 '........obbbo...',
533 '.......odbbbboo.',
534 '.......oddbobbbo',
535 'o......oddbbbwwo',
536 'ob......odbbwwoo',
537 'oo.ooooooobbwpo.',
538 'obooabbbborrry..',
539 '.obbbbbbbbbbwo..',
540 '..obbbbbbbbbwo..',
541 '..obbbbbbbbbo...',
542 '...obco.obco....',
543 '...oco..oco.....',
544 '...ow...ow......',
545 '................',
546 ],
547 done: [
548 '..oo........oo..',
549 '.oddo......oddo.',
550 '.oddooooooooddoo',
551 '.oddbbbbbbbbddo.',
552 '.odbbobbbbobbdo.',
553 '..obbobbbbobbo..',
554 '..obbbbwwbbbbo..',
555 '...obbwoowbbo...',
556 '...obbwwwwbbo...',
557 '....orrpprro....',
558 '...obbwppwbbo...',
559 '..obbbwwwwbbbo..',
560 '..obbbwwwwbbbo..',
561 '..obobwwwwbobo..',
562 '..owwbbbbbbwwo..',
563 '...oooooooooo...',
564 ],
565 failed: [
566 '................',
567 '................',
568 '................',
569 '................',
570 '................',
571 '................',
572 '..........oooo..',
573 '.........obbbbo.',
574 '........oddbbbbo',
575 '.oo.....oddboobo',
576 'obooooooodbzbwwo',
577 '.obbbbbbbbbzbwoo',
578 '.obbbbbbbbbbbwo.',
579 '.owbbbbbbbbbbwwo',
580 'oowwooooooooowwo',
581 '.oo.......z.ooo.',
582 ],
583 stopped: [
584 '.........ooo....',
585 '........obbbo...',
586 '.......odbbbboo.',
587 '.......oddbobbbo',
588 '.......oddbbbwwo',
589 '........odbbwwoo',
590 '........obbbwwo.',
591 '........orrryo..',
592 '.......obbbbwo..',
593 '......obbbbbwo..',
594 '.....obbbbbbwo..',
595 'oo..obbbbbbbwo..',
596 'obooobbbbbbbo...',
597 '.obbbbbcbbbco...',
598 '..obbbcowwcwwo..',
599 '...ooooooooooo..',
600 ],
601 quiet: [
602 '...........ZZZZ.',
603 '.............Z..',
604 '.......ZZZ..Z...',
605 '.........Z.ZZZZ.',
606 '........Z.......',
607 '.......ZZZ......',
608 '..........oooo..',
609 '.........obbbbo.',
610 '........oddbbbbo',
611 '.oo.....oddboobo',
612 'obooooooodbbbwwo',
613 '.obbbbbbbbbbbwoo',
614 '.obbbbbbbbbbbwo.',
615 '.owbbbbbbbbbbwwo',
616 'oowwooooooooowwo',
617 '.oo.........ooo.',
618 ],
619 workflow: [
620 '................',
621 '.....oooooo.....',
622 '...ooTTTTTToo...',
623 '..oTTgTTTTTTTo..',
624 '..oTggTTTTTTTo..',
625 '.oTTTggTTTTTTUo.',
626 '.oTTTTggTTTTTUo.',
627 '.oTTTTTgggTTTUo.',
628 '.oTTTTTTTTggTUo.',
629 '.oTTTTTTTTTTgUo.',
630 '.oTTTTTTTTTTggo.',
631 '..oTTTTTTTTTUo..',
632 '..oUTTTTTTTUUo..',
633 '...ooUUUUUUoo...',
634 '.....oooooo.....',
635 '................',
636 ],
637 group: [
638 '................',
639 '.....oo...oo....',
640 '....obbo.obbo...',
641 '....obbo.obbo...',
642 '.oo..oo...oo.oo.',
643 'obbo.........obo',
644 'obbo..oooo...obo',
645 '.oo..obbbbo...o.',
646 '....obbbbbbo....',
647 '...obbbbbbbbo...',
648 '...obbbbbbbbo...',
649 '...obbbbbbbbo...',
650 '....obbooobbo...',
651 '.....oo...oo....',
652 '................',
653 '................',
654 ],
655 scene: [
656 '................................................................',
657 '................................................................',
658 '................................................................',
659 '........................oQQQQo..................................',
660 '......................oRRRRRRRRo................................',
661 '....................oRRRRRRRRRRRRo..............................',
662 '..................oRRRRRRRRRRRRRRRRo............................',
663 '................oRRRRRRRRRRRRRRRRRRRRo..........................',
664 '..............oRRRRRRRRRRRRRRRRRRRRRRRRo........................',
665 '............oRRRRRRRRRRRRRRRRRRRRRRRRRRRRo......................',
666 '..........oRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRo....................',
667 '........oRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRo..................',
668 '......oRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRRo................',
669 '....QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQZZZZ............',
670 '.......oWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWo...Z.............',
671 '.......oVVVVVVVVVVVVVVVVVoooVVVVVVVVVVVVVVVVVVo..Z..............',
672 '.......oWWWWWWWWWWWWWWWWWoyoWWWWWWWWWWWWWWWWWWo.ZZZZ............',
673 '.......oWWWWWWWWWWWWWWWWWoooWWWWWWWWWWWWWWWWZZZ.................',
674 '.......oVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVZ.................',
675 '.......oWWWWWWWWWWWWWWkkkkkkkkkkWWWWWWWWWWWWWZo.................',
676 '.......oWWWWWWWWWWWWWWkkkkkkkkkkWWWWWWWWWWWWZZZ.................',
677 '.......oVVVVVVVVVVVVkkkkkkkkkkkkkkVVVVVVVVVVVVo.................',
678 '.......oWWWWWWWWWWWWkkkkkkkkkkkkkkWWWWWWWWWoooo.................',
679 '.......oWWWWWWWWWWWWkkkkkkkkkkkkkkWWWWWWWWobbbbo................',
680 '.......oVVVVVVVVVVVVkkkkkkkkkkkkkkVVVVVVVoddbbbbo...............',
681 '.......oWWWWWWWWWWWWkkkkkkkkkkkkkkooWWWWWoddboobo...............',
682 '.......oWWWWWWWWWWWWkkkkkkkkkkkkkobooooooodbbbwwo...............',
683 '.......oVVVVVVVVVVVVkkkkkkkkkkkkkkobbbbbbbbbbbwoo......ooo......',
684 '........oooooo..WWWWkkkkkkkkkkkkkkobbbbbbbbbbbwo......oTTTo.....',
685 '.......oLLLLLLo.WWWWkkkkkkkkkkkkkkowbbbbbbbbbbwwo.....oTgTo.....',
686 '..G....oKKKKKKo.VVVVkkkkkkkkkkkkkoowwooooooooowwo...G.oUUUo...G.',
687 '.G......oooooo..WWWWkkkkkkkkkkkkkkooWWWWWWWWWooo...G...ooo...G..',
688 'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG',
689 'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG',
690 'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG',
691 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF',
692 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF',
693 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF',
694 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF',
695 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF',
696 ],
697 },
698}
699
700export const ART: Record<ArtName, ArtSet> = { kitty: KITTY, fish: FISH, dog: DOG }
701