SLOPSHOPPER

agent-tree

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

newpanerowsguardcommandtoast
v0.1.0MITupdated 2026-10-07SeanoChang/agent-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-tree
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./view.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agent-tree │ ⎿ agent-tree: Agents pane opened. Click it; ? shows the keys. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
▣ client module ./view.tsx
README

agent-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.

agent-tree: three Explore agents run in parallel, then the run opens in History as a trace and a report

Install

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.

What it shows

ViewWhat you get
TreeEach run with its agents nested under it: status, the current step, model, tokens and time
TimelineOne bar per agent. The critical path (the chain of agents that set the end time) is heavy, and dots mark idle time
TraceOne lane per agent, with every tool call, edit and hand-back in order. Search it with /, jump between problems with n p
BoardAgents grouped by status
HistoryThe last 150 runs across sessions, per repo, with a short report for each

More:

  • Compare runs. Press 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.
  • Insights. One-line notes flag what needs a look: failed agents, an agent that made no tool call for a while, two agents that edited the same file, a workflow phase that took most of the time, and the agent that used the most tokens.
  • Export. x saves the open Timeline or Trace as SVG (and PNG) in ~/Downloads/agent-tree/.
  • Desktop app. The pane has a native view for the Code tab in the Claude desktop app.

An exported timeline: the critical path is outlined

Keys

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.

Settings

Run /plugin, pick agent-tree, and set:

OptionValuesDefault
thememinimal (plain glyphs, any terminal), kitty, fish, dog (colours and emoji)minimal
aioff (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.

How it works

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.

Develop

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 .

Feedback

Issues and ideas are welcome: open an issue or a discussion. A screenshot of the pane helps most.

License

MIT. See LICENSE.

Source 16 files
hooks/register.tsx 1185 lines
1import { 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}
1185
hooks/theme.ts 198 lines
1import 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
198
hooks/list.ts 627 lines
1import 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}
627
hooks/report.ts 165 lines
1import 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))
165
hooks/trace.ts 418 lines
1import 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}
418
hooks/ai.ts 235 lines
1import 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}
235
hooks/lens.ts 240 lines
1import 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}
240
hooks/desktop/svg.ts 333 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
333
hooks/desktop/pane.tsx 919 lines
1import 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}
919
hooks/desktop/state.ts 36 lines
1import 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}
36
hooks/view.tsx 2059 lines
1import 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 lines
1// 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