A live workflow pane: replaces the default Workflow progress list with a phase-by-phase graph or a timeline beside the transcript, showing each agent's state…

Claude Code runs a workflow as a list of agents ticking over. flowpane draws it instead: one column per phase, an agent as a node, the barriers and carries between them as edges, repainted about eight times a second while the run is live.
Built on Claude Code function hooks (Claude Mods), the early-access plugin API from anthropics/claude-code#91870.
| Phases across | Phases down |
|---|---|
![]() | ![]() |
| Timeline | A node's detail |
|---|---|
![]() | ![]() |
From the marketplace, inside Claude Code:
/plugin marketplace add mpolatcan/cc-plugins
/plugin install flowpane
Or from a clone, which is also how you work on it:
git clone https://github.com/mpolatcan/flowpane.git
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./flowpane
Then run any workflow. The pane opens on launch; /flowpane toggles it afterwards.
Two things to know before it draws anything:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 is required./tui default a pane never draws. Run /tui fullscreen, which docks the pane beside the transcript from 110 columns; below that it sits inline above the prompt.Everything sits under the drawing. Click a button, or Tab to it and press Enter.
| Control | What it does |
|---|---|
| A node's label | opens that agent's detail dialog; click again to close |
| ⚙ Settings | opens the settings dialog over the drawing |
flowpane 0.10.0 | the name at the right-hand end of the bottom row: opens what the pane is, what presses it, and where it reads from |
| A setting's value | unrolls that setting's list where it stands; press it again to roll the list up |
| The graph, with any dialog open | takes no presses — it is pushed back behind the dialog until the dialog shuts |
| Layout | across, down, timeline, list, or fits the shape — picked by name |
Detail height − + | rows the detail dialog takes, 5 to 32; grey at either end of the range |
| Theme | twelve palettes, nine dark and three light — each listed beside three cells of its own |
| ✕ (in a dialog) | closes it, from the dialog's own top corner |
| A tab in the detail dialog | shows that pane across the dialog's whole width; which tab is open is kept from node to node |
| A pass number on a node's row | opens that attempt's detail: ✔ 1 ✔ 2 ✖ 3 is one row and three presses |
The ▸ on a nested run's name, or its ▸ 23 agents | unfolds that run into its own agents, each one pressable — in any layout. The count on the run's own card takes the same press as the mark on its band. The mark turns ▾ while it is open |
| ▴ / ▾ (in a dialog) | scrolls the open pane three lines. Grey at either end |
| ▴ / ▾ ◂ / ▸ (at the pane's edges) | scrolls the drawing, where it is larger than the pane. A live run is drawn with the window on the phase it is working in, so scrolling away is a peek: it goes back when the run enters a new phase |
| The wheel, over the pane | moves the drawing, or the open dialog while one is open |
| The wheel, over the rail along the foot | moves the drawing sideways — as does the wheel anywhere over a drawing that only goes that way |
| The run's name | opens the session's other runs as a menu under it, once there is more than one; grouped by state, most recent first, each with the second it started on, and opening on the run the pane is drawing. Picking one moves the drawing to it, live or finished |
| ▴ / ▾ (down a run list) | scrolls the runs three rows, in the menu or on the idle pane, with a thumb saying whereabouts in the list they are. The wheel over an open list moves it too |
| A run on the idle pane | draws it. With nothing running the pane lists the session's runs in place of the graph, grouped and timed the same way |
/plugin configure flowpane, or pluginConfigs in settings.json:
| Field | Values | What it does |
|---|---|---|
orientation | horizontal, vertical, timeline, list, or auto (default) — the last spelled fits in /flowpane and in the settings | horizontal runs the phases across the pane, vertical runs them down it, auto follows the pane's proportions. A node is the same size in every one of them at every size of pane: the drawing is laid out whole and the pane is a window on it, so what it cannot hold it scrolls to and the rails say which way. list draws a row per agent under each phase with no edges between them, which is the compact reading of a long run. timeline swaps the graph for a trace view: a ruled time axis, one bar per agent on it, grouped by phase and indented under the nested runs they belong to, with a marker where now is. |
detailRows | 5–32 (default 24) | Rows the detail dialog takes when a node is selected, capped at what the pane can inset. |
paneRows | 6–80 (0 = let the surface decide) | Rows the pane asks for. A dock beside the transcript usually picks its own height. |
theme | tokyo-night (default), catppuccin, gruvbox, nord, dracula, solarized, monokai, vscode-dark, insider-one, github-light, solarized-light, insider-one-light | The palette everything is drawn in, its ground included. The pane always paints on that ground, footer and all, so the drawing reads the same in every terminal rather than against whatever the terminal happens to be; the ground stops at the drawing's own right edge — see the note under the tree below. |
The repository keeps one run, dev/audit.workflow.js, so there is something to watch while working on the pane. It is a development tool rather than part of the plugin: it is not installed with flowpane and it is not offered as a skill. Run it from a checkout with bun dev/dryrun.ts for the stubbed pass, or hand the script to the Workflow tool for the real one.
It is a read-only audit of the repository it is run in: two to five minutes, twenty to thirty agents, nothing written to disk. It is shaped to put every case the pane can draw in front of it at once — a branch, a loop, a chain that stops early, a phase never entered, a fan-out, three models, four kinds of tool call and an edge too long to thread between the bands. See docs/demo-workflow.md.
claude plugin test . # the test suite
bunx tsc --noEmit # typecheck
bun dev/preview.ts # the pane, drawn to the terminal, no session needed
bun dev/shot.ts --journal <dir> # the pane, working, in a browser
bun dev/lines.ts # every line of every run at every size
bun dev/audit.ts # every run, layout and size: nothing throws, every cell legible
docs/development.md has the rest of the tools and what each test measures. CLAUDE.md has the constraints and conventions a change has to keep.
The drawing is a long argument with itself, and the reasoning is kept where the decision is. Each of these says what the pane does, what it did before, and why it changed:
| Document | What it covers |
|---|---|
| docs/pane.md | Which seat the pane takes, and the idle pane |
| docs/controls.md | Every control, the bottom row, and the dialogs |
| docs/settings.md | The four settings, and how a theme becomes a palette |
| docs/header.md | The run line, the phase names, and what moves |
| docs/nodes.md | What a node says, and the four states |
| docs/graph.md | What the files state, what the pane infers, and how each is drawn |
| docs/detail.md | The dialog a node opens |
| docs/data.md | The files a run writes, and recovering a run already going |
| docs/demo-workflow.md | The audit run under dev/ |
| docs/engine.md | What the function-hooks API allows, and what it does not |
| docs/development.md | The dev tools, and what the tests measure |
Loads and runs on Claude Code 2.1.272: hooks register, /flowpane lists, the launch hook fires and reads the journal. 361 tests run over the engine with claude plugin test .; dev/lines.ts checks every line of every run on disk at twelve widths and ten heights. Nothing the pane reads leaves the machine.
MIT. See LICENSE.
hooks/register.ts 1843 lines1/**
2 * The workflow pane.
3 *
4 * `tool.call` on Workflow catches the launch and reads the run's identity off
5 * the result the engine hands back — the tool returns as soon as the run is
6 * registered, so this fires seconds before the first agent lands. The pane opens
7 * there, a timer tails the run's `journal.jsonl` and repaints, and `ui.render`
8 * on the Pane hands the engine the drawing.
9 *
10 * The same `tool.call` chain carries every subagent's tool call, with the
11 * `agentId` the journal names, so what an agent is doing right now needs no
12 * polling: it arrives as an event, before and after the call.
13 *
14 * Repaints go through `$.ui.blit` where the surface has a Raster, which replaces
15 * its cells without re-rendering the tree; otherwise through `$.ui.invalidate`.
16 *
17 * Every helper that takes `$` is declared at the top of this file: the engine
18 * follows `$` statically and refuses a module that hands it to a closure, so the
19 * pane's state lives in `state` here rather than in `register`'s scope.
20 */
21
22import type { EngineInterface, On, PluginOptions, Timer } from 'claude-code'
23
24import { Canvas, cellColor, DEFAULT_COLOR } from './canvas'
25import {
26 applyFileClock,
27 applyJournal,
28 applyRunFile,
29 contextOfUsage,
30 readAgentMeta,
31 inputOf,
32 noteCallEnd,
33 noteCallStart,
34 noteStep,
35 noteToolCall,
36 phasesOfScript,
37 readAgentTranscript,
38 runFileOf,
39 runOfScriptName,
40 type RunState,
41} from './journal'
42import { aboutText, NAME, noteShipped, shippedVersion } from './about'
43import type { Orientation } from './layout'
44import {
45 ABOUT,
46 paint,
47 paintIdle,
48 quietOf,
49 SETTINGS,
50 useTheme,
51 type BodyScroll,
52 type DetailView,
53 type Hotspot,
54 type PaintOptions,
55 type PaintResult,
56 type RunEntry,
57 type RunWindow,
58 type SettingMenu,
59} from './paint'
60import { applyPress, drew, MAX_DETAIL, MIN_DETAIL, ORIENTATIONS, showDetail, wheel } from './press'
61import { DEFAULT_THEME, hexOf, paletteOf, themeOf, THEMES } from './theme'
62import { bandsOf, pictureOf, type Band } from './tree'
63
64const PANE_ID = 'flowpane'
65/**
66 * The command the pane answers to, exported because `dev/recover.ts` keys its
67 * handler table by it: a second spelling of the name in the tools is a rename
68 * that typechecks, passes the suite, and breaks the tool at call time.
69 */
70export const COMMAND = 'flowpane'
71/** With a Raster, frames are blitted and can run at animation speed. */
72const FRAME_MS = 120
73/** Without one, every frame is a re-render, so they come slower. */
74const FRAME_MS_REDRAW = 320
75/** Journal reads are cheaper than frames; one every fourth. */
76const READ_EVERY = 4
77/**
78 * A node's label is an element, not a cell, so a blit does not touch it: its
79 * spinner and its colour only move when the tree is built again. That is the
80 * expensive path, so it runs on every third frame rather than on each one.
81 */
82const REDRAW_EVERY = 3
83/** What the detail dialog's height is allowed to be, at the controls and at `/flowpane detail`. */
84/** The pane's opaque backdrop: the theme's own ground. */
85function backdropOf(): number {
86 return themeOf(state.theme).bg
87}
88
89/** What a control under the pointer is drawn in: the pane's own selection colour. */
90function accentHex(): string {
91 return paneHex(paletteOf(themeOf(state.theme)).accent)
92}
93
94/**
95 * One color as an element takes it. Rounded to the four bits a channel a Raster
96 * keeps, so a row drawn as elements sits on the same ground as the cells above
97 * and below it rather than a few values off it.
98 */
99function paneHex(color: number): string {
100 return hexOf(cellColor(color))
101}
102
103/**
104 * What a surface that draws no Buttons points at instead.
105 *
106 * Spelled from `COMMAND` rather than written out, because this string is drawn
107 * in exactly one seat — the footer of a surface with no Button in its table —
108 * and a seat nothing renders is a seat a rename can leave behind pointing at a
109 * command that no longer answers.
110 */
111const HELP_HINT = `/${COMMAND} help`
112/** Calls the engine gave no `tool_use_id`; a counter names them instead. */
113let callCounter = 0
114
115type Launch = {
116 status?: string
117 runId?: string
118 workflowName?: string
119 summary?: string
120 transcriptDir?: string
121}
122
123const state: {
124 runs: RunState[]
125 shown: RunState | null
126 canvas: Canvas | null
127 /** How the last render cut the drawing up, so a frame can blit the same bands. */
128 bands: Band[]
129 /**
130 * What was last written into each band, so a band that has not changed is not
131 * written again.
132 *
133 * Every frame used to send every band. A pane a hundred and twenty columns
134 * by forty is five thousand cells, twelve bytes each, and at eight frames a
135 * second that is most of a megabyte a second crossing the wire to say that
136 * nothing moved — because most of it has not: the cards of the agents that
137 * already landed are the same cells they were a minute ago, and what changes
138 * is the spinner, the clock on the top bar and the rows of whatever is still
139 * running.
140 */
141 sent: Map<string, string>
142 timer: Timer | null
143 tick: number
144 isPaneOpen: boolean
145 size: { columns: number; rows: number } | null
146 /** Whether this build's terminal table has a Raster (2.1.271 and after). */
147 hasRaster: boolean
148 hotspots: Hotspot[]
149 /** The agent whose detail dialog is open, or `@run:<phase>` for a nested run. */
150 selectedId: string | null
151 /** The nested run an open agent was reached from, for the way back. */
152 fromRun: string | null
153 /** The tool call opened out of that agent's Calls list, by its own id. */
154 openCall: string | null
155 /** The first line on screen in the opened call. */
156 callScroll: number
157 callOutScroll: number
158 /** Set by a Button's onPress, acted on by the `ui.press` hook, which has `$`. */
159 pressed: string | null
160 /** Agents whose transcript has been read, so it is read once. */
161 loaded: Set<string>
162 /** Agents whose `.meta.json` has been asked for, so it is asked for once. */
163 models: Set<string>
164 /** Agents whose transcript has been read for a token count, so it is read once. */
165 spent: Set<string>
166 /**
167 * `<agentId>:<state>` for each agent whose clock has been read off its files,
168 * so the pair is read once when the agent appears and once after it stops.
169 */
170 clocks: Set<string>
171 orientation: Orientation
172 detailRows: number
173 /** Which palette the drawing is painted in. */
174 theme: string
175 /** Rows the pane asks for while seated inline above the prompt. */
176 paneRows: number | null
177 /** The last time a frame ran, for hooks that must not await the clock. */
178 nowMs: number
179 /** The first line on screen in each block of the detail dialog. */
180 detailScroll: number[]
181 /** Which of the detail dialog's tabs is on top; kept across nodes. */
182 detailTab: number
183 /** What the last paint said about the detail list, for clamping a scroll. */
184 detailView: DetailView | null
185 /** Where the body is in a drawing larger than it, in cells. */
186 bodyScroll: { x: number; y: number }
187 /** The nested runs the reader has unfolded, by phase name. */
188 opened: string[]
189 /** What the last paint said the drawing overruns the body by, for clamping. */
190 bodyView: BodyScroll | null
191 /** Whether the body follows the phase the run is working in. */
192 following: boolean
193 /** The phase the last paint said the run was working in, to see it change. */
194 front: string | null
195 /** True while the run name's list is unrolled under the top bar. */
196 picking: boolean
197 /** The first row on screen in the session's run list, wherever it is drawn. */
198 runScroll: number | null
199 /** What the last paint said about that list, for clamping a scroll. */
200 runListView: RunWindow | null
201 /** True while the settings dialog is open over the drawing. */
202 settings: boolean
203 /** Which setting's list is unrolled inside that dialog, if any. */
204 menu: SettingMenu | null
205 /** True while the About dialog is open over the drawing. */
206 about: boolean
207 /** Whether this build's table has a Button, so the run's name can be pressed. */
208 canChoose: boolean
209} = {
210 runs: [],
211 shown: null,
212 canvas: null,
213 bands: [],
214 sent: new Map(),
215 timer: null,
216 tick: 0,
217 isPaneOpen: false,
218 size: null,
219 hasRaster: true,
220 hotspots: [],
221 selectedId: null,
222 fromRun: null,
223 openCall: null,
224 callScroll: 0,
225 callOutScroll: 0,
226 pressed: null,
227 loaded: new Set(),
228 models: new Set(),
229 spent: new Set(),
230 clocks: new Set(),
231 orientation: 'auto',
232 detailRows: 24,
233 theme: DEFAULT_THEME,
234 paneRows: null,
235 nowMs: 0,
236 detailScroll: [],
237 detailTab: 0,
238 detailView: null,
239 bodyScroll: { x: 0, y: 0 },
240 opened: [],
241 bodyView: null,
242 following: true,
243 front: null,
244 picking: false,
245 runScroll: null,
246 runListView: null,
247 settings: false,
248 menu: null,
249 about: false,
250 canChoose: true,
251}
252
253function tryParse(text: string): unknown {
254 try {
255 return JSON.parse(text)
256 } catch {
257 return null
258 }
259}
260
261/** Reads whatever `unknown` a tool result is into a launch record. */
262function launchOf(value: unknown): Launch | null {
263 const raw = typeof value === 'string' ? tryParse(value) : value
264
265 if (!raw || typeof raw !== 'object') {
266 return null
267 }
268
269 const launch = raw as Launch
270
271 return launch.runId && launch.transcriptDir ? launch : null
272}
273
274function runOfAgent(agentId: string): RunState | undefined {
275 return state.runs.find(run => run.agents.some(a => a.agentId === agentId))
276}
277
278function stopTimer(): void {
279 state.timer?.cancel()
280 state.timer = null
281}
282
283/**
284 * Finds the runs this session has already made, by walking the files the engine
285 * wrote for them.
286 *
287 * A module reload starts with an empty run list while the session's panes and
288 * its runs are still there, so the pane would claim nothing had run. Two kinds
289 * of run are on disk and they are found different ways: a run that is over has
290 * a summary, and a run that is still going has only the script it was launched
291 * from.
292 */
293async function recoverRuns($: EngineInterface): Promise<void> {
294 const home = await $.env.get('HOME')
295 const sessionId = await $.session.id()
296
297 if (!home || !sessionId) {
298 return
299 }
300
301 const projects = `${home}/.claude/projects`
302
303 if (!(await $.fs.exists(projects))) {
304 return
305 }
306
307 for (const entry of await $.fs.list(projects)) {
308 if (entry.kind !== 'dir') {
309 continue
310 }
311
312 const base = `${projects}/${entry.name}/${sessionId}`
313
314 if (!(await $.fs.exists(`${base}/workflows`))) {
315 continue
316 }
317
318 await recoverEnded($, base)
319 await recoverLive($, base)
320
321 break
322 }
323
324 state.runs.sort((a, b) => a.startedMs - b.startedMs)
325}
326
327/** The runs of this session that are over, from the summary each one wrote. */
328async function recoverEnded($: EngineInterface, base: string): Promise<void> {
329 const dir = `${base}/workflows`
330
331 for (const file of await $.fs.list(dir)) {
332 // A run's summary is named for the run. The engine keeps its own
333 // bookkeeping in the same directory — `.skipped-runs.json` among it —
334 // and a file that is not a run has no agents, no clock and no status,
335 // so it listed as a run that had just started and never moved.
336 if (!/^wf_.+\.json$/.test(file.name)) {
337 continue
338 }
339
340 const runId = file.name.replace(/\.json$/, '')
341
342 if (state.runs.some(r => r.runId === runId)) {
343 continue
344 }
345
346 const summary = await $.fs.read(`${dir}/${file.name}`)
347 const run = blankRun(base, runId, nameOf(summary) ?? runId)
348
349 // The journal first, then the summary, and never the other way round.
350 // The journal says an agent landed but not when: the reader stamps it
351 // with the moment it read the line, which for a run recovered after the
352 // fact is now. The summary carries the engine's own clock, so it has to
353 // be the one that settles every row — otherwise a run that took two
354 // minutes yesterday draws as having taken until today.
355 await readJournal($, run)
356
357 applyRunFile(run, summary, await $.clock.now())
358
359 state.runs.push(run)
360 }
361}
362
363/**
364 * The runs of this session that are still going.
365 *
366 * A summary is written when a run ends, so the walk above finds only the runs
367 * that are over: a workflow already in flight when the plugin loaded was
368 * missing from the list, and the one run a reader most wants to watch was the
369 * one run the pane could not offer. The engine persists each run's script at
370 * launch, under a name carrying both the workflow's name and the run's id, so
371 * the script is what says a run exists before there is anything to summarise.
372 * Its mtime is the launch, to the second, which the journal does not record
373 * either.
374 */
375async function recoverLive($: EngineInterface, base: string): Promise<void> {
376 const dir = `${base}/workflows/scripts`
377
378 if (!(await $.fs.exists(dir))) {
379 return
380 }
381
382 for (const file of await $.fs.list(dir)) {
383 const named = runOfScriptName(file.name)
384
385 if (!named || state.runs.some(r => r.runId === named.runId)) {
386 continue
387 }
388
389 const path = `${dir}/${file.name}`
390 const run = blankRun(base, named.runId, named.name)
391
392 run.startedMs = Math.round((await $.fs.stat(path)).mtimeMs)
393 run.plan = phasesOfScript(await $.fs.read(path))
394 run.phases = run.plan.map(step => step.title)
395
396 await readJournal($, run)
397 await readClocks($, run)
398
399 state.runs.push(run)
400 }
401}
402
403/** A run with nothing read into it yet, at the paths its files will be at. */
404function blankRun(base: string, runId: string, name: string): RunState {
405 return {
406 runId,
407 name,
408 summary: '',
409 transcriptDir: `${base}/subagents/workflows/${runId}`,
410 runFile: `${base}/workflows/${runId}.json`,
411 startedMs: 0,
412 status: 'running',
413 phases: [],
414 agents: [],
415 consumed: 0,
416 recovered: true,
417 }
418}
419
420/** Folds in whatever the run's journal holds, if it has written one yet. */
421async function readJournal($: EngineInterface, run: RunState): Promise<void> {
422 const path = `${run.transcriptDir}/journal.jsonl`
423
424 if (await $.fs.exists(path)) {
425 applyJournal(run, await $.fs.read(path), await $.clock.now())
426 }
427}
428
429/** When a file was last written, or nothing when it is not there. */
430async function mtimeOf($: EngineInterface, path: string): Promise<number | undefined> {
431 return (await $.fs.exists(path)) ? (await $.fs.stat(path)).mtimeMs : undefined
432}
433
434/**
435 * Stamps the agents of a run rebuilt from disk with the clock its files carry.
436 *
437 * The journal says an agent started and that it landed, but not when, so a
438 * reader that joined late stamps both with the moment it read the line: every
439 * agent of a run adopted mid-flight drew as having started now and taken no
440 * time at all. The summary would settle it, but a run still going has not
441 * written one. Each agent leaves two files that are stamped for it — its
442 * `.meta.json`, written when it is spawned, and its transcript, written to
443 * until it stops — and those two mtimes come within a second of the figures
444 * the summary gives later.
445 */
446async function readClocks($: EngineInterface, run: RunState): Promise<void> {
447 for (const agent of run.agents) {
448 const path = `${run.transcriptDir}/agent-${agent.agentId}`
449 const key = `${agent.agentId}:${agent.state}`
450
451 // Once when the agent is first seen, and once more after it stops: those
452 // are the two moments its files have something new to say.
453 if (!state.clocks.has(key)) {
454 state.clocks.add(key)
455
456 applyFileClock(agent, await mtimeOf($, `${path}.meta.json`), await mtimeOf($, `${path}.jsonl`))
457
458 continue
459 }
460
461 // While it runs, its transcript's mtime keeps moving, and the pane reads
462 // how long ago that was as how long the agent has been quiet.
463 if (agent.state === 'running') {
464 applyFileClock(agent, undefined, await mtimeOf($, `${path}.jsonl`))
465 }
466 }
467}
468
469/** The workflow's own name out of a summary file. */
470function nameOf(text: string): string | null {
471 const parsed = tryParse(text)
472
473 return parsed && typeof parsed === 'object' && typeof (parsed as { workflowName?: unknown }).workflowName === 'string'
474 ? (parsed as { workflowName: string }).workflowName
475 : null
476}
477
478/** Reads the journal's new lines, and the summary once the run has written it. */
479async function readRun($: EngineInterface, run: RunState, nowMs: number): Promise<void> {
480 const journalPath = `${run.transcriptDir}/journal.jsonl`
481
482 if (await $.fs.exists(journalPath)) {
483 applyJournal(run, await $.fs.read(journalPath), nowMs)
484 }
485
486 if (run.status === 'running' && (await $.fs.exists(run.runFile))) {
487 applyRunFile(run, await $.fs.read(run.runFile), nowMs)
488 }
489
490 // A run the pane was not there for is still being read line by line, and each
491 // new line is stamped `nowMs` again. Until its summary lands, the files are
492 // the only clock it has.
493 if (run.recovered && run.status === 'running') {
494 await readClocks($, run)
495 }
496
497 await readModels($, run)
498 await readSpend($, run)
499}
500
501/**
502 * Fills in the model of any agent the summary has not named yet.
503 *
504 * The summary is the better source — it is one file for the whole run — but the
505 * engine does not write it until there is a run to summarise, so early on there
506 * is nothing to read. Each agent's own `.meta.json` is there from the moment it
507 * is spawned, so the pane asks those instead, once per agent, and stops asking
508 * as soon as one of the two answers.
509 */
510async function readModels($: EngineInterface, run: RunState): Promise<void> {
511 for (const agent of run.agents) {
512 if (agent.model || state.models.has(agent.agentId)) {
513 continue
514 }
515
516 state.models.add(agent.agentId)
517
518 const path = `${run.transcriptDir}/agent-${agent.agentId}.meta.json`
519
520 if (await $.fs.exists(path)) {
521 agent.model = readAgentMeta(await $.fs.read(path))
522 }
523 }
524}
525
526/**
527 * Fills in the token count of any agent the summary has not counted yet.
528 *
529 * The engine attributes a count to every agent, and it does so when the run
530 * ends. Until then the only figure the pane has is whatever it watched go past
531 * on `turn.step` — so an agent that finished before this pane was drawing, or
532 * before the session it is drawing in started, carried no figure at all, and
533 * drew none. A thirty-second helper reading nothing said it had cost nothing.
534 *
535 * So the agent's own transcript is read for it, the way `readModels` reads the
536 * model: once per agent, and only for an agent that has stopped and still has
537 * no count. A running agent is left to the live reader, which is ahead of the
538 * file; a run that has ended has the summary, which is better than both.
539 */
540async function readSpend($: EngineInterface, run: RunState): Promise<void> {
541 for (const agent of run.agents) {
542 if (agent.state === 'running' || agent.tokens !== undefined || agent.liveTokens !== undefined) {
543 continue
544 }
545
546 if (state.spent.has(agent.agentId)) {
547 continue
548 }
549
550 state.spent.add(agent.agentId)
551
552 const path = `${run.transcriptDir}/agent-${agent.agentId}.jsonl`
553
554 if (await $.fs.exists(path)) {
555 agent.liveTokens = readAgentTranscript(await $.fs.read(path)).tokens
556 }
557 }
558}
559
560/**
561 * Reads one agent's own transcript for the prompt it was given and the text it
562 * answered with — read once per agent, when its detail dialog is first opened.
563 */
564async function loadDetail($: EngineInterface, run: RunState, agentId: string): Promise<void> {
565 const row = run.agents.find(a => a.agentId === agentId)
566
567 if (!row || state.loaded.has(agentId)) {
568 return
569 }
570
571 const path = `${run.transcriptDir}/agent-${agentId}.jsonl`
572
573 if (!(await $.fs.exists(path))) {
574 return
575 }
576
577 const read = readAgentTranscript(await $.fs.read(path))
578
579 row.prompt = read.prompt ?? row.prompt
580 row.result = read.result ?? row.result
581 // The same figure `readSpend` sweeps for, taken while the file is open. The
582 // sweep skips a running agent and this does not: a reader who opens a live
583 // agent's dialog is looking at the one row on the pane whose file has just
584 // been read, and leaving it blank there would be a gap they can see the
585 // answer to.
586 row.liveTokens = row.liveTokens ?? read.tokens
587
588 // The live chain records calls as they happen and knows their timing; the
589 // transcript knows only what was passed and what came back. So the transcript
590 // fills the list for a run that was never watched, and stands aside for one
591 // that was.
592 if (read.calls.length > 0 && (row.calls?.length ?? 0) === 0) {
593 row.calls = read.calls
594
595 // The tally the pane counts tools by is filled by the live chain, which was
596 // never running for a replayed run. Recovered from the same calls, so the
597 // figures a reopened run shows are the ones it showed while it ran.
598 if (row.tools.length === 0) {
599 for (const call of read.calls) {
600 const seen = row.tools.find(t => t.name === call.name)
601
602 if (seen) {
603 seen.count++
604 seen.atMs = Math.max(seen.atMs, call.startedMs)
605 } else {
606 row.tools.push({ name: call.name, count: 1, atMs: call.startedMs, isRunning: false })
607 }
608 }
609 }
610 }
611
612 // A running agent's transcript grows; only a finished one is read for good.
613 if (row.state !== 'running') {
614 state.loaded.add(agentId)
615 }
616}
617
618/**
619 * Whether the run's name at the top of the pane opens the list of the session's
620 * other runs. One run is not a choice, and a build with no Button has no way to
621 * press the name — there the list stays a chooser of its own under the footer.
622 */
623function canPickRun(): boolean {
624 return state.canChoose && state.runs.length > 1
625}
626
627/**
628 * What every paint of the shown run is drawn with.
629 *
630 * The blit between renders has to produce the same hotspots the render did, so
631 * both paints read their options from here rather than each listing their own.
632 */
633/**
634 * The body row the rail along the foot is on, when the drawing has one.
635 *
636 * The canvas is painted into the top of the pane's body and the footer rule and
637 * row come under it, so the canvas's own last row is the body row a pointer
638 * reports — and the rail is drawn on that row whenever the drawing overruns
639 * sideways. A wheel over it scrolls sideways; anywhere else is a wheel over the
640 * drawing.
641 */
642function footRow(): number | null {
643 return state.canvas && (state.bodyView?.spanX ?? 0) > 0 ? state.canvas.rows - 1 : null
644}
645
646/** What the paint just decided, kept for the next press and the next paint. */
647function kept(drawn: PaintResult): void {
648 state.hotspots = drawn.hotspots
649 state.detailView = drawn.detail ?? null
650 state.bodyView = drawn.body ?? null
651 state.runListView = drawn.runList ?? null
652
653 drew(state, drawn)
654}
655
656function paintOptions(nowMs: number): PaintOptions {
657 return {
658 nowMs,
659 tick: state.tick,
660 orientation: state.orientation,
661 selectedId: state.selectedId ?? undefined,
662 fromRun: state.fromRun ?? undefined,
663 openCall: state.openCall ?? undefined,
664 callScroll: state.callScroll,
665 callOutScroll: state.callOutScroll,
666 detailRows: state.detailRows,
667 detailScroll: state.detailScroll,
668 detailTab: state.detailTab,
669 bodyScroll: state.bodyScroll,
670 follow: state.following,
671 opened: state.opened,
672 runPicker: canPickRun() ? (state.picking ? 'open' : 'shut') : undefined,
673 runs: state.picking ? state.runs.map(runEntry) : undefined,
674 runScroll: state.runScroll ?? undefined,
675 settings: state.settings,
676 menu: state.menu ?? undefined,
677 about: state.about,
678 }
679}
680
681/** Paints the shown run and pushes the drawing to the pane. */
682function repaint($: EngineInterface, run: RunState, nowMs: number): void {
683 if (!state.canvas || !state.isPaneOpen) {
684 return
685 }
686
687 kept(paint(state.canvas, run, paintOptions(nowMs)))
688
689 // Without a Raster there is nothing to blit into: the tree itself carries the
690 // picture, so the redraw has to go through the renderer.
691 if (!state.hasRaster) {
692 $.ui.invalidate('ui.render')
693 return
694 }
695
696 // A blit writes into the Rasters the last render mounted. When this frame
697 // puts a node's label on a different row, those Rasters no longer cover the
698 // rows they did, so the tree has to be built again before anything is written
699 // into it.
700 const bands = bandsOf(state.canvas.rows, state.hotspots)
701
702 if (!sameBands(bands, state.bands)) {
703 // The Rasters this frame would have written into are about to be replaced,
704 // so what was written into the old ones says nothing about the new.
705 state.sent.clear()
706 $.ui.invalidate('ui.render')
707
708 return
709 }
710
711 for (const band of bands) {
712 if (!band.key) {
713 continue
714 }
715
716 const cells = state.canvas.encode(band.from, band.rows)
717
718 // Byte for byte what this band already holds: sending it again would draw
719 // the same picture over itself.
720 if (state.sent.get(band.key) === cells) {
721 continue
722 }
723
724 state.sent.set(band.key, cells)
725
726 void $.ui
727 .blit({
728 requestId: PANE_ID,
729 key: band.key,
730 cells,
731 })
732 // A write that did not land leaves the band holding whatever it held, so
733 // the note that it was written has to go with it.
734 .catch(() => state.sent.delete(band.key as string))
735 }
736
737 if (state.tick % REDRAW_EVERY === 0 && bands.some(b => !b.key)) {
738 $.ui.invalidate('ui.render')
739 }
740}
741
742/** Whether two cuts name the same Rasters over the same rows. */
743function sameBands(a: Band[], b: Band[]): boolean {
744 return (
745 a.length === b.length &&
746 a.every((band, i) => band.key === b[i].key && band.from === b[i].from && band.rows === b[i].rows)
747 )
748}
749
750/** One frame: advance the spinner, read the journal on the slow beat, repaint. */
751async function frame($: EngineInterface, run: RunState): Promise<void> {
752 state.tick++
753
754 const nowMs = await $.clock.now()
755
756 state.nowMs = nowMs
757
758 if (state.tick % READ_EVERY === 0 && run.status === 'running') {
759 await readRun($, run, nowMs)
760 }
761
762 // A selected agent that is still working has more to say each time it is read.
763 if (state.selectedId && state.tick % READ_EVERY === 0) {
764 await loadDetail($, run, state.selectedId)
765 }
766
767 repaint($, run, nowMs)
768
769 // A finished run keeps its last frame; nothing is left to animate once no
770 // agent is running.
771 if (run.status !== 'running' && !run.agents.some(a => a.state === 'running')) {
772 stopTimer()
773 repaint($, run, run.endedMs ?? nowMs)
774 }
775}
776
777function startTimer($: EngineInterface, run: RunState): void {
778 stopTimer()
779
780 state.timer = $.clock.every(state.hasRaster ? FRAME_MS : FRAME_MS_REDRAW, () => {
781 void frame($, run)
782 })
783}
784
785
786/**
787 * Opens the pane with no run on it: the idle view, which says nothing is
788 * running and offers the session's earlier runs to look at.
789 */
790async function showIdle($: EngineInterface, focus: boolean): Promise<void> {
791 state.shown = null
792 state.picking = false
793 showDetail(state, null)
794 stopTimer()
795
796 await $.ui.open(
797 focus ? { id: PANE_ID, title: 'workflow', focus: true } : { id: PANE_ID, title: 'workflow' },
798 )
799
800 state.isPaneOpen = true
801}
802
803/** Opens the pane on a run and starts (or, for a finished run, skips) the timer. */
804async function showRun($: EngineInterface, run: RunState, focus: boolean): Promise<void> {
805 state.shown = run
806 state.picking = false
807 showDetail(state, null)
808
809 // `rows` is what the pane asks for while the surface seats it inline above the
810 // prompt — the full-width seat a session gets outside the fullscreen renderer.
811 // The dock ignores it, so it costs nothing to send either way.
812 const open: { id: string; title: string; focus?: true; rows?: number } = {
813 id: PANE_ID,
814 title: `workflow · ${run.name}`,
815 }
816
817 if (focus) {
818 open.focus = true
819 }
820
821 if (state.paneRows) {
822 open.rows = state.paneRows
823 }
824
825 await $.ui.open(open)
826
827 state.isPaneOpen = true
828 state.nowMs = await $.clock.now()
829
830 await readRun($, run, state.nowMs)
831
832 if (run.status === 'running') {
833 startTimer($, run)
834 } else {
835 repaint($, run, run.endedMs ?? state.nowMs)
836 }
837}
838
839/** Acts on whatever a Button's onPress recorded, now that `$` is in hand. */
840async function actOnPress($: EngineInterface): Promise<void> {
841 const pressed = state.pressed
842
843 state.pressed = null
844
845 if (!pressed) {
846 return
847 }
848
849 // What the press means to the view is decided in `press.ts`, which knows
850 // nothing about the engine; what is left here is the part that needs one.
851 const result = applyPress(state, pressed, {
852 hasRun: state.shown !== null,
853 detail: state.detailView,
854 body: state.bodyView,
855 runList: state.runListView,
856 })
857
858 for (const key of result.store ?? []) {
859 await $.store.set(key, state[key])
860 }
861
862 if (result.run !== undefined) {
863 const run = state.runs.find(r => r.runId === result.run)
864
865 if (run) {
866 // `showRun` retitles the pane, reads the run in, and starts or skips the
867 // timer, whichever site the drawing is on.
868 await showRun($, run, false)
869 }
870 }
871
872 if (result.opened !== undefined && state.shown) {
873 await loadDetail($, state.shown, result.opened)
874 }
875
876 state.nowMs = await $.clock.now()
877
878 if (state.shown) {
879 repaint($, state.shown, state.nowMs)
880 }
881
882 // A blit alone would leave the footer's button and the state beside it showing
883 // what the pane used to be set to, and the idle view has nothing to blit into.
884 $.ui.invalidate('ui.render')
885}
886
887/**
888 * The layout words `/flowpane` takes, against the orientations they name.
889 *
890 * The first two name the axis the run reads along, which is what a reader
891 * picking between them is choosing. They were `across` and `down`, which name
892 * the same two axes in words the pane uses for a dozen other things — a card's
893 * frame is drawn `across` and `down`, a band's rule runs `across` — so the one
894 * place the word had to mean the layout was the one place it did not stand out.
895 * `across` and `down` still work, unlisted, for anyone who learned them.
896 *
897 * `list` names the drawing rather than an axis, because that is what picking it
898 * changes: the phases still run down the pane, and what stands in each of them
899 * is a row an agent instead of a band of cards.
900 */
901const LAYOUT_WORDS: Record<string, Orientation> = {
902 horizontal: 'horizontal',
903 vertical: 'vertical',
904 timeline: 'timeline',
905 list: 'list',
906 fits: 'auto',
907 across: 'horizontal',
908 down: 'vertical',
909}
910
911/**
912 * What the orientations were called before 0.5.0, against their names now.
913 *
914 * Read separately from the words above because they are read for a different
915 * reason: nobody picks one now. They are what the layout control wrote into the
916 * store, and what the manifest documented its `orientation` setting as taking,
917 * so a reader who picked a layout on an earlier build — or who set one in their
918 * own config — has `flow`, `stack` or `time` saved. Read strictly that is not an
919 * orientation at all: the preference was dropped on the way in and the pane came
920 * back on `auto`, which reads as a control that does not hold rather than as a
921 * rename. Consulted after the words a reader can type, so the surface the help
922 * lists is the surface these three do not join.
923 */
924const WAS_CALLED: Record<string, Orientation> = {
925 flow: 'horizontal',
926 stack: 'vertical',
927 time: 'timeline',
928}
929
930/**
931 * Every form of the command, against what it does.
932 *
933 * The lines are built from `COMMAND` and padded to the widest of them rather
934 * than laid out by hand: the name was written out seven times here once, and a
935 * rename that missed one produced a help page that taught the wrong word.
936 */
937const HELP = ((forms: [string, string][]) => {
938 const width = Math.max(...forms.map(([args]) => `/${COMMAND} ${args}`.trimEnd().length)) + 3
939
940 return forms.map(([args, does]) => `${`/${COMMAND} ${args}`.trimEnd().padEnd(width)}${does}`).join('\n')
941})([
942 ['', 'open the pane, or close it'],
943 ['runs', 'list this session’s runs'],
944 ['<n>', 'show run <n>'],
945 ['horizontal|vertical|timeline|list|fits', 'lay the graph out'],
946 ['detail <n>', 'rows the detail dialog takes (5–32)'],
947 ['theme [name]', 'list the palettes, or paint in one'],
948 ['about', 'what the pane is, and what presses it'],
949])
950
951/**
952 * A run's state as one character.
953 *
954 * The pane already says what the run is doing, in words, on its top line. A
955 * chooser that says it again underneath means the same fact is on screen twice
956 * in two different wordings, and a reader checks both. The mark is the same
957 * vocabulary the nodes use, so it reads without a key.
958 */
959function markOf(run: RunState): string {
960 return run.status === 'running'
961 ? '\u25b8'
962 : run.status === 'completed'
963 ? '\u2714'
964 : run.status === 'failed'
965 ? '\u2716'
966 : '\u2298'
967}
968
969/** One run as the list and the picker both name it. */
970function runLine(run: RunState, index: number): string {
971 return `${index + 1}. ${runLabel(run)}`
972}
973
974/** What a run is called, with what it is and how far it got. */
975function runLabel(run: RunState): string {
976 return `${markOf(run)} ${run.name} ${tallyOf(run)}`
977}
978
979/** How many of a run's agents have landed, over how many it has. */
980function tallyOf(run: RunState): string {
981 const landed = run.agents.filter(a => a.state === 'done' || a.state === 'failed').length
982
983 return `${landed}/${run.agents.length}`
984}
985
986/**
987 * One run as the menu lists it. The menu draws the state, the name and the
988 * clock in columns of their own, so it takes the parts rather than the line.
989 */
990function runEntry(run: RunState): RunEntry {
991 return {
992 id: run.runId,
993 mark: markOf(run),
994 name: run.name,
995 tally: tallyOf(run),
996 status: run.status,
997 startedMs: run.startedMs,
998 }
999}
1000
1001/**
1002 * `/flowpane` with something after it.
1003 *
1004 * Every choice the settings dialog offers is reachable here too. That is what
1005 * makes the hint line under the prompt a usable seat rather than a trap: it
1006 * draws no Buttons, so neither the dialog nor the button that opens it exists
1007 * there, and without these words there is no way back off it.
1008 */
1009async function applyArgs($: EngineInterface, args: string): Promise<string> {
1010 const words = args.split(/\s+/)
1011 const verb = words[0].toLowerCase()
1012 const rest = words[1]
1013
1014 if (verb === 'help') {
1015 return HELP
1016 }
1017
1018 // The same words the About dialog draws. This is the seat where a reader
1019 // needs them most: no pane, no buttons, and no way to press a name.
1020 if (verb === 'about') {
1021 return aboutText()
1022 }
1023
1024 if (verb === 'runs') {
1025 if (state.runs.length === 0) {
1026 return 'No runs in this session yet. Launch a workflow and the view opens on it.'
1027 }
1028
1029 return state.runs.map(runLine).join('\n')
1030 }
1031
1032 if (verb === 'theme') {
1033 if (!rest) {
1034 return `Themes: ${THEMES.map(t => (t.name === state.theme ? `${t.name} (on)` : t.name)).join(', ')}.`
1035 }
1036
1037 if (!THEMES.some(t => t.name === rest)) {
1038 return `No theme "${rest}". Themes: ${THEMES.map(t => t.name).join(', ')}.`
1039 }
1040
1041 state.theme = rest
1042 useTheme(state.theme)
1043
1044 await $.store.set('theme', state.theme)
1045 $.ui.invalidate('ui.render')
1046
1047 return `Theme ${state.theme}.`
1048 }
1049
1050 if (verb in LAYOUT_WORDS) {
1051 state.orientation = LAYOUT_WORDS[verb]
1052
1053 await $.store.set('orientation', state.orientation)
1054 $.ui.invalidate('ui.render')
1055
1056 return `${layoutLabel()}.`
1057 }
1058
1059 const count = Number(rest)
1060
1061 if (verb === 'detail' && Number.isFinite(count)) {
1062 state.detailRows = Math.max(MIN_DETAIL, Math.min(MAX_DETAIL, Math.floor(count)))
1063
1064 await $.store.set('detailRows', state.detailRows)
1065 $.ui.invalidate('ui.render')
1066
1067 return `Detail dialog ${state.detailRows} rows.`
1068 }
1069
1070 // A bare number, or `run 2`, names a run in the order `/flowpane runs` listed them.
1071 const which = Number(verb === 'run' ? rest : verb)
1072
1073 if (Number.isFinite(which)) {
1074 const run = state.runs[Math.floor(which) - 1]
1075
1076 if (!run) {
1077 return state.runs.length === 0
1078 ? 'No runs in this session yet.'
1079 : `No run ${Math.floor(which)}. There ${state.runs.length === 1 ? 'is 1 run' : `are ${state.runs.length} runs`}; /flowpane runs lists them.`
1080 }
1081
1082 await showRun($, run, true)
1083 $.ui.invalidate('ui.render')
1084
1085 return runLine(run, state.runs.indexOf(run))
1086 }
1087
1088 return `/flowpane takes no "${args}". ${HELP}`
1089}
1090
1091
1092/**
1093 * A layout by either name. The button and `/flowpane` say what the layout looks like
1094 * — horizontal, vertical, timeline, list, fits — and the setting is named after the
1095 * axis it uses. One vocabulary would be better; until the stored values can change, both
1096 * are read wherever a layout is named.
1097 */
1098export function orientationOf(value: unknown): Orientation | null {
1099 if (ORIENTATIONS.includes(value as Orientation)) {
1100 return value as Orientation
1101 }
1102
1103 const word = String(value ?? '').toLowerCase()
1104
1105 if (word in LAYOUT_WORDS) {
1106 return LAYOUT_WORDS[word]
1107 }
1108
1109 return word in WAS_CALLED ? WAS_CALLED[word] : null
1110}
1111
1112
1113function readOptions(options: PluginOptions): void {
1114 const orientation = orientationOf(options.orientation)
1115
1116 if (orientation) {
1117 state.orientation = orientation
1118 }
1119
1120 const rows = Number(options.detailRows)
1121
1122 if (Number.isFinite(rows) && rows >= 5 && rows <= 32) {
1123 state.detailRows = Math.floor(rows)
1124 }
1125
1126 const pane = Number(options.paneRows)
1127
1128 if (Number.isFinite(pane) && pane >= 6 && pane <= 80) {
1129 state.paneRows = Math.floor(pane)
1130 }
1131
1132 if (typeof options.theme === 'string' && THEMES.some(t => t.name === options.theme)) {
1133 state.theme = options.theme
1134 }
1135
1136 useTheme(state.theme)
1137}
1138
1139/**
1140 * The version this install ships, read off the manifest the engine loaded it by.
1141 *
1142 * The pane used to name a constant in `hooks/about.ts` and nothing held the two
1143 * together: the suite runs without an `fs` noun, so no test can open the
1144 * manifest, and the check lived in `dev/checkmeta.ts` for somebody to remember.
1145 * Read here, the drawn version is the installed one whatever the constant says.
1146 *
1147 * Every way this can fail ends in the same place — the pane keeps the constant.
1148 * `$.plugin` is absent in a test and the read is refused or missing on an
1149 * install the engine placed somewhere this cannot reach.
1150 */
1151async function readManifest($: EngineInterface): Promise<void> {
1152 try {
1153 // Spelled out rather than reached for through `?.`: the host scans this
1154 // file before it loads it and refuses `$.plugin` written any way but
1155 // `$.plugin.name` or `$.plugin.root`, so the absence is caught here
1156 // instead. It is absent under the test runner, whose `$` carries the events
1157 // a plugin fires and not the nouns the engine fills in around them.
1158 const root = $.plugin.root
1159
1160 noteShipped(await $.fs.read(`${root}/.claude-plugin/plugin.json`).catch(() => undefined))
1161 } catch {
1162 noteShipped(undefined)
1163 }
1164}
1165
1166/** The choices a person made with the buttons, kept across sessions. */
1167async function readStore($: EngineInterface): Promise<void> {
1168 const orientation = orientationOf(await $.store.get('orientation'))
1169
1170 if (orientation) {
1171 state.orientation = orientation
1172 }
1173
1174 const detailRows = Number(await $.store.get('detailRows'))
1175
1176 if (Number.isFinite(detailRows) && detailRows >= 5 && detailRows <= 32) {
1177 state.detailRows = Math.floor(detailRows)
1178 }
1179
1180 const theme = await $.store.get('theme')
1181
1182 if (typeof theme === 'string' && THEMES.some(t => t.name === theme)) {
1183 state.theme = theme
1184 }
1185
1186 useTheme(state.theme)
1187}
1188
1189export function register(on: On, options: PluginOptions) {
1190 readOptions(options ?? {})
1191
1192 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => {
1193 const out = await next(e)
1194 const launch = launchOf((out as { result?: unknown }).result)
1195
1196 if (!launch?.runId || !launch.transcriptDir) {
1197 return out
1198 }
1199
1200 const script =hooks/canvas.ts 593 lines1/**
2 * A truecolor cell buffer and the drawing primitives the graph is painted with.
3 *
4 * A Raster's cells are `columns * rows` little-endian u32 triplets
5 * `[codePoint, foreground, background]`, base64 of that buffer. Every code
6 * point has to be one printable width-1 BMP character, so the whole vocabulary
7 * here is box drawing, blocks and braille — which is enough for state rules
8 * down a node's edge, orthogonal edge routing and a row of bars.
9 */
10
11/** The terminal's own color, as bit 24 alone rather than an RGB value. */
12export const DEFAULT_COLOR = 0x01000000
13
14/**
15 * What stands in for a character this grid cannot hold: the terminal's own sign
16 * for a glyph it has nothing to draw.
17 *
18 * Every cell of a Raster is one column wide, and every measurement on this pane
19 * — a card's frame, a label's room, the column a wire turns in — counts cells.
20 * A character the terminal gives two columns to breaks that: the glyph is
21 * drawn, and every cell after it on the row is drawn one column right of where
22 * the drawing put it, so a card's right edge lands outside the card and the
23 * pane's own edge lands outside the pane. One wide glyph in one label is enough
24 * to do it, and agent labels come from whatever a workflow script called them.
25 *
26 * So the grid is what holds and the glyph is what gives way. A label in a
27 * script that is written in Japanese comes out as boxes, which is the same
28 * thing a terminal with no font for it would show, and the drawing around it is
29 * still a drawing.
30 */
31const TOFU = 0x25a1
32
33/**
34 * Whether a terminal gives this code point two columns instead of one.
35 *
36 * The East Asian Wide and Fullwidth blocks, and the handful of symbols outside
37 * them that default to an emoji presentation. Not the ambiguous-width ones: the
38 * marks this pane is drawn with — `\u2714`, `\u2298`, `\u2211` — are ambiguous, and
39 * refusing those would leave nothing to draw with.
40 */
41export function wide(code: number): boolean {
42 return (
43 (code >= 0x1100 && code <= 0x115f) ||
44 (code >= 0x2e80 && code <= 0x303e) ||
45 (code >= 0x3041 && code <= 0x33ff) ||
46 (code >= 0x3400 && code <= 0x4dbf) ||
47 (code >= 0x4e00 && code <= 0x9fff) ||
48 (code >= 0xa000 && code <= 0xa4cf) ||
49 (code >= 0xa960 && code <= 0xa97f) ||
50 (code >= 0xac00 && code <= 0xd7a3) ||
51 (code >= 0xf900 && code <= 0xfaff) ||
52 (code >= 0xfe10 && code <= 0xfe19) ||
53 (code >= 0xfe30 && code <= 0xfe6f) ||
54 (code >= 0xff00 && code <= 0xff60) ||
55 (code >= 0xffe0 && code <= 0xffe6) ||
56 // The symbols a terminal draws as emoji whether or not it is asked to.
57 (code >= 0x231a && code <= 0x231b) ||
58 (code >= 0x23e9 && code <= 0x23ec) ||
59 code === 0x23f0 ||
60 code === 0x23f3 ||
61 (code >= 0x25fd && code <= 0x25fe) ||
62 (code >= 0x2614 && code <= 0x2615) ||
63 (code >= 0x2648 && code <= 0x2653) ||
64 code === 0x267f ||
65 code === 0x2693 ||
66 code === 0x26a1 ||
67 (code >= 0x26aa && code <= 0x26ab) ||
68 (code >= 0x26bd && code <= 0x26be) ||
69 (code >= 0x26c4 && code <= 0x26c5) ||
70 code === 0x26ce ||
71 code === 0x26d4 ||
72 code === 0x26ea ||
73 (code >= 0x26f2 && code <= 0x26f3) ||
74 code === 0x26f5 ||
75 code === 0x26fa ||
76 code === 0x26fd ||
77 code === 0x2705 ||
78 (code >= 0x270a && code <= 0x270b) ||
79 code === 0x2728 ||
80 code === 0x274c ||
81 code === 0x274e ||
82 (code >= 0x2753 && code <= 0x2755) ||
83 code === 0x2757 ||
84 (code >= 0x2795 && code <= 0x2797) ||
85 code === 0x27b0 ||
86 code === 0x27bf ||
87 (code >= 0x2b1b && code <= 0x2b1c) ||
88 code === 0x2b50 ||
89 code === 0x2b55
90 )
91}
92
93/**
94 * Whether a terminal gives this code point no column at all: a combining mark,
95 * a variation selector, a zero-width joiner or space.
96 *
97 * Written into a cell of its own, one of these takes a column the terminal does
98 * not give it back — and a variation selector does worse, since what it does is
99 * turn the character before it into a wide one after that character has already
100 * been measured and placed.
101 */
102export function hidden(code: number): boolean {
103 return (
104 (code >= 0x0300 && code <= 0x036f) ||
105 (code >= 0x1ab0 && code <= 0x1aff) ||
106 (code >= 0x20d0 && code <= 0x20f0) ||
107 (code >= 0x200b && code <= 0x200f) ||
108 (code >= 0xfe00 && code <= 0xfe0f) ||
109 (code >= 0xfe20 && code <= 0xfe2f) ||
110 (code >= 0x2060 && code <= 0x2064) ||
111 code === 0xfeff
112 )
113}
114
115/**
116 * How many cells a string takes once it is written: one per code point the grid
117 * keeps, and none for the ones it drops.
118 *
119 * `String.length` counts UTF-16 units, so it reads an astral character as two
120 * and a combining mark as one — and every budget measured that way hands out
121 * room the text does not use or room it overruns.
122 */
123export function cells(s: string): number {
124 let n = 0
125
126 for (const ch of s) {
127 if (!hidden(ch.codePointAt(0) ?? 0x20)) {
128 n++
129 }
130 }
131
132 return n
133}
134
135export type Rgb = number
136
137export function rgb(r: number, g: number, b: number): Rgb {
138 return ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff)
139}
140
141/**
142 * The color a Raster cell is drawn in, which is not always the color it was
143 * given: the surface keeps four bits a channel there, rounding each to the
144 * nearest seventeenth, so `#282828` is painted `#222222`.
145 *
146 * An element's color is not rounded, so the same ground came out six values
147 * apart on the two kinds of row a drawing is cut into, and every row carrying a
148 * node's label had a band across the pane. Elements are given the color the
149 * cells beside them will be drawn in.
150 */
151export function cellColor(value: Rgb): Rgb {
152 if (value === DEFAULT_COLOR) {
153 return value
154 }
155
156 const channel = (shift: number) => Math.round(((value >> shift) & 0xff) / 17) * 17
157
158 return (channel(16) << 16) | (channel(8) << 8) | channel(0)
159}
160
161/** Mixes two colors, `t` from 0 (all `a`) to 1 (all `b`). */
162export function mix(a: Rgb, b: Rgb, t: number): Rgb {
163 if (a === DEFAULT_COLOR || b === DEFAULT_COLOR) {
164 return t < 0.5 ? a : b
165 }
166
167 const k = Math.max(0, Math.min(1, t))
168 const ch = (shift: number) => {
169 const from = (a >> shift) & 0xff
170 const to = (b >> shift) & 0xff
171
172 return Math.round(from + (to - from) * k) & 0xff
173 }
174
175 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
176}
177
178export class Canvas {
179 readonly columns: number
180 readonly rows: number
181
182 private readonly words: Uint32Array
183
184 /** The ground every clear paints: the terminal's own, or an opaque colour. */
185 readonly background: Rgb
186
187 constructor(columns: number, rows: number, background: Rgb = DEFAULT_COLOR) {
188 // A NaN or infinite size would make a buffer with no cells, and a drawing
189 // that silently draws nothing; a bad measurement becomes a small canvas.
190 this.columns = Math.max(1, Math.min(512, Number.isFinite(columns) ? Math.floor(columns) : 20))
191 this.rows = Math.max(1, Math.min(256, Number.isFinite(rows) ? Math.floor(rows) : 6))
192 this.words = new Uint32Array(this.columns * this.rows * 3)
193 this.background = background
194
195 this.clear()
196 }
197
198 /**
199 * The rectangle drawing lands in. Everything outside it is dropped.
200 *
201 * A drawing larger than the pane used to be cut down until it fitted, which
202 * meant the run had to be made smaller to be seen at all. With a window, the
203 * layout is done whole and moved under a header that stays put: the cells
204 * that fall outside simply do not land, so a lane shifted half off the top
205 * draws the half that is still in the body and nothing over the header.
206 */
207 private clip = { x: 0, y: 0, w: 0, h: 0 }
208
209 /**
210 * Sets the window, or clears it when called with nothing. Drawing between
211 * the two calls is clipped to the rectangle; drawing outside them is not.
212 */
213 window(x?: number, y?: number, w?: number, h?: number): void {
214 this.clip =
215 x === undefined
216 ? { x: 0, y: 0, w: this.columns, h: this.rows }
217 : { x, y: y ?? 0, w: w ?? this.columns, h: h ?? this.rows }
218 }
219
220 clear(background: Rgb = this.background): void {
221 // Each frame starts with the whole pane open, so a painter that set a
222 // window and threw cannot leave the next frame clipped to it.
223 this.window()
224
225 for (let i = 0; i < this.columns * this.rows; i++) {
226 this.words[i * 3] = 0x20
227 this.words[i * 3 + 1] = DEFAULT_COLOR
228 this.words[i * 3 + 2] = background
229 }
230 }
231
232 /** Writes one cell, ignoring anything outside the buffer. */
233 put(x: number, y: number, codePoint: number, fg: Rgb, bg?: Rgb): void {
234 if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) {
235 return
236 }
237
238 const clip = this.clip
239
240 if (x < clip.x || y < clip.y || x >= clip.x + clip.w || y >= clip.y + clip.h) {
241 return
242 }
243
244 const i = (y * this.columns + x) * 3
245
246 this.words[i] = codePoint
247 this.words[i + 1] = fg
248
249 if (bg !== undefined) {
250 this.words[i + 2] = bg
251 }
252 }
253
254 /** One cell's code point and colors, for a surface without a Raster. */
255 cell(x: number, y: number): { code: number; fg: Rgb; bg: Rgb } {
256 const i = (y * this.columns + x) * 3
257
258 return { code: this.words[i], fg: this.words[i + 1], bg: this.words[i + 2] }
259 }
260
261 /** The code point at a cell, or a space for one off the buffer. */
262 at(x: number, y: number): number {
263 if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) {
264 return 0x20
265 }
266
267 // A cell outside the window reads blank, so a line drawn up to the edge of
268 // the body joins nothing on the other side of it.
269 const clip = this.clip
270
271 if (x < clip.x || y < clip.y || x >= clip.x + clip.w || y >= clip.y + clip.h) {
272 return 0x20
273 }
274
275 return this.words[(y * this.columns + x) * 3]
276 }
277
278 /** Draws text, clipped to `max` columns and to the buffer's width. */
279 text(x: number, y: number, s: string, fg: Rgb, bg?: Rgb, max?: number): number {
280 const limit = max ?? this.columns - x
281 let drawn = 0
282
283 for (const ch of s) {
284 if (drawn >= limit) {
285 break
286 }
287
288 const code = ch.codePointAt(0) ?? 0x20
289
290 // A character the terminal gives no column of its own takes none here
291 // either: written into a cell it would consume one and render in the cell
292 // before it, which shifts the row by one and leaves the mark doubled.
293 if (hidden(code)) {
294 continue
295 }
296
297 // Anything outside the BMP, a control character, or a character two
298 // columns wide would be refused: a cell is one printable column, and
299 // every measurement on this pane counts cells. See `TOFU`.
300 const held = code < 0x20 ? 0x20 : code > 0xffff || wide(code) ? TOFU : code
301
302 this.put(x + drawn, y, held, fg, bg)
303 drawn++
304 }
305
306 return drawn
307 }
308
309 fill(x: number, y: number, w: number, h: number, bg: Rgb): void {
310 for (let dy = 0; dy < h; dy++) {
311 for (let dx = 0; dx < w; dx++) {
312 this.put(x + dx, y + dy, 0x20, DEFAULT_COLOR, bg)
313 }
314 }
315 }
316
317 /**
318 * The base64 the `cells` prop and `$.ui.blit` both take.
319 *
320 * A hooks module runs with neither Node nor a DOM, so this is
321 * `Uint8Array.toBase64` — the encoding the Raster's own documentation uses —
322 * with a hand-rolled fall-back for a runtime that predates it.
323 */
324 encode(fromRow = 0, rows = this.rows - fromRow): string {
325 const from = Math.max(0, Math.min(this.rows, Math.floor(fromRow)))
326 const count = Math.max(0, Math.min(this.rows - from, Math.floor(rows)))
327 const bytes = new Uint8Array(
328 this.words.buffer,
329 from * this.columns * 3 * 4,
330 count * this.columns * 3 * 4,
331 )
332 const native = (bytes as { toBase64?: () => string }).toBase64
333
334 if (typeof native === 'function') {
335 return native.call(bytes)
336 }
337
338 const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
339 let out = ''
340
341 for (let i = 0; i < bytes.length; i += 3) {
342 const a = bytes[i]
343 const b = bytes[i + 1]
344 const c = bytes[i + 2]
345 const word = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
346
347 out += ALPHABET[(word >> 18) & 63] + ALPHABET[(word >> 12) & 63]
348 out += b === undefined ? '=' : ALPHABET[(word >> 6) & 63]
349 out += c === undefined ? '=' : ALPHABET[word & 63]
350 }
351
352 return out
353 }
354}
355
356const ROUND = { tl: 0x256d, tr: 0x256e, br: 0x256f, bl: 0x2570 }
357const H = 0x2500
358const V = 0x2502
359
360/**
361 * Which way each line character points, as up | right | down | left.
362 *
363 * Edge routes cross constantly, so a cell has to be able to say what is already
364 * in it before a second route decides what to put there. The dashed pieces
365 * carry the same arms as the solid ones: a guessed edge crossing a proven one
366 * still needs a junction, and the junction is drawn solid because half of what
367 * meets there is.
368 */
369const UP = 0b0001
370const RIGHT = 0b0010
371const DOWN = 0b0100
372const LEFT = 0b1000
373
374/** The four directions a line piece can point, for callers that build one. */
375export const ARM = { up: UP, right: RIGHT, down: DOWN, left: LEFT }
376
377/**
378 * The arc a run makes over a line it merely passes.
379 *
380 * Drawn as a junction the two lines look joined, and a reader following one arm
381 * of a `\u253c` has no way to tell which of the other three continues it. Drawn
382 * as a gap the run looks like it stops. The arc is what wiring diagrams have
383 * always used instead: the run steps over, and what it steps over is whole on
384 * both sides.
385 */
386export const HOP = 0x25e0
387
388/**
389 * The point a line leaves a card by: one to a side, in the middle of it.
390 *
391 * A line that simply begins against a frame reads as part of the frame, and a
392 * card with four lines coming off four different rows reads as four cards. The
393 * dot says where a card connects and the run starts a cell past it. Nothing is
394 * ever drawn over one — a line crossing a card's own point would take the one
395 * mark that says which card it belongs to.
396 */
397export const PORT = 0x2022
398
399/**
400 * The marks in the gutter that no line may be drawn over, the port among them.
401 *
402 * A line drawn through one takes a fact off the pane rather than a cell of a
403 * line that is drawn again a cell along: the port says which card a run belongs
404 * to, and the double stroke says the two phases either side of it ran at the
405 * same time. Both stand in the gutter, which is exactly where the wires are, so
406 * a wire meeting one breaks rather than crosses.
407 */
408const KEPT = new Set([PORT, 0x2550, 0x2551])
409
410const ARMS: Record<number, number> = {
411 [H]: LEFT | RIGHT,
412 [V]: UP | DOWN,
413 0x2504: LEFT | RIGHT,
414 0x2506: UP | DOWN,
415 [ROUND.tl]: RIGHT | DOWN,
416 [ROUND.tr]: LEFT | DOWN,
417 [ROUND.br]: UP | LEFT,
418 [ROUND.bl]: UP | RIGHT,
419 0x251c: UP | RIGHT | DOWN,
420 0x2524: UP | DOWN | LEFT,
421 0x252c: RIGHT | DOWN | LEFT,
422 0x2534: UP | RIGHT | LEFT,
423 0x253c: UP | RIGHT | DOWN | LEFT,
424 [HOP]: LEFT | RIGHT,
425}
426
427/** The character for a set of arms, for every set worth more than one piece. */
428const JOINED = new Map<number, number>(
429 Object.entries(ARMS)
430 // A dashed piece has the arms of its solid twin, and the solid one is the
431 // answer: the entries are read in order, so the solid pieces come first and
432 // the dashed ones do not displace them.
433 .map(([code, arms]) => [arms, Number(code)] as const)
434 .filter(([arms]) => arms !== (LEFT | RIGHT) && arms !== (UP | DOWN))
435 .concat([
436 [LEFT | RIGHT, H],
437 [UP | DOWN, V],
438 ]),
439)
440
441/**
442 * The line character pointing exactly these ways, or `undefined` where no
443 * single character does — a stub of one arm, or nothing at all.
444 */
445export function joint(arms: number): number | undefined {
446 return JOINED.get(arms)
447}
448
449/** The ways a line character points, or nothing where the cell holds no line. */
450export function armsOf(code: number): number | undefined {
451 return ARMS[code]
452}
453
454/**
455 * Draws one piece of a line, joining whatever is already in the cell.
456 *
457 * Without this a later route punches a hole through an earlier one. The old
458 * table of pairs got the common crossings right and quietly overwrote the rest:
459 * a route arriving at a cell that had already become a junction found no entry
460 * for it and replaced it, which turned the `┤` where four agents fed a bus into
461 * the `╭` of whichever edge left it last.
462 *
463 * A card's point is not joined but kept. A corner standing on one says what the
464 * point already says — a run starts in this cell — and takes with it the one
465 * mark that says which card the run belongs to, leaving the corner's other arm
466 * pointing into the blank between the card's name and the gutter.
467 */
468export function line(c: Canvas, x: number, y: number, code: number, fg: Rgb): void {
469 const under = c.at(x, y)
470
471 if (KEPT.has(under)) {
472 return
473 }
474
475 const held = ARMS[under]
476 const adding = ARMS[code]
477
478 if (held === undefined || adding === undefined || held === adding) {
479 c.put(x, y, code, fg)
480
481 return
482 }
483
484 c.put(x, y, JOINED.get(held | adding) ?? code, fg)
485}
486
487/**
488 * One piece of a line, arced over whatever already crosses the cell.
489 *
490 * `line` joins what it meets, which is what a junction is for and wrong for
491 * everything else: two runs that share a cell on their way somewhere else are
492 * two runs, not a fork. Where the piece being drawn points across what is
493 * already there — a horizontal over a vertical or the other way about — the
494 * cell is drawn as a hop instead of a cross.
495 */
496export function cross(c: Canvas, x: number, y: number, code: number, fg: Rgb): void {
497 const under = c.at(x, y)
498
499 if (KEPT.has(under)) {
500 return
501 }
502
503 const held = ARMS[under]
504 const adding = ARMS[code]
505
506 if (held !== undefined && adding !== undefined && (held & adding) === 0) {
507 c.put(x, y, HOP, fg)
508
509 return
510 }
511
512 line(c, x, y, code, fg)
513}
514
515/**
516 * Routes an orthogonal edge from one point to another: out to a mid column,
517 * down or up, then in. The color runs from `from` to `to` along the route, so
518 * an edge reads its direction without an arrowhead at every step.
519 */
520export function edge(
521 c: Canvas,
522 x0: number,
523 y0: number,
524 x1: number,
525 y1: number,
526 from: Rgb,
527 to: Rgb,
528 /** True for an edge read off labels, which is drawn in the dashed register. */
529 dashed = false,
530): { x: number; y: number }[] {
531 const points: { x: number; y: number; code: number }[] = []
532 const midX = x1 - 2 <= x0 ? x0 + 1 : Math.max(x0 + 1, x1 - Math.floor((x1 - x0) / 2))
533 // The corners stay solid either way. A dashed corner is one cell with one
534 // dash in it, which reads as a gap in the line rather than as a turn.
535 const across = dashed ? 0x2504 : H
536 const down = dashed ? 0x2506 : V
537
538 for (let x = x0; x < midX; x++) {
539 points.push({ x, y: y0, code: across })
540 }
541
542 if (y0 !== y1) {
543 points.push({
544 x: midX,
545 y: y0,
546 code: y1 > y0 ? ROUND.tr : ROUND.br,
547 })
548
549 const step = y1 > y0 ? 1 : -1
550
551 for (let y = y0 + step; y !== y1; y += step) {
552 points.push({ x: midX, y, code: down })
553 }
554
555 points.push({
556 x: midX,
557 y: y1,
558 code: y1 > y0 ? ROUND.bl : ROUND.tl,
559 })
560 } else {
561 points.push({ x: midX, y: y0, code: across })
562 }
563
564 for (let x = midX + 1; x < x1; x++) {
565 points.push({ x, y: y1, code: across })
566 }
567
568 points.forEach((p, i) => {
569 line(c, p.x, p.y, p.code, mix(from, to, points.length < 2 ? 1 : i / (points.length - 1)))
570 })
571
572 // The head sits on the target's own border cell, so it reads as an arrival.
573 c.put(x1, y1, 0x25b8, to)
574
575 // The route it took, in the order it took it, for anything that has to follow
576 // the line afterwards — a light travelling along it, say.
577 return [...points.map(p => ({ x: p.x, y: p.y })), { x: x1, y: y1 }]
578}
579
580/**
581 * The braille spinner, one frame per 80ms of run time.
582 *
583 * Seven dots of eight, with the hole travelling round: a cell nearly full,
584 * which is as large as a glyph gets. The four-dot spinner it replaced was a
585 * quarter of a cell of ink beside a name drawn in a whole one, so the one mark
586 * on the card that changes was the faintest thing on it.
587 */
588const SPINNER = [0x28fe, 0x28fd, 0x28fb, 0x28bf, 0x287f, 0x28df, 0x28ef, 0x28f7]
589
590export function spinnerAt(tick: number): number {
591 return SPINNER[Math.abs(Math.floor(tick)) % SPINNER.length]
592}
593hooks/journal.ts 1028 lines1/**
2 * The run model the pane draws, and the journal reader that fills it.
3 *
4 * A workflow run writes `journal.jsonl` into its transcript directory as it
5 * goes — one `started` line per agent with its label and phase, one `result`
6 * (or `error`) line when that agent lands — and the engine writes the run's
7 * summary to `workflows/<runId>.json` once the whole run is over. The journal
8 * is the live feed; the summary is the completion signal and the source of the
9 * per-agent numbers the journal has no room for.
10 */
11
12export type AgentState = 'running' | 'done' | 'failed' | 'stopped'
13
14/** One tool the agent called, and how often. */
15export type ToolUse = {
16 name: string
17 count: number
18 /** When the most recent call started, in wall-clock ms. */
19 atMs: number
20 /** True while the most recent call is still running. */
21 isRunning: boolean
22}
23
24/** One tool call, as the `tool.call` chain saw it go by. */
25export type ToolCall = {
26 /** The call's `tool_use_id`, or a counter when the engine gave none. */
27 id: string
28 name: string
29 /**
30 * The argument that says what the call was about: a command, a path, a query.
31 *
32 * Held whole, its own line breaks included, up to `INPUT_MAX` — the Tool Calls tab
33 * draws it as it was written, and a command cut down to its first line is a
34 * command nobody can check.
35 */
36 input: string
37 startedMs: number
38 endedMs?: number
39 isError?: boolean
40 /**
41 * What the tool answered, held the same way its argument is, up to `INPUT_MAX`.
42 *
43 * One line of it used to be kept, which made the reading a header with nothing
44 * under it: a test run's line one is `RUN v2.1.9` and the verdict is thirty
45 * lines down. A run watched live and the same run replayed keep the same
46 * amount, so the two say the same thing.
47 */
48 result?: string
49 /** The model request (1-based) that issued it, and what that request wrote. */
50 step?: number
51 stepTokens?: number
52}
53
54export type AgentRow = {
55 agentId: string
56 label: string
57 phase: string
58 state: AgentState
59 /** Wall-clock ms when this reader first saw the agent start. */
60 startedMs: number
61 /** Wall-clock ms when it landed; undefined while it runs. */
62 endedMs?: number
63 /** First line of the agent's result, for the row's tail. */
64 resultPreview?: string
65 /** Total tokens, from the run summary; absent until the run ends. */
66 tokens?: number
67 /** The tools this agent has called, newest last. Filled live from tool.call. */
68 tools: ToolUse[]
69 /** Every call in order, for the detail dialog; absent until the first lands. */
70 calls?: ToolCall[]
71 /** The prompt the agent was given, read from its transcript on demand. */
72 prompt?: string
73 /** The agent's full result, read from its transcript on demand. */
74 result?: string
75 /** The model it resolved to, from the run summary. */
76 model?: string
77 /** Which try this is, from the run summary: 1 unless the engine retried it. */
78 attempt?: number
79 /** Tool calls the summary counted, for a run the hook chain never watched. */
80 toolCalls?: number
81 /** The last tool the summary saw it call, with what that call was about. */
82 lastTool?: string
83 /** The context its newest model request carried, live from `turn.step`. */
84 liveTokens?: number
85 /** Model requests made so far, counted live from `turn.step`. */
86 steps?: number
87 /** True from a model request's first chunk to its stop. */
88 isThinking?: boolean
89 /** Wall-clock ms of the last thing seen from this agent: a request, a tool call. */
90 activeMs?: number
91 /** The tail of what the model said in its latest request, from the text chunks. */
92 saying?: string
93}
94
95export type RunStatus = 'running' | 'completed' | 'failed' | 'stopped'
96
97export type RunState = {
98 runId: string
99 name: string
100 summary: string
101 transcriptDir: string
102 /** `workflows/<runId>.json`, written when the run ends. */
103 runFile: string
104 startedMs: number
105 endedMs?: number
106 status: RunStatus
107 /** Phase titles in script order, from `meta.phases`. */
108 phases: string[]
109 /**
110 * What the script said each phase was for, where it said anything.
111 *
112 * Titles alone are what the pane needs for a phase with work in it — the
113 * agents say the rest. A phase the run never entered has no agents, so this
114 * is everything it has: see `Step`.
115 */
116 plan?: Step[]
117 /** Agent rows in the order the journal announced them. */
118 agents: AgentRow[]
119 /** The run's return value, once it has one. */
120 result?: string
121 error?: string
122 /** What the script `log()`ged, from the run summary. */
123 logs?: string[]
124 /** Every agent's tokens, from the run summary. */
125 totalTokens?: number
126 /** The model the run resolved to, for an agent whose own is not recorded. */
127 defaultModel?: string
128 /**
129 * True when the run was rebuilt from the files on disk rather than watched
130 * from its launch. Those files carry no clock of their own, so such a run
131 * has its times read off the files instead.
132 */
133 recovered?: boolean
134 /**
135 * Journal lines already folded in. It lives on the run, not on the reader:
136 * the pane can be closed and reopened on the same run, and a reader that
137 * started over would announce every agent a second time.
138 */
139 consumed: number
140}
141
142type JournalLine = {
143 type: string
144 agentId?: string
145 label?: string
146 phase?: string
147 result?: unknown
148 error?: unknown
149}
150
151/**
152 * What a workflow declared about one of its phases, before anything ran in it.
153 *
154 * A phase the run never entered has no agents to draw and, until now, nothing
155 * else either — so the pane drew it as an empty box. The script says more than
156 * its name: what the phase is for, and what it would have run on. Both are
157 * written at launch and neither depends on the run reaching the phase, so they
158 * are what a phase that never ran can still say for itself.
159 */
160export type Step = {
161 title: string
162 /** The one-line description `meta.phases` carries beside the title. */
163 detail?: string
164 /** The model the phase declared, where it declared one. */
165 model?: string
166}
167
168/**
169 * Pulls the declared phases out of a workflow script's `meta` block.
170 *
171 * `meta` is required to be a pure literal, so a scan of the entries inside
172 * `phases: [...]` is enough and costs no evaluation. A script that declares no
173 * phases returns none, and the pane falls back to the phase names the journal
174 * reports.
175 *
176 * The entries are split on the braces rather than scanned field by field
177 * across the whole block: a single pass for `title:` and another for `detail:`
178 * would pair the first title with the first detail wherever a phase in between
179 * declared one and not the other.
180 */
181export function phasesOfScript(script: string): Step[] {
182 const block = /phases\s*:\s*\[([\s\S]*?)\]/.exec(script)
183
184 if (!block) {
185 return []
186 }
187
188 const steps: Step[] = []
189 const field = (entry: string, name: string) =>
190 new RegExp(`${name}\\s*:\\s*['"\`]([^'"\`]*)['"\`]`).exec(entry)?.[1] || undefined
191
192 for (const entry of block[1].matchAll(/\{([^{}]*)\}/g)) {
193 const title = field(entry[1] as string, 'title')
194
195 if (title) {
196 steps.push({ title, detail: field(entry[1] as string, 'detail'), model: field(entry[1] as string, 'model') })
197 }
198 }
199
200 return steps
201}
202
203/**
204 * The workflow's name and the run's id out of a persisted script's file name.
205 *
206 * The engine writes every run's script to `workflows/scripts` at launch, named
207 * `<workflowName>-<runId>.js`. It is the only file a run has before it ends, so
208 * it is what says a run exists while it is still going — and a run id starts
209 * `wf_`, which a workflow name is free to as well, so the split is taken at the
210 * last `wf_` in the name rather than the first.
211 */
212export function runOfScriptName(fileName: string): { name: string; runId: string } | null {
213 const parts = /^(.+)-(wf_.+)\.js$/.exec(fileName)
214
215 return parts ? { name: parts[1], runId: parts[2] } : null
216}
217
218/**
219 * Times one agent of a recovered run by the files the engine stamped for it:
220 * its `.meta.json`, written when it was spawned, and its own transcript,
221 * written to until it stopped. Either may be missing, and the row keeps what it
222 * had when one is.
223 */
224export function applyFileClock(row: AgentRow, spawnedMs?: number, lastWroteMs?: number): void {
225 if (spawnedMs !== undefined) {
226 row.startedMs = Math.round(spawnedMs)
227 }
228
229 if (lastWroteMs !== undefined) {
230 // A running agent is still writing, so its transcript's mtime is when it
231 // last said something rather than when it stopped — and how long ago that
232 // was is what the pane calls an agent quiet by. Read as an end instead, a
233 // busy agent would have been drawn as having stopped minutes ago.
234 if (row.state === 'running') {
235 row.activeMs = Math.max(row.activeMs ?? 0, Math.round(lastWroteMs))
236 } else {
237 row.endedMs = Math.round(lastWroteMs)
238 }
239 }
240
241 // The two files are stamped a moment apart from each other as well as from
242 // the engine's own clock, and an agent that answered at once can have them
243 // land out of order. A bar drawn from a negative duration runs backwards.
244 if (row.endedMs !== undefined && row.startedMs > row.endedMs) {
245 row.startedMs = row.endedMs
246 }
247}
248
249/** The `workflows/<runId>.json` path that pairs with a transcript directory. */
250export function runFileOf(transcriptDir: string, runId: string): string {
251 // <session>/subagents/workflows/<runId> -> <session>/workflows/<runId>.json
252 const session = transcriptDir.replace(/\/subagents\/workflows\/[^/]+$/, '')
253
254 return `${session}/workflows/${runId}.json`
255}
256
257/**
258 * The whole of what an agent answered, as text.
259 *
260 * An agent given a schema answers with an object, and returns it through a tool
261 * call rather than as text — so its transcript holds no answer at all and the
262 * journal line is the only place the value exists. Capped, because a run that
263 * answered with a megabyte would otherwise be held in memory a frame at a time.
264 */
265function answerOfLine(value: unknown): string | undefined {
266 if (value === undefined || value === null) {
267 return undefined
268 }
269
270 const text = typeof value === 'string' ? value : JSON.stringify(value) ?? ''
271
272 return text.length > 20_000 ? text.slice(0, 20_000) : text
273}
274
275function previewOf(value: unknown): string {
276 const text = typeof value === 'string' ? value : JSON.stringify(value) ?? ''
277 const line = text.split('\n').find(l => l.trim().length > 0) ?? ''
278
279 return line.length > 120 ? `${line.slice(0, 117)}...` : line
280}
281
282/**
283 * Folds the journal's lines into the run, in place.
284 *
285 * Only lines past `consumed` are read, so a poll costs one file read and the
286 * new lines' parse; the returned count is the next poll's starting point, and
287 * `changed` says whether the pane needs redrawing.
288 */
289export function applyJournal(run: RunState, text: string, nowMs: number): boolean {
290 const lines = text.split('\n').filter(l => l.trim().length > 0)
291
292 if (lines.length <= run.consumed) {
293 return false
294 }
295
296 let changed = false
297
298 for (const raw of lines.slice(run.consumed)) {
299 let line: JournalLine
300
301 try {
302 line = JSON.parse(raw) as JournalLine
303 } catch {
304 continue
305 }
306
307 if (line.type === 'started' && line.agentId) {
308 // A journal read that overlaps one already folded in must not announce
309 // an agent twice; the id is the agent's identity across reads.
310 if (run.agents.some(a => a.agentId === line.agentId)) {
311 continue
312 }
313
314 run.agents.push({
315 agentId: line.agentId,
316 label: line.label ?? line.agentId.slice(0, 8),
317 phase: line.phase ?? '',
318 state: 'running',
319 startedMs: nowMs,
320 tools: [],
321 })
322
323 if (line.phase && !run.phases.includes(line.phase)) {
324 run.phases.push(line.phase)
325 }
326
327 changed = true
328 continue
329 }
330
331 if (line.type === 'result' || line.type === 'error') {
332 const row = run.agents.find(a => a.agentId === line.agentId)
333
334 if (row) {
335 row.state = line.type === 'error' ? 'failed' : 'done'
336 // When it landed is the reading, not the landing — the journal line
337 // carries no clock. A row the summary has already timed keeps that
338 // time; this only covers the gap before the summary is written.
339 row.endedMs = row.endedMs ?? nowMs
340
341 const said = line.type === 'error' ? line.error : line.result
342
343 row.resultPreview = previewOf(said)
344 // The preview is one line cut to fit a node's tail; the dialog wants the
345 // whole answer, and for an agent that answered through a schema this
346 // line is the only place the whole answer is written down.
347 row.result = row.result ?? answerOfLine(said)
348 changed = true
349 }
350
351 continue
352 }
353 }
354
355 run.consumed = lines.length
356
357 return changed
358}
359
360type RunSummary = {
361 status?: string
362 result?: unknown
363 durationMs?: number
364 startTime?: number
365 logs?: unknown[]
366 totalTokens?: number
367 defaultModel?: string
368 /** Present per agent below; declared here for the row fields that use it. */
369 phases?: { title?: string; detail?: string; model?: string }[]
370 workflowProgress?: {
371 type?: string
372 agentId?: string
373 label?: string
374 phaseTitle?: string
375 state?: string
376 model?: string
377 promptPreview?: string
378 startedAt?: number
379 durationMs?: number
380 tokens?: number
381 resultPreview?: string
382 attempt?: number
383 toolCalls?: number
384 lastToolName?: string
385 lastToolSummary?: string
386 lastProgressAt?: number
387 }[]
388}
389
390/**
391 * Closes the run out from `workflows/<runId>.json`: the final status and
392 * return value, and the per-agent durations and token counts the journal
393 * never carried. A row the summary knows and the journal missed is added, so
394 * a run whose journal the pane joined late still draws whole.
395 */
396export function applyRunFile(run: RunState, text: string, nowMs: number): void {
397 let summary: RunSummary
398
399 try {
400 summary = JSON.parse(text) as RunSummary
401 } catch {
402 return
403 }
404
405 // The summary file exists while the run is still going, so an unfinished
406 // status must read as unfinished: mapping everything that is not 'completed'
407 // to a failure told the person a live run had already failed.
408 run.status =
409 summary.status === 'completed'
410 ? 'completed'
411 : summary.status === 'killed' || summary.status === 'aborted'
412 ? 'stopped'
413 : summary.status === 'failed' || summary.status === 'error'
414 ? 'failed'
415 : 'running'
416
417 if (typeof summary.startTime === 'number') {
418 run.startedMs = summary.startTime
419 }
420
421 if (Array.isArray(summary.logs)) {
422 run.logs = summary.logs.map(previewOf).filter(Boolean)
423 }
424
425 if (typeof summary.totalTokens === 'number') {
426 run.totalTokens = summary.totalTokens
427 }
428
429 if (typeof summary.defaultModel === 'string') {
430 run.defaultModel = summary.defaultModel
431 }
432
433 if (run.status === 'running') {
434 return applyProgress(run, summary, nowMs)
435 }
436
437 run.endedMs =
438 typeof summary.startTime === 'number' && typeof summary.durationMs === 'number'
439 ? summary.startTime + summary.durationMs
440 : nowMs
441 run.result = summary.result === undefined ? undefined : previewOf(summary.result)
442
443 applyProgress(run, summary, nowMs)
444
445 // Nothing in a finished run ended after the run did. A row read back from the
446 // journal long after the fact was stamped with the reading, not the landing;
447 // the run's own end is the latest anything in it can honestly claim.
448 for (const row of run.agents) {
449 if (row.endedMs === undefined || row.endedMs > run.endedMs) {
450 row.endedMs = run.endedMs
451 }
452
453 if (row.startedMs > row.endedMs) {
454 row.startedMs = row.endedMs
455 }
456 }
457}
458
459/** The per-agent rows of a summary, live or final. */
460function applyProgress(run: RunState, summary: RunSummary, nowMs: number): void {
461 for (const step of summary.phases ?? []) {
462 if (!step.title) {
463 continue
464 }
465
466 if (!run.phases.includes(step.title)) {
467 run.phases.push(step.title)
468 }
469
470 // The file is the fuller source: it is written from the engine's own copy
471 // of `meta`, where the script the pane scans at launch is text. Where both
472 // have something to say about a phase, this is the one that is right.
473 const plan = (run.plan ??= [])
474 const at = plan.findIndex(s => s.title === step.title)
475 const said = { title: step.title, detail: step.detail, model: step.model }
476
477 if (at < 0) {
478 plan.push(said)
479 } else {
480 plan[at] = said
481 }
482 }
483
484 for (const entry of summary.workflowProgress ?? []) {
485 if (entry.type !== 'workflow_agent' || !entry.agentId) {
486 continue
487 }
488
489 let row = run.agents.find(a => a.agentId === entry.agentId)
490
491 if (!row) {
492 row = {
493 agentId: entry.agentId,
494 label: entry.label ?? entry.agentId.slice(0, 8),
495 phase: entry.phaseTitle ?? '',
496 state: 'running',
497 startedMs: nowMs,
498 tools: [],
499 }
500
501 run.agents.push(row)
502 }
503
504 row.tokens = entry.tokens
505 row.model = entry.model ?? row.model
506 row.prompt = row.prompt ?? entry.promptPreview
507 row.label = entry.label ?? row.label
508 row.phase = entry.phaseTitle ?? row.phase
509 row.resultPreview = row.resultPreview ?? entry.resultPreview
510
511 if (typeof entry.attempt === 'number') {
512 row.attempt = entry.attempt
513 }
514
515 if (typeof entry.toolCalls === 'number') {
516 row.toolCalls = entry.toolCalls
517 }
518
519 if (entry.lastToolName) {
520 row.lastTool = entry.lastToolSummary
521 ? `${entry.lastToolName} ${entry.lastToolSummary}`
522 : entry.lastToolName
523 }
524
525 if (typeof entry.lastProgressAt === 'number' && entry.lastProgressAt > (row.activeMs ?? 0)) {
526 row.activeMs = entry.lastProgressAt
527 }
528
529 if (entry.state === 'done') {
530 row.state = 'done'
531 } else if (entry.state === 'failed' || entry.state === 'error') {
532 row.state = 'failed'
533 } else if (run.status !== 'running' && row.state === 'running') {
534 // The run is over, so nothing of it is still running: an agent with no
535 // result of its own went down with it.
536 row.state = 'stopped'
537 }
538
539 // `startedAt` and `durationMs` are the engine's own clock. Whatever the
540 // reader guessed from when it happened to see the journal line gives way.
541 if (typeof entry.startedAt === 'number') {
542 row.startedMs = entry.startedAt
543 }
544
545 if (typeof entry.durationMs === 'number') {
546 row.endedMs = row.startedMs + entry.durationMs
547 }
548 }
549}
550
551/**
552 * Records a tool call against the agent that made it.
553 *
554 * A workflow subagent's `tool.call` reaches a plugin hook carrying the same
555 * `agentId` the journal names, so the pane can show what an agent is doing
556 * right now — the one thing neither the journal nor the summary says while the
557 * agent is still running.
558 */
559export function noteToolCall(
560 run: RunState,
561 agentId: string,
562 tool: string,
563 nowMs: number,
564 isRunning: boolean,
565): boolean {
566 const row = run.agents.find(a => a.agentId === agentId)
567
568 if (!row) {
569 return false
570 }
571
572 row.activeMs = Math.max(row.activeMs ?? 0, nowMs)
573
574 const seen = row.tools.find(t => t.name === tool)
575
576 if (seen) {
577 if (isRunning) {
578 seen.count++
579 seen.atMs = nowMs
580 }
581
582 seen.isRunning = isRunning
583 } else {
584 row.tools.push({ name: tool, count: 1, atMs: nowMs, isRunning })
585 }
586
587 return true
588}
589
590/** The keys a `tool.call` event carries beside the tool's own arguments. */
591const RESERVED_KEYS = new Set([
592 'tool',
593 'tool_use_id',
594 'agentId',
595 'consent',
596 'session_id',
597 'transcript_path',
598 'cwd',
599 'prompt_id',
600 'permission_mode',
601 'agent_id',
602 'agent_type',
603 'effort',
604])
605
606/**
607 * The cells of a call's argument the run holds on to.
608 *
609 * A command is the thing a reader opens a detail dialog to read, and it used to
610 * arrive here as the first line of itself cut to a hundred and seventeen cells
611 * — so a heredoc, a pipeline written over three lines, or any `gh api` call with
612 * its flags wrapped was unrecoverable from the moment the chain saw it. No
613 * dialog can undo that, however wide it is drawn.
614 *
615 * The cap is what a person will read rather than what a tool may be handed: an
616 * `Edit` carrying a file's whole new text is not a command anybody reads in a
617 * pane, and holding megabytes of it per call on a seventy-eight agent run is
618 * memory spent on something never drawn. The transcript reader is capped the
619 * same way, so a run watched live and the same run replayed say the same thing.
620 */
621const INPUT_MAX = 4_000
622
623function heldOf(value: string): string {
624 return value.length > INPUT_MAX ? `${value.slice(0, INPUT_MAX)}…` : value
625}
626
627/** The argument that says what a call is about, with its own line breaks kept. */
628export function inputOf(event: Record<string, unknown>): string {
629 const preferred = ['command', 'file_path', 'path', 'pattern', 'query', 'url', 'prompt', 'description']
630
631 for (const key of preferred) {
632 const value = event[key]
633
634 if (typeof value === 'string' && value.trim()) {
635 return heldOf(value)
636 }
637 }
638
639 for (const [key, value] of Object.entries(event)) {
640 if (RESERVED_KEYS.has(key) || value === undefined) {
641 continue
642 }
643
644 return heldOf(typeof value === 'string' ? value : JSON.stringify(value))
645 }
646
647 return ''
648}
649
650/** Opens a call record on the agent: its id, tool and argument, and which request issued it. */
651export function noteCallStart(
652 run: RunState,
653 agentId: string,
654 call: { id: string; name: string; input: string },
655 nowMs: number,
656): boolean {
657 const row = run.agents.find(a => a.agentId === agentId)
658
659 if (!row) {
660 return false
661 }
662
663 row.calls ??= []
664 row.calls.push({ ...call, startedMs: nowMs, step: row.steps })
665
666 return true
667}
668
669/** Closes a call record with what the tool answered. */
670export function noteCallEnd(
671 run: RunState,
672 agentId: string,
673 id: string,
674 nowMs: number,
675 outcome: { result?: unknown; text?: unknown; isError?: boolean },
676): boolean {
677 const call = run.agents.find(a => a.agentId === agentId)?.calls?.find(c => c.id === id)
678
679 if (!call) {
680 return false
681 }
682
683 call.endedMs = nowMs
684 call.isError = outcome.isError === true
685
686 const said = typeof outcome.text === 'string' ? outcome.text : outcome.result
687
688 if (said !== undefined) {
689 call.result = heldOf(typeof said === 'string' ? said : JSON.stringify(said) ?? '')
690 }
691
692 return true
693}
694
695/** One thing a `turn.step` hook saw of an agent's model request. */
696export type StepPart =
697 | { kind: 'start' }
698 | { kind: 'text'; text: string }
699 | { kind: 'stop'; tokens?: number; output?: number }
700 | { kind: 'end' }
701
702/** How much of what the model says the row keeps, for the tail and the detail. */
703const SAYING_MAX = 240
704
705/**
706 * Records what a model request of one agent is doing, as its stream goes by.
707 *
708 * `turn.step` carries the agent's id and streams the request beneath it, so
709 * the pane learns three things the journal and the summary never say while an
710 * agent runs: that it is thinking, what it has said so far, and what its
711 * requests have cost.
712 *
713 * The cost is the context its newest request carried, which is what the run
714 * summary's own `tokens` turns out to be: over the eleven thousand agents on
715 * this machine whose transcript and summary can both be read, the two agree to
716 * within a fraction of a percent on all but seven.
717 *
718 * It was the four usage counts of every request added together, which counts
719 * the same context once per request: an agent that made eleven requests over a
720 * twenty-thousand-token context read `∑ 224k` while the engine's own panel
721 * beside it read `21.3k`, and a long one read `∑ 2m` against `153.5k`. Cached
722 * context is read back whole on every request and is not spent again, so adding
723 * it up measures how often the agent was called rather than what it cost.
724 *
725 * The newest rather than the widest, which are the same figure until a context
726 * is compacted. Five agents in the corpus were: they ran to a quarter of a
727 * million tokens, were cut back, and finished at a third of that — and the
728 * engine reports what they finished carrying. A high-water mark would leave the
729 * pane a hundred thousand above the panel beside it for the rest of the run.
730 *
731 * The seven it misses are retries. The engine's figure for an agent it ran
732 * twice covers both tries and the transcript keeps only the last, so a retry
733 * reads low until the summary lands and replaces the figure outright. A drop in
734 * context cannot be banked instead, because compaction is the same drop and
735 * banking it would double-count the five above.
736 */
737export function noteStep(run: RunState, agentId: string, nowMs: number, part: StepPart): boolean {
738 const row = run.agents.find(a => a.agentId === agentId)
739
740 if (!row) {
741 return false
742 }
743
744 row.activeMs = Math.max(row.activeMs ?? 0, nowMs)
745
746 if (part.kind === 'start') {
747 row.steps = (row.steps ?? 0) + 1
748 row.isThinking = true
749 row.saying = ''
750 } else if (part.kind === 'text') {
751 const said = `${row.saying ?? ''}${part.text}`
752
753 row.saying = said.length > SAYING_MAX ? said.slice(said.length - SAYING_MAX) : said
754 } else if (part.kind === 'stop') {
755 // A usage with nothing in it is the stream being closed rather than a
756 // request being paid for — the last record of a transcript is often one,
757 // every count zero. Taking it as the newest request would drop the figure
758 // to nothing at the moment the agent finished.
759 if (part.tokens) {
760 // The newest request's context, not a running total and not the widest
761 // one either. An agent's context usually only grows, so the widest and
762 // the newest are the same figure — until it is compacted, and then the
763 // agent carries what is left rather than what it had at its fullest. The
764 // engine's own per-agent `tokens` is the newest, on both sides of that.
765 row.liveTokens = part.tokens
766
767 // The calls this request issued are stamped with what it wrote: tokens
768 // belong to a request, not a call, so the same figure stands on each.
769 //
770 // What it wrote, not what it cost. A request's full cost is mostly the
771 // context it read back, which is all but the same figure on every request
772 // an agent makes — a column of `∑ 28k` twenty-five deep says nothing
773 // about any one call. What the model produced to make the call is the
774 // part that moves.
775 const issued = part.output ?? part.tokens
776
777 for (const call of row.calls ?? []) {
778 if (call.step === row.steps && issued !== undefined) {
779 call.stepTokens = issued
780 }
781 }
782 }
783
784 row.isThinking = false
785 } else {
786 row.isThinking = false
787 }
788
789 return true
790}
791
792/**
793 * The context a request carried: what was sent fresh, what was written to the
794 * cache, and what was read back out of it.
795 *
796 * Not the output. The engine's own per-agent `tokens` is this figure at its
797 * widest, and adding the output on overshoots it — the run summary is counting
798 * what the agent had to hold, not what it produced.
799 */
800export function contextOfUsage(usage: {
801 input_tokens?: number
802 output_tokens?: number
803 cache_read_input_tokens?: number
804 cache_creation_input_tokens?: number
805}): number {
806 return (
807 (usage.input_tokens ?? 0) +
808 (usage.cache_read_input_tokens ?? 0) +
809 (usage.cache_creation_input_tokens ?? 0)
810 )
811}
812
813/** What a tool was handed, as text: the one string argument, or the whole object. */
814function payloadOf(input: unknown): string {
815 if (typeof input === 'string') {
816 return input
817 }
818
819 if (!input || typeof input !== 'object') {
820 return ''
821 }
822
823 const entries = Object.entries(input as Record<string, unknown>)
824
825 // One argument is the payload itself — a command, a path, a query — and
826 // wrapping it in its own key would spend a line saying `command`.
827 if (entries.length === 1) {
828 const [, only] = entries[0]
829
830 return typeof only === 'string' ? only : JSON.stringify(only)
831 }
832
833 return entries
834 .map(([key, value]) => `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`)
835 .join('\n')
836}
837
838/** What a tool answered, as text, however the transcript wrapped it. */
839function answerOf(content: unknown): string {
840 if (typeof content === 'string') {
841 return content
842 }
843
844 if (!Array.isArray(content)) {
845 return content === undefined ? '' : JSON.stringify(content)
846 }
847
848 return content
849 .map(part =>
850 part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string'
851 ? (part as { text: string }).text
852 : typeof part === 'string'
853 ? part
854 : JSON.stringify(part),
855 )
856 .join('\n')
857}
858
859/**
860 * One agent's transcript: the prompt it was given, the text it answered, and
861 * every tool call it made with what it passed and what came back.
862 *
863 * The first user message is what the workflow asked for and the last assistant
864 * text is what it answered. The calls matter for a run this module never
865 * watched happen — recovered from disk, the summary knows only a tool's name
866 * and a count, so without reading these the detail could say `1 call, last
867 * Bash` and nothing about what the call actually did.
868 */
869/**
870 * The model an agent was spawned on, from the `.meta.json` the engine writes
871 * beside its transcript.
872 *
873 * The run summary carries the same fact and more, but it is not written until
874 * the run has something to summarise — for the first minute of a run there is
875 * no file at all, which is exactly the minute a reader is watching hardest.
876 * This one exists from the moment the agent is spawned.
877 */
878export function readAgentMeta(text: string): string | undefined {
879 try {
880 const meta = JSON.parse(text) as { model?: unknown }
881
882 return typeof meta.model === 'string' && meta.model.length > 0 ? meta.model : undefined
883 } catch {
884 return undefined
885 }
886}
887
888/**
889 * A model id as a person would say it: `Haiku 4.5`, `Opus 5`, `Sonnet 3.5`.
890 *
891 * The engine writes the id it resolved — `claude-haiku-4-5-20251001`, or
892 * `claude-opus-5[1m]` with the context window on the end — and neither the
893 * date nor the prefix tells anyone anything a node has room to say.
894 *
895 * The family is capitalised because it is a name. Lower case made it read as a
896 * word the drawing had chosen — a card saying `haiku` under an agent that
897 * wrote one is a card that has to be read twice.
898 */
899export function modelName(model?: string): string {
900 if (!model) {
901 return ''
902 }
903
904 const id = model.replace(/\[[^\]]*\]$/, '').replace(/-\d{8}$/, '').replace(/^claude-/, '')
905 const family = /(haiku|sonnet|opus|fable)/.exec(id)?.[1]
906
907 if (!family) {
908 return id
909 }
910
911 const named = family[0].toUpperCase() + family.slice(1)
912
913 // The version sits either side of the family name, depending on the era the
914 // id was minted in: `haiku-4-5` and `3-5-sonnet` are the same shape of fact.
915 const version =
916 new RegExp(`${family}-(\\d+)(?:-(\\d+))?`).exec(id) ??
917 new RegExp(`(\\d+)(?:-(\\d+))?-${family}`).exec(id)
918
919 if (!version) {
920 return named
921 }
922
923 return `${named} ${version[1]}${version[2] ? `.${version[2]}` : ''}`
924}
925
926export function readAgentTranscript(text: string): {
927 prompt?: string
928 result?: string
929 tokens?: number
930 calls: ToolCall[]
931} {
932 let prompt
933 let result
934 let tokens
935 const calls: ToolCall[] = []
936 const byId = new Map<string, ToolCall>()
937
938 for (const raw of text.split('\n')) {
939 if (!raw.trim()) {
940 continue
941 }
942
943 let row
944
945 try {
946 row = JSON.parse(raw)
947 } catch {
948 continue
949 }
950
951 const content = row?.message?.content
952
953 if (row?.type === 'user' && prompt === undefined && typeof content === 'string') {
954 prompt = content
955 }
956
957 if (!Array.isArray(content)) {
958 continue
959 }
960
961 // Both ends of a call are stamped in the recording: the request that issued
962 // it carries the moment it was written, and the row carrying the answer the
963 // moment it came back. Read off those, a call that ran while nothing was
964 // watching states its own duration and cost like one that did.
965 const stamp = Date.parse(String(row?.timestamp ?? ''))
966 const at = Number.isFinite(stamp) ? stamp : 0
967 const issued = Number(row?.message?.usage?.output_tokens)
968 // The same figure the live reader keeps, read off the file instead: the
969 // newest request's context, skipping the empty usage that closes a stream.
970 // Without this an agent that finished before this pane was watching showed
971 // no count at all until its whole run ended and the engine's own summary
972 // landed — and a helper that ran for thirty seconds looked like a step that
973 // cost nothing. See `noteStep` for why it is the newest and not the sum.
974 const carried = contextOfUsage(row?.message?.usage ?? {})
975
976 if (carried > 0) {
977 tokens = carried
978 }
979
980 for (const part of content) {
981 if (part?.type === 'tool_use' && typeof part.name === 'string') {
982 const call: ToolCall = {
983 id: typeof part.id === 'string' ? part.id : `call-${calls.length + 1}`,
984 name: part.name,
985 input: heldOf(payloadOf(part.input)),
986 startedMs: at,
987 }
988
989 // Tokens belong to a request, not to a call, so every call the same
990 // request issued carries the same figure — the way the live reader
991 // stamps them.
992 if (Number.isFinite(issued) && issued > 0) {
993 call.stepTokens = issued
994 }
995
996 calls.push(call)
997 byId.set(call.id, call)
998 }
999
1000 if (part?.type === 'tool_result') {
1001 const call = byId.get(String(part.tool_use_id))
1002
1003 if (call) {
1004 call.result = heldOf(answerOf(part.content))
1005 call.isError = part.is_error === true
1006
1007 if (at > 0 && call.startedMs > 0 && at >= call.startedMs) {
1008 call.endedMs = at
1009 }
1010 }
1011 }
1012 }
1013
1014 if (row?.type === 'assistant') {
1015 const said = content
1016 .filter(c => c?.type === 'text' && typeof c.text === 'string')
1017 .map(c => c.text)
1018 .join('')
1019
1020 if (said.trim()) {
1021 result = said
1022 }
1023 }
1024 }
1025
1026 return { prompt, result, tokens, calls }
1027}
1028hooks/about.ts 155 lines1/**
2 * What the pane says about itself, in one place.
3 *
4 * The About dialog draws these rows on the canvas and `/flowpane about` prints the
5 * same ones as text, because the seat without Buttons is the seat where a
6 * reader most needs to be told what the pane is and how to drive it. Two
7 * copies of that answer drift: the dialog would gain a line the command never
8 * learned, and a reader on the hint line would be told less by the surface
9 * that can show them least.
10 */
11
12/**
13 * The plugin's own name, as the footer and the dialog title both say it.
14 *
15 * Capitalised, where the manifest's `name` is not: the manifest's is an
16 * identifier the engine installs and addresses by, and an identifier is
17 * lowercase. This is the word a reader sees.
18 */
19export const NAME = 'FlowPane'
20
21/** The line under the name, wherever the pane has room to say what it is. */
22export const TAGLINE = 'Dynamic Workflow Visualizer'
23
24/** Who to ask about it. */
25export const MAINTAINER = 'Mutlu Polatcan'
26
27/**
28 * When this version was published, written the way a European reader dates.
29 *
30 * It moves with {@link VERSION} or it says nothing: 0.3.1 and 0.5.0 both shipped
31 * claiming the 15th, because bumping the version is the visible half of a
32 * release and this line is the half nobody looks at. A date left behind is worse
33 * than no date, since the row is well formed and reads as true. The test suite
34 * holds it to the day the release before it went out, or a later one: two
35 * releases can go out on one day, and 0.6.0, 0.7.0 and 0.8.0 did.
36 */
37export const RELEASED = '22-09-2026'
38
39/**
40 * The version this build was written with, and what the pane falls back to.
41 *
42 * It used to be the only answer, kept in step with `.claude-plugin/plugin.json`
43 * by hand: the manifest is what the engine installs by and this is what the
44 * reader was told, and a pane claiming 0.3.0 while the marketplace serves 0.5.0
45 * is worse than a pane that names no version at all. Nothing inside the suite
46 * could hold the two together — the manifest is not a file a test can open, so
47 * the constant was checked against it by `dev/checkmeta.ts`, which is a check
48 * somebody has to remember.
49 *
50 * So the session reads the manifest instead and the pane says what it found;
51 * see {@link noteShipped}. A test can hold every seat to that, by answering the
52 * read rather than opening the file. This constant is what is left when the read
53 * failed, which is the only case where the two can still disagree, and
54 * `dev/checkmeta.ts` keeps it honest for that case.
55 */
56export const VERSION = '0.10.0'
57
58/** What the manifest said, once a session has read it. */
59let shipped: string | undefined
60
61/**
62 * The version the pane names, which is the manifest's where there is one.
63 *
64 * Read rather than remembered: what the engine installed by is the fact a
65 * reader checking a build against the marketplace is after, and the constant
66 * above is one edit away from being last release's answer at any time.
67 */
68export function shippedVersion(): string {
69 return shipped ?? VERSION
70}
71
72/**
73 * Hand the session's own `.claude-plugin/plugin.json` to the pane.
74 *
75 * Takes the file as text rather than a version already picked out of it, so
76 * the reading is here with the fallback it belongs to rather than in the hook
77 * that did the loading: anything that is not JSON naming a version — a file
78 * that was not there, a read that was refused, a manifest from a build that
79 * states none — leaves the pane on {@link VERSION}. Called with nothing, it
80 * forgets what it was told, which is what a session ending amounts to.
81 */
82export function noteShipped(manifest: string | undefined): void {
83 shipped = undefined
84
85 if (manifest === undefined) {
86 return
87 }
88
89 try {
90 const version: unknown = (JSON.parse(manifest) as { version?: unknown }).version
91
92 if (typeof version === 'string' && version.trim() !== '') {
93 shipped = version.trim()
94 }
95 } catch {
96 // A manifest that does not parse is a manifest the pane has not read, and
97 // the fallback already covers that. Nothing is logged: this runs at session
98 // start, where a line about the plugin's own packaging is a line in front of
99 // a reader who asked for none.
100 }
101}
102
103/** One line of the About dialog: a pair, a sentence, or the air between them. */
104export type AboutRow =
105 | { kind: 'field'; left: string; right: string }
106 | { kind: 'text'; text: string }
107 | { kind: 'gap' }
108
109/**
110 * What the pane is, what presses it, and where it reads from.
111 *
112 * The order is the order a reader asks in. What is this — one sentence, since
113 * a reader who opened a dialog titled with the name already knows roughly. Then
114 * what can be pressed, because that is the question that brought them. Then the
115 * commands, for the seat that draws no buttons. Then where the drawing comes
116 * from, which is the question every live view eventually raises: a graph with
117 * nothing in it is either a quiet session or a path this plugin cannot read,
118 * and the reader cannot tell which without being told where it looks.
119 */
120export function aboutRows(): AboutRow[] {
121 return [
122 { kind: 'text', text: 'A live picture of the agents a workflow runs: what each one is doing, what it has spent, and what it answered.' },
123 { kind: 'gap' },
124 { kind: 'field', left: 'A node', right: 'opens its detail' },
125 { kind: 'field', left: 'Tab, Enter', right: 'moves, presses' },
126 { kind: 'field', left: '✕', right: 'closes what is open' },
127 { kind: 'gap' },
128 { kind: 'field', left: '/flowpane help', right: 'every command' },
129 { kind: 'field', left: '/flowpane runs', right: 'this session’s runs' },
130 { kind: 'gap' },
131 { kind: 'text', text: 'Reads the workflow journals under ~/.claude/projects.' },
132 { kind: 'text', text: 'Nothing leaves this machine.' },
133 { kind: 'gap' },
134 { kind: 'field', left: 'Maintainer', right: MAINTAINER },
135 { kind: 'field', left: 'Released', right: RELEASED },
136 ]
137}
138
139/** The same rows as text, for `/flowpane about` and any seat that draws no canvas. */
140export function aboutText(): string {
141 const rows = aboutRows()
142 const pad = rows.reduce((w, r) => (r.kind === 'field' ? Math.max(w, r.left.length) : w), 0)
143
144 return [
145 `${NAME} ${shippedVersion()} — ${TAGLINE}`,
146 ...rows.map(row =>
147 row.kind === 'gap'
148 ? ''
149 : row.kind === 'text'
150 ? row.text
151 : `${row.left.padEnd(pad)} ${row.right}`,
152 ),
153 ].join('\n')
154}
155hooks/layout.ts 872 lines1/**
2 * Lays the run out as a layered graph, along whichever axis the pane's shape
3 * (or the person's setting) calls for.
4 *
5 * `flow` puts one phase per column and reads left to right, which is how a
6 * pipeline is usually drawn and what a wide seat wants. `stack` puts one phase
7 * per row and reads top to bottom, which is what a tall narrow seat wants — a
8 * pane docked beside the transcript is often forty columns wide, and a
9 * five-phase run drawn across it would be five columns of eight characters.
10 *
11 * The layout answers to the body it is given either way: as the widest lane
12 * outgrows the room, nodes drop first their stat line and then their frames, so
13 * a forty-agent run still reads as a graph rather than scrolling out of view.
14 */
15
16import type { AgentRow, RunState } from './journal'
17import { orderedLanes, type Fold, type FoldedLane, type FoldPass } from './shape'
18
19/**
20 * Which way the phases run. `auto` picks from the pane's proportions; `time`
21 * is not a direction for the graph but the timeline in its place, one row per
22 * agent along a clock, which `paint` draws without this layout; `list` is the
23 * flat list, which runs down the pane like `vertical` and draws a row per agent
24 * in place of the bands.
25 *
26 * The list used to be reached only by making the pane small enough that a band
27 * could not be drawn as cards. It is a reader's choice now, for the reason
28 * every other size-driven device was given up: the pane's size decides what a
29 * reader can see at once, not which drawing they are looking at.
30 */
31export type Orientation = 'auto' | 'horizontal' | 'vertical' | 'timeline' | 'list'
32
33export type NodeBox = {
34 /**
35 * The agent the box draws: where the work was done more than once, the last
36 * pass of it. Its figures are that pass's, not the sum — a reader comparing
37 * two cards is comparing two agents, and a row that silently totalled three
38 * of them would make the comparison a lie.
39 */
40 agent: AgentRow
41 /**
42 * Every time this piece of work ran, where it ran more than once. Drawn as a
43 * mark per pass beside the name; absent where the work ran once.
44 */
45 passes?: FoldPass[]
46 /**
47 * The row's own name, where it is not the agent's: a box standing for a whole
48 * nested run is named after that run, not after whichever of its agents is
49 * carrying its figures.
50 */
51 name?: string
52 /**
53 * What the box stands for, where it stands for a whole nested run rather than
54 * one agent: how many agents are folded inside it, and the phase a press on
55 * the count unfolds.
56 *
57 * `alone` marks the boxes that carry that press themselves. A shut nested run
58 * is a phase, so its band has a rule with a caption on it and the caption
59 * carries the handle; a wave folded back inside an opened run is a row in the
60 * middle of a band and has no rule of its own, so the row is the only place
61 * its own way back out can go.
62 */
63 inside?: { agents: number; phase: string; alone?: boolean }
64 x: number
65 y: number
66 w: number
67 h: number
68}
69
70export type LaneBox = {
71 phase: string
72 index: number
73 x: number
74 y: number
75 w: number
76 h: number
77 /** Which row a stacked band's name rule takes, over the band it names. */
78 captionRow?: number
79 /**
80 * Where the boundary before this lane is drawn, laid out across: the cell
81 * halfway between this lane's cards and the ones before them. -1 for the
82 * first lane, which opens at the edge of the pane, and for a stacked band,
83 * whose boundary is its own name rule.
84 */
85 edgeAt: number
86 /** Where the barrier's spine stands, in the gutter before this lane. */
87 busAt: number
88 nodes: NodeBox[]
89 /**
90 * True for a nested run a reader has opened: its rows are a run of their own
91 * and are drawn one step in, behind a gutter of their own, with the row at
92 * the foot of the lane given to what the whole of that run came to.
93 */
94 inset?: boolean
95 /** Where a declared-but-unentered phase draws its outline. */
96 ghost?: { x: number; y: number; w: number; h: number }
97}
98
99export type Layout = {
100 lanes: LaneBox[]
101 density: 'card' | 'row'
102 headerRows: number
103 /** Which axis this layout used, after `auto` resolved. */
104 orientation: 'horizontal' | 'vertical'
105 /** Phases the pane had no room to draw, named beside or under it instead. */
106 pending: string[]
107 /** The strip at the end of the drawing those phases are named in, if any. */
108 ahead?: { x: number; w: number }
109 /**
110 * Rows the drawing gave up at its foot to name those phases instead, where
111 * there was no room for a strip beside it.
112 */
113 aheadRows?: number
114 /**
115 * True where the drawing is the flat list: sections of one-line rows, in the
116 * run's own order, with no edges between them. A pane too narrow for a node
117 * of any kind falls back to it, and the painter draws no wires, barriers or
118 * rails there — the order down the page is the only thing left to say them
119 * with.
120 */
121 list?: boolean
122}
123
124/** A phase and what ran in it, numbered by where it falls in the run. */
125type Lane = FoldedLane & { index: number }
126
127/** Cells a nested run's own rows stand in from the run that called them. */
128export const INSET_W = 2
129
130/**
131 * What a fold contributes to the box that draws it, wherever the box is placed.
132 *
133 * The passes ride along only where there is more than one of them: a node whose
134 * work ran once has nothing to say about passes, and an array of one would have
135 * every painter checking its length before drawing nothing.
136 */
137function boxOf(fold: Fold): Pick<NodeBox, 'agent' | 'name' | 'passes' | 'inside'> {
138 return {
139 agent: fold.agent,
140 ...(fold.label === fold.agent.label ? {} : { name: fold.label }),
141 ...(fold.passes.length > 1 ? { passes: fold.passes } : {}),
142 ...(fold.inside ? { inside: fold.inside } : {}),
143 }
144}
145
146/** A card: its top border with the name in it, its figures, its bottom border. */
147export const CARD_H = 3
148
149/**
150 * The cells the pane's own title needs, and the pane it is worth spending them
151 * on.
152 *
153 * `FlowPane - Dynamic Workflow Visualizer` is thirty-eight, and a title with
154 * nothing either side of it reads as a label stuck to the top edge rather than
155 * as a heading, so the row is only taken where there are a few cells to spare.
156 * The height bar is the harder one: the title says the same thing on every
157 * frame, and a row that never changes is the first row a short pane should give
158 * up to one that does.
159 */
160export const TITLE_MIN_COLUMNS = 46
161export const TITLE_MIN_ROWS = 16
162
163/** Whether this pane carries the title row at all. */
164export function titledPane(rows: number, columns: number): boolean {
165 return rows >= TITLE_MIN_ROWS && columns >= TITLE_MIN_COLUMNS
166}
167
168/**
169 * How deep the bar at the top of the pane is.
170 *
171 * Three rows: a rule, the run's line, a rule. The line sits between two edges
172 * rather than against the pane's own, and what comes after it starts under a
173 * border rather than after a gap. A pane too short for that gives up the bar's
174 * opening rule first — the closing one is the border, and the border stays.
175 *
176 * A fourth row above all of it carries the pane's own name, where the pane is
177 * big enough to spare it — see `titledPane`.
178 */
179export function barRowsOf(rows: number, columns: number): number {
180 return (rows >= 10 ? 3 : 2) + (titledPane(rows, columns) ? 1 : 0)
181}
182
183/**
184 * The gutter between two phases, laid out across.
185 *
186 * Seven columns rather than five, because three things share it and each needs
187 * a clear cell either side: the boundary between the two phases, the barrier's
188 * spine, and the wires running between them. At five the boundary stood in the
189 * spine's own column, so a reader could not tell the edge of a phase from the
190 * line every agent of it feeds.
191 */
192const GUTTER = 7
193const MIN_COL = 12
194/**
195 * Columns take the width the pane gives them; the cap is the point past which
196 * a wider node stops carrying more label and starts carrying air. A card holds
197 * a label, a clock with a token count, and a model name — the longest of those
198 * is the label, and past thirty-two columns the frame grows and the words do
199 * not.
200 */
201const MAX_COL = 32
202/**
203 * The width every node is drawn at, in every layout, at every size of pane.
204 *
205 * A node used to be measured against the room there was for it: across, the
206 * phases divided the pane between them and a column took a share; down, a band
207 * wrapped so that its nodes could keep a width they were legible at; and past a
208 * certain height every node in the drawing flattened from a card to a row. Three
209 * devices, all of them answering the same question with the node's own size —
210 * so the same run on the same machine was a different picture in a narrow
211 * window than in a wide one, and a reader who dragged the pane's edge watched
212 * the drawing redraw itself rather than move.
213 *
214 * A node is the same size whatever the pane is now. What the pane cannot hold
215 * it scrolls to: the drawing runs past the edge and the rails say which way.
216 * `MAX_COL` is the width to fix it at because that is the width the columns
217 * already grew to wherever there was room — the point past which a wider node
218 * carries no more label, only air.
219 */
220const NODE_W = MAX_COL
221
222/**
223 * Picks the axis from the pane's proportions: a seat wide enough to give every
224 * phase a legible column reads left to right, anything squarer or taller reads
225 * downward.
226 */
227export function resolveOrientation(
228 setting: Orientation,
229 columns: number,
230 rows: number,
231 phases: number,
232): 'horizontal' | 'vertical' {
233 if (setting === 'horizontal' || setting === 'vertical') {
234 return setting
235 }
236
237 // The list runs down the pane, so it reads on the vertical axis and takes the
238 // header that axis takes. What makes it a list is which layout is called, not
239 // which way it is read.
240 if (setting === 'list') {
241 return 'vertical'
242 }
243
244 // One phase has no direction to read in; keep the familiar one.
245 if (phases <= 1) {
246 return 'horizontal'
247 }
248
249 const fits = phases * (MIN_COL + GUTTER)
250
251 return columns >= fits && columns >= rows * 2 ? 'horizontal' : 'vertical'
252}
253
254export function layout(
255 run: RunState,
256 columns: number,
257 rows: number,
258 setting: Orientation = 'auto',
259 reservedRows = 0,
260 /** Nested runs the reader has unfolded; the rest draw as one row each. */
261 opened?: Set<string>,
262): Layout {
263 const lanes = orderedLanes(run, opened)
264 const barRows = barRowsOf(rows, columns)
265 // What follows the bar is the phases, and where they are written depends on
266 // which way they run: across, their names take a row under the bar and their
267 // progress the row after it; downward, each band carries its own name over
268 // itself, so the header ends with the row the first of those takes.
269 const probe = Math.max(1, rows - barRows - 3 - reservedRows)
270 const orientation = resolveOrientation(setting, columns, probe, lanes.length)
271 const headerRows = barRows + (orientation === 'horizontal' ? 2 : 1)
272
273 if (lanes.length === 0) {
274 return { lanes: [], density: 'card', headerRows, orientation: 'horizontal', pending: [] }
275 }
276
277 const body = Math.max(1, rows - headerRows - 1 - reservedRows)
278 const widest = Math.max(1, ...lanes.map(l => l.folds.length))
279 const numbered = lanes.map((lane, index) => ({ ...lane, index }))
280 // A phase left out is named rather than dropped, and the naming costs the
281 // drawing something: across, a strip at the end of the phases, where the run
282 // would have reached it; down, a row under the drawing. What is left over is
283 // what the sections are laid out in.
284 //
285 // This used to be measured twice — what fits, what the naming leaves, what
286 // fits in that — because how many phases the pane could hold depended on how
287 // wide the pane was. It does not any more: a phase is left out only for
288 // being ahead of the run, which no amount of dragging the pane's edge
289 // changes.
290 const shown = fitting(numbered)
291 const pending = numbered.filter(lane => !shown.includes(lane)).map(lane => lane.phase)
292 const strip = orientation === 'horizontal' ? aheadWidth(pending, columns) : 0
293 const depth = strip > 0 ? 0 : aheadDepth(pending.length, body)
294 const room = Math.max(1, body - depth)
295
296 const view =
297 setting === 'list'
298 ? listLayout(shown, columns, room, headerRows)
299 : orientation === 'horizontal'
300 ? flowLayout(shown, columns, room, headerRows, widest, strip)
301 : stackLayout(shown, columns, room, headerRows)
302
303 // The strip names what the run has not reached, so it belongs after what the
304 // run has. It stands at the pane's own right edge, where the drawing ends
305 // with the pane; where the drawing runs past the pane it goes after the last
306 // phase instead, because pinned to the edge it would sit in the middle of the
307 // drawing, between two phases that did run.
308 const drawnTo = Math.max(
309 0,
310 ...view.lanes.flatMap(lane => [lane.x + lane.w, ...lane.nodes.map(n => n.x + n.w)]),
311 )
312 const stripAt = drawnTo > columns - strip ? drawnTo + GUTTER : columns - strip
313
314 return {
315 ...view,
316 pending,
317 ...(strip > 0 ? { ahead: { x: stripAt, w: strip } } : {}),
318 ...(depth > 0 ? { aheadRows: depth } : {}),
319 }
320}
321
322/**
323 * The strip at the end of the drawing the phases still to come are named in.
324 *
325 * A phase the run has not reached has nothing to draw, so it is named instead
326 * — and named where the run will reach it, after the phases that are drawn,
327 * rather than in a rule under them, where it read as a note about the pane.
328 *
329 * It costs width, so it is taken only where the drawing can spare it: enough
330 * for the longest name, never more than a fifth of the pane, and never at the
331 * price of the phases that have work in them. A pane too narrow for that names
332 * them in the row under the drawing, which costs a line instead.
333 */
334const AHEAD_NAME = 14
335
336/**
337 * The rows the drawing gives up at its foot to name them instead.
338 *
339 * Down the pane, along a timeline, and across a pane too narrow for a strip,
340 * the naming costs height rather than width — and there it is drawn as the band
341 * it stands for: the caption set into its own rule, dashed rather than solid
342 * because nothing has run in it, and the names on the row it opens.
343 *
344 * A body with nothing to spare gives up the caption: at one row the names take
345 * its place inside the rule, which is the same line a band's own name is set
346 * into, and says the same thing in the room a short pane has.
347 */
348/** The caption's own rule, plus a row for the first phase under it. */
349const AHEAD_ROWS = 2
350
351export function aheadDepth(pending: number, body: number): number {
352 if (pending === 0) {
353 return 0
354 }
355
356 // Never a third of the body: a drawing that spent two of its five rows on
357 // what has not happened yet is a drawing about the wrong thing.
358 if (body < AHEAD_ROWS * 3) {
359 return 1
360 }
361
362 // A row each, within that third. The band used to be two rows whatever it
363 // held, so every phase in it was squeezed onto one line as a run of names
364 // with arrows between them — a device the pane uses nowhere else, for the
365 // only part of the drawing that is not a node. A row each is the same row
366 // the list layout gives an agent, which is a shape the reader already knows.
367 return Math.max(AHEAD_ROWS, Math.min(1 + pending, Math.floor(body / 3)))
368}
369
370function aheadWidth(phases: string[], columns: number): number {
371 if (phases.length === 0) {
372 return 0
373 }
374
375 const longest = Math.min(AHEAD_NAME, Math.max(...phases.map(phase => phase.length)))
376 // The boundary and a clear cell after it, then the node: its state rule, its
377 // mark, the cell between the mark and the name, the name, and the cell that
378 // closes it off. Every other one-row node on the pane is built the same way,
379 // and the strip holds nodes now rather than a column of bare names.
380 const want = longest + 7
381
382 return want <= Math.floor(columns / 5) && columns - want >= MIN_COL * 2 + GUTTER ? want : 0
383}
384
385/**
386 * The phases the drawing holds, which is every phase the run has reached.
387 *
388 * The pane's size used to decide this as well: a section wanted a column wide
389 * enough for a label or a band deep enough for its card, and where there were
390 * more phases than the pane could give that to, the empty ones gave their
391 * sections up from the end of the run backward. It made the number of phases a
392 * reader could see a function of how wide their window was, and a phase that
393 * vanished when the pane narrowed looked like a phase the run had lost.
394 *
395 * Size is answered by scrolling now, so what is left here is the one reason to
396 * leave a phase out that has nothing to do with the pane: the run has not
397 * reached it. A phase with no agents in it is a rule with blank rows under it,
398 * which reads as a band that failed to draw, and the phases at the end of a run
399 * are all like that at once. Named together in the strip beside the drawing or
400 * the band at its foot, they read as what they are — the rest of the run, in
401 * order — and each reappears the moment an agent starts in it.
402 */
403function fitting(lanes: Lane[]): Lane[] {
404 const shown = [...lanes]
405
406 // The phases the run has not reached give up their sections however much room
407 // the pane has, and go together rather than one at a time. A section is a rule
408 // and the nodes under it; a phase with no agents has no nodes, so its section
409 // is a rule with blank rows under it — which reads as a band that failed to
410 // draw. Named together in the strip beside the drawing or the band at its
411 // foot, they read as what they are: the rest of the run, in order.
412 while (shown.length > 1 && shown[shown.length - 1].folds.length === 0) {
413 shown.pop()
414 }
415
416 return shown
417}
418
419/**
420 * Where a band with nothing in it puts the one node it draws.
421 *
422 * Its own width, not the lane width. A band's cards are cut to whatever divides
423 * the widest band between its agents — five across a narrow pane leaves eleven
424 * cells a card — and a band with one node in it was cut to the same eleven, so
425 * a phase that never ran said `⊘ Veri…` in a band with sixty columns spare
426 * either side of it.
427 *
428 * It is a row rather than a card, so the ceiling is the one the band at the foot
429 * holds its rows to — a name, the line the phase wrote about itself and a model,
430 * in seven tenths of the pane — rather than the one a card is held to. A card's
431 * ceiling on a row left the detail cut to `only what …` with half the pane
432 * empty beside it.
433 */
434function plannedBox(columns: number, colW: number, y: number): { x: number; y: number; w: number; h: number } {
435 const room = Math.max(8, columns - 2)
436 const w = Math.max(colW, Math.min(room, Math.max(MAX_COL, Math.floor(columns * 0.7))))
437
438 return { x: Math.max(1, Math.floor((columns - w) / 2)), y, w, h: 1 }
439}
440
441/** Phases as columns, agents stacked inside them, the run reading rightward. */
442function flowLayout(
443 lanes: Lane[],
444 columns: number,
445 full: number,
446 headerRows: number,
447 tallest: number,
448 /** Cells kept at the end for the phases the run has not reached. */
449 ahead = 0,
450): Layout {
451 const body = full
452
453 const width = Math.max(1, columns - ahead)
454 const gutters = GUTTER * Math.max(0, lanes.length - 1)
455 /**
456 * Every column is the same width, and it is the width a node is drawn at.
457 *
458 * The pane used to divide itself between the phases and give each one a
459 * share, falling back to a floor once the share stopped carrying a name and
460 * letting the drawing overrun from there. Two devices, and the reader met
461 * both of them by dragging one edge: the cards narrowed, the labels turned to
462 * ellipses, and at some width the whole drawing changed shape. Past that
463 * width it scrolled instead — which is what it does at every width now.
464 */
465 const colW = NODE_W
466 /**
467 * Each phase owns an equal slice of the width and stands its cards in the
468 * middle of it.
469 *
470 * The cards used to be packed at a fixed gutter and the block centred, which
471 * left the drawing's spare width pooled at its two ends — so the first
472 * phase's cards sat well right of the middle of everything drawn around
473 * them, and its name with them, while the last phase's sat well left. A
474 * section that divides the pane puts every column of cards, and every name
475 * over one, in the middle of the section it belongs to, and the boundary
476 * between two phases halfway between their cards.
477 */
478 const slice = width / lanes.length
479 // Divided only while a slice can hold a whole node and the gutter its wires
480 // run down. Past that the slices are the wrong device — they would set a
481 // thirty-two-cell card in a thirteen-cell section — so the columns stand at
482 // the gutter they need, the drawing runs past the pane's edge, and the body
483 // scrolls to the rest.
484 const tiled = Math.floor(slice) - GUTTER >= colW
485
486 const density: Layout['density'] = 'card'
487 const nodeH = CARD_H
488 // Room the tallest lane leaves over goes between its nodes rather than into
489 // margins at the two ends: a lane of three boxes in thirty rows read as a
490 // clump with half the pane blank around it. Capped, so a lane of two does not
491 // put its nodes at opposite corners.
492 const slack = tallest > 1 ? Math.floor((body - tallest * nodeH) / (tallest - 1)) : 0
493 const nodeGap = Math.max(1, Math.min(3, slack))
494
495 const boxes: LaneBox[] = []
496 const graphW = colW * lanes.length + gutters
497 const packedAt = Math.max(0, Math.min(1, width - graphW), Math.floor((width - graphW) / 2))
498 const sliceAt = (i: number) => Math.round(i * slice)
499 const placeOf = (i: number) =>
500 tiled
501 ? sliceAt(i) + Math.max(0, Math.floor((sliceAt(i + 1) - sliceAt(i) - colW) / 2))
502 : packedAt + i * (colW + GUTTER)
503
504 // The tallest lane sets the block every other lane centres inside, so a
505 // fan-out still reads as a fan, and the block centres in the body so every
506 // column reads as a section of the same height with its nodes in the middle
507 // of it. The leftover splits above and below rather than pooling under the
508 // graph, which read as a drawing that had run out rather than one placed.
509 const blockH = tallest * nodeH + Math.max(0, tallest - 1) * nodeGap
510 const blockTop = headerRows + Math.max(0, Math.floor((body - blockH) / 2))
511
512 lanes.forEach((lane, place) => {
513 const height = lane.folds.length * nodeH + Math.max(0, lane.folds.length - 1) * nodeGap
514 const top = blockTop + Math.max(0, Math.floor((blockH - height) / 2))
515 const x = placeOf(place)
516 const before = boxes[boxes.length - 1]
517 const gapAt = before === undefined ? -1 : before.x + before.w
518 // Halfway between the cards either side of it, so each phase's section is
519 // as wide on one side of its cards as on the other.
520 const edgeAt = gapAt < 0 ? -1 : gapAt + Math.max(0, Math.floor((x - gapAt - 1) / 2))
521
522 boxes.push({
523 phase: lane.phase,
524 index: lane.index,
525 x,
526 y: headerRows,
527 w: colW,
528 h: body,
529 edgeAt,
530 // The spine stands between the boundary and the cards it feeds, clear of
531 // both: a line in the boundary's own column reads as the boundary, and
532 // one against the cards reads as their edge.
533 busAt:
534 place === 0
535 ? x - 1
536 : Math.min(x - 2, edgeAt + Math.max(1, Math.ceil((x - 1 - edgeAt) / 2))),
537 nodes: lane.folds.map((fold, i) => ({
538 ...boxOf(fold),
539 x,
540 y: top + i * (nodeH + nodeGap),
541 w: colW,
542 h: nodeH,
543 })),
544 // One row, wherever the nodes beside it are cards: a phase that never ran
545 // has a name, a line about itself and a model, and a card drawn round
546 // three facts to sit level with cards that carry four is a frame around
547 // blank cells. The same row the strip and the foot band name it in.
548 ghost:
549 lane.folds.length === 0
550 ? { x, y: blockTop + Math.max(0, Math.floor((blockH - 1) / 2)), w: colW, h: 1 }
551 : undefined,
552 })
553 })
554
555 return { lanes: boxes, density, headerRows, orientation: 'horizontal', pending: [] }
556}
557
558/**
559 * Phases as rows, agents side by side inside them, the run reading downward.
560 *
561 * Each band opens with a rule across the pane carrying its own name, which is
562 * both the boundary between two phases and the label of the one below it. The
563 * captions took a rail down the left before, and a rail is fourteen columns
564 * spent on six words: on a pane docked beside a transcript those columns are
565 * the difference between boxes and a list.
566 */
567function stackLayout(lanes: Lane[], columns: number, body: number, headerRows: number): Layout {
568 // One column between neighbours reads them apart on its own — each node opens
569 // with a rule in its own state's colour, a harder edge than any amount of
570 // blank. Two is better where the width allows it: an edge that has to pass a
571 // band needs a clear column to run down, and with a single one there is none,
572 // so it falls out to the margin and draws three sides of a rectangle round
573 // the band instead.
574 // Two, always. It used to fall back to one where the wider gap left the band
575 // too narrow to name its nodes, which was the gap paying for a width the
576 // nodes no longer give up: a node is the same size at every pane size now, so
577 // the only thing a narrower gap buys is a tighter band, and a band that does
578 // not fit is scrolled to rather than squeezed.
579 const nodeGap = 2
580
581 // Every phase gets a band of the same depth, and a band carries its node, the
582 // rule that opens the band under it, and the two rows the wires arriving need:
583 // one to gather on and one for the arrowheads. A run whose first phase took
584 // only the rows its cards needed crushed those cards against the rule opening
585 // the phase below, and every wire leaving them crossed that rule in the row it
586 // left in.
587 const bands = Math.max(1, lanes.length)
588 /**
589 * The depth a band is actually given: the floor, plus the two rows under the
590 * node that make it centred — the row a card's exit point stands on and one
591 * of air below that — plus one more above it, which is the row a card fed
592 * from further back writes that phase's name on.
593 *
594 * At the floor a band is two rows of wire, the node, and then the next band's
595 * rule — so the node sits hard against that rule with all of the band's air
596 * above it, and a reader looking down the pane sees every card lying on the
597 * line under it rather than standing in its own band. The exit point had
598 * nowhere to go either: it landed on the rule, where the caption clears its
599 * own cells, so the one mark saying which card a wire left was rubbed out
600 * whenever it fell under the name.
601 *
602 * The third row above the node is what lets the written source stand centred
603 * on its card with the mark and the word, the way the same label stands
604 * across the pane. With two, the only row above a card was the one the
605 * arrowheads land on, and an arrowhead stands in the middle of it: the label
606 * had half a card's width, gave up its word for its name, and on a narrow
607 * pane said nothing at all — the same run, the same card, said in one layout
608 * and not the other. The wire gives way for the label on that row and runs
609 * whole above and below it, which is what a band's own caption does to the
610 * wire it covers.
611 */
612 const airyAs = (nodeH: number) => nodeH + 6
613 /**
614 * The bands stand cards, at every size of pane.
615 *
616 * This was a plan the layout drew up twice, once per density, because what a
617 * node spends on itself before a letter of its name is drawn depends on which
618 * one it is — a card pays for its frame, a row does not — and the body's depth
619 * chose between them: a run of seventeen phases in a body of thirty flattened
620 * every node in the drawing to a row. The height has stopped bargaining. A
621 * node is a card whatever the pane is, the bands keep the depth they need, the
622 * drawing runs past the foot of the pane, and the body scrolls to the rest.
623 */
624 const density: Layout['density'] = 'card'
625 const nodeH = CARD_H
626 const colW = NODE_W
627
628 // A body with no room for one card is the one pane a card cannot be drawn on
629 // at all, and there the list is the honest drawing: a row per agent, in the
630 // run's own order. Every deeper body keeps its cards and scrolls to what is
631 // past the foot.
632 //
633 // This used to catch far more than that — a band too narrow to stand two
634 // legible nodes side by side fell back to the list as well — because the
635 // nodes were sized to the pane and a narrow pane made them illegible. They
636 // are not sized to it any more, so a narrow pane is a drawing that runs off
637 // the edge, not a drawing that has to become something else.
638 if (body < CARD_H) {
639 return listLayout(lanes, columns, body, headerRows)
640 }
641 /**
642 * What a band is given down the pane: its own node with the air a band keeps
643 * around it, and never less than an even share of the body.
644 *
645 * Equal shares are what give the stack its rhythm — every phase the same
646 * depth, every card in the middle of its own band. A band is one node deep
647 * whatever it holds, so every band asks the same of the body.
648 */
649 const depthOf = (_lane: Lane) => Math.max(airyAs(nodeH), Math.floor(body / bands))
650 /** Where each band opens, which is where the one before it ended. */
651 const tops: number[] = []
652 let at = headerRows
653
654 for (const lane of lanes) {
655 tops.push(at)
656 at += depthOf(lane)
657 }
658
659 const boxes: LaneBox[] = []
660
661 lanes.forEach((lane, place) => {
662 const top = tops[place] as number
663 const bandH = depthOf(lane)
664 const blockH = nodeH
665 // A band's last row carries the next band's name rule, so the card centres
666 // in what is left over. The last band has no rule under it and centres in
667 // the whole of its own.
668 const room = bandH - (place === lanes.length - 1 ? 0 : 1)
669 const slack = Math.max(0, room - blockH)
670 // Centred in what the band has, and never nearer the rule above than three
671 // rows: the first carries the bundle every wire into this band turns on, the
672 // last the arrowheads, and the one between them is where a card fed from
673 // further back writes the name of the phase that fed it. With one row for
674 // the bundle and the heads, the bundle is drawn over the very heads it
675 // arrives with; with none, both land in the rule itself and the band's own
676 // name is drawn through with wire.
677 //
678 // A band is deep enough for three rows above its node and two below, so
679 // centring and that floor no longer pull against each other. They used to:
680 // at the old depth the halves came to one row, the floor won, and every node
681 // in the drawing sat as low in its band as it could go. Where a band has
682 // more depth than the floor asks for, the halves win again and the node
683 // stands in the middle of it.
684 const lift = slack === 0 ? 0 : Math.max(Math.min(slack, 3), Math.floor(slack / 2))
685 const y = top + lift
686 const shown = Math.max(1, lane.folds.length)
687 const width = shown * colW + Math.max(0, shown - 1) * nodeGap
688 // Centred, which is what makes a fan read as a fan — and what a band of one
689 // needs, since a lone card pinned to the left edge of a pane it has all of
690 // is a drawing that ran out rather than one placed. A band wider than the
691 // pane has nothing to centre in, so it opens at the left like the run does
692 // and runs off the right edge; the body scrolls to the rest of it.
693 const left = Math.max(1, Math.floor((columns - width) / 2))
694
695 boxes.push({
696 phase: lane.phase,
697 index: lane.index,
698 x: 0,
699 y,
700 w: columns,
701 h: blockH,
702 // The band's name rule is the line between it and the band before it: the
703 // last row of that band, and for the first the row the header keeps back.
704 edgeAt: -1,
705 captionRow: Math.max(0, top - 1),
706 // The spine takes the row under that rule, so the arrowheads always have
707 // a row of their own beneath it. Sharing the arrowheads' row, a bundle
708 // drawn there rubbed out the very heads it was arriving with — and in a
709 // gap that narrow every crossing drawn as its own line turned on one row
710 // and ran over the others.
711 busAt: lift > 1 ? top : Math.max(0, top - 1),
712 nodes: lane.folds.map((fold, i) => ({
713 ...boxOf(fold),
714 x: left + i * (colW + nodeGap),
715 y,
716 w: colW,
717 h: nodeH,
718 })),
719 // On the row a card in this band would have its middle on, not the row a
720 // card would start at: one row set against the top of a three-row block
721 // sits high in a band laid out to centre what it holds.
722 ghost:
723 lane.folds.length === 0
724 ? plannedBox(columns, colW, y + Math.floor((nodeH - 1) / 2))
725 : undefined,
726 })
727 })
728
729 return { lanes: boxes, density, headerRows, orientation: 'vertical', pending: [] }
730}
731
732/**
733 * Every phase as a section of one-line rows: the shape a pane too narrow for
734 * boxes can still carry. Order down the page is the run's order, so the list
735 * needs no edges drawn between its rows.
736 */
737function listLayout(
738 lanes: Lane[],
739 columns: number,
740 body: number,
741 headerRows: number,
742): Layout {
743 const left = 2
744 const width = Math.max(8, columns - left - 1)
745 // A section per phase, one row per agent, one blank row between — and the
746 // rows left over spread through those blanks rather than left in a block at
747 // the foot of the pane.
748 // A nested run a reader has opened is a run of its own, drawn one step in
749 // from the run that called it: its rows take a gutter of their own, and one
750 // row more at their foot for what the whole of it came to. Shut, it is a
751 // single row of this run's and takes neither.
752 const insetOf = (lane: Lane) => lane.nested && lane.shut !== true && lane.folds.length > 0
753 const depthOf = (lane: Lane) => Math.max(1, lane.folds.length) + (insetOf(lane) ? 1 : 0)
754 const rows = lanes.reduce((sum, lane) => sum + depthOf(lane), 0)
755 const gaps = Math.max(0, lanes.length - 1)
756 const spare = body - rows - gaps
757 const sectionGap = 1 + (gaps > 0 ? Math.max(0, Math.min(2, Math.floor(spare / gaps))) : 0)
758 const boxes: LaneBox[] = []
759 let y = headerRows
760
761 lanes.forEach(lane => {
762 const inset = insetOf(lane)
763 const indent = inset ? INSET_W : 0
764 const nodes = lane.folds.map((fold, i) => ({
765 ...boxOf(fold),
766 x: left + indent,
767 y: y + i,
768 w: Math.max(4, width - indent),
769 h: 1,
770 }))
771
772 boxes.push({
773 phase: lane.phase,
774 index: lane.index,
775 x: 0,
776 y,
777 w: columns,
778 h: depthOf(lane),
779 edgeAt: -1,
780 captionRow: Math.max(0, y - 1),
781 busAt: y - 1,
782 nodes,
783 ...(inset ? { inset: true } : {}),
784 ghost:
785 lane.folds.length === 0 ? { x: left, y, w: width, h: 1 } : undefined,
786 })
787
788 y += depthOf(lane) + sectionGap
789 })
790
791 return { lanes: boxes, density: 'row', headerRows, orientation: 'vertical', pending: [], list: true }
792}
793
794/** Where an edge leaves a node, on the axis the layout runs along. */
795export function exitOf(node: NodeBox, orientation: 'horizontal' | 'vertical'): { x: number; y: number } {
796 return orientation === 'horizontal'
797 ? { x: node.x + node.w, y: portRow(node) }
798 : { x: node.x + Math.floor(node.w / 2), y: node.y + node.h }
799}
800
801/**
802 * The row an edge leaves and arrives on, laid out across.
803 *
804 * A two-row card has no middle row, and `h / 2` rounds to the second — the one
805 * with the figures on it. The edge then reads as a line drawn to a token count
806 * rather than to an agent, so it goes to the row with the name on it.
807 */
808function portRow(node: NodeBox): number {
809 return node.y + (node.h > 2 ? Math.floor(node.h / 2) : 0)
810}
811
812/** Where an edge arrives. */
813export function entryOf(node: NodeBox, orientation: 'horizontal' | 'vertical'): { x: number; y: number } {
814 return orientation === 'horizontal'
815 ? { x: node.x - 1, y: portRow(node) }
816 : { x: node.x + Math.floor(node.w / 2), y: node.y - 1 }
817}
818
819/** How far a laid-out drawing reaches, in cells from the pane's own origin. */
820export function extentOf(view: Layout): { w: number; h: number } {
821 const boxes = view.lanes.flatMap(lane => [
822 { x: lane.x, y: lane.y, w: lane.w, h: lane.h },
823 ...lane.nodes,
824 ...(lane.ghost ? [lane.ghost] : []),
825 ])
826
827 return {
828 // The strip and the band naming the phases the run has not reached are part
829 // of the drawing, not furniture around it: a reader scrolling to the end of
830 // a long run is scrolling to see what is still to come.
831 w: Math.max(0, ...boxes.map(b => b.x + b.w), view.ahead ? view.ahead.x + view.ahead.w : 0),
832 h: Math.max(0, ...boxes.map(b => b.y + b.h)) + (view.aheadRows ? view.aheadRows + 1 : 0),
833 }
834}
835
836/**
837 * The same drawing, moved.
838 *
839 * Scrolling happens after the layout rather than inside it, and it moves the
840 * boxes rather than the cells: every wire, barrier, rail and arrowhead the
841 * painter draws is derived from where the boxes are, so moving the boxes moves
842 * the whole picture and keeps it consistent with itself. The painter's window
843 * then drops whatever falls outside the body.
844 */
845export function scrolled(view: Layout, dx: number, dy: number): Layout {
846 if (dx === 0 && dy === 0) {
847 return view
848 }
849
850 const move = <T extends { x: number; y: number }>(box: T): T => ({ ...box, x: box.x + dx, y: box.y + dy })
851 // The spine is a column across the pane and a row down it, so which way it
852 // moves is which way the drawing runs. Moved by `dx` in both, a band scrolled
853 // down the pane kept its spine on the row it was laid out at while its cards
854 // went with the scroll, and every wire into that band ran from a card at the
855 // top of the drawing to a spine forty rows below it — through every card in
856 // between. Nothing caught it while the stacked layout could not scroll.
857 const along = view.orientation === 'horizontal' ? dx : dy
858
859 return {
860 ...view,
861 lanes: view.lanes.map(lane => ({
862 ...move(lane),
863 ...(lane.captionRow !== undefined ? { captionRow: lane.captionRow + dy } : {}),
864 edgeAt: lane.edgeAt < 0 ? lane.edgeAt : lane.edgeAt + dx,
865 busAt: lane.busAt < 0 ? lane.busAt : lane.busAt + along,
866 nodes: lane.nodes.map(move),
867 ...(lane.ghost ? { ghost: move(lane.ghost) } : {}),
868 })),
869 ...(view.ahead ? { ahead: { ...view.ahead, x: view.ahead.x + dx } } : {}),
870 }
871}
872hooks/paint.ts 9533 lines1/**
2 * Paints a run onto a canvas: the header band, the phases along whichever axis
3 * the layout chose, the barrier spines between them, the inferred carries that
4 * cross a barrier, the agent nodes, and — when one is selected — the detail
5 * dialog carrying what the graph has no room for.
6 *
7 * Everything here is a pure function of the run, the tick and the selection, so
8 * the ticker can repaint and blit without the engine re-rendering the tree. It
9 * returns the label spans it drew, which is what makes a node clickable: the
10 * tree builder lays a Button over the same cells.
11 */
12
13import {
14 ARM,
15 armsOf,
16 Canvas,
17 cells,
18 cross,
19 DEFAULT_COLOR,
20 edge,
21 HOP,
22 joint,
23 line,
24 mix,
25 PORT,
26 spinnerAt,
27 type Rgb,
28} from './canvas'
29import { aboutRows, NAME, shippedVersion, TAGLINE, type AboutRow } from './about'
30import { modelName, type AgentRow, type RunState, type RunStatus, type Step, type ToolCall } from './journal'
31import { DEFAULT_THEME, paletteOf, themeOf, THEMES, type Palette, type Theme } from './theme'
32import {
33 aheadDepth,
34 barRowsOf,
35 entryOf,
36 exitOf,
37 extentOf,
38 INSET_W,
39 layout,
40 type LaneBox,
41 type Layout,
42 type NodeBox,
43 type Orientation,
44 scrolled,
45 titledPane,
46} from './layout'
47import {
48 chainOf,
49 wavesOf,
50 edgesOf,
51 follows,
52 isPipelineLike,
53 orderedLanes,
54 passesFor,
55 passesOf,
56 sourcesOf,
57 type Carry,
58 type FoldedLane,
59 type FoldPass,
60 type Pass,
61} from './shape'
62
63/**
64 * Four state colours, three data colours, and the greys between them.
65 *
66 * The states answer "how is it going": only work in flight is warm, so a
67 * running agent is the one thing on a finished-green pane that moves and
68 * glows. The data colours answer "what am I looking at": the clock, the token
69 * count and the model name each keep one hue wherever they are drawn — on a
70 * node, down a timeline row, in the detail dialog — so a column of numbers can
71 * be read by colour before it is read by position. They are deliberately
72 * quiet: three more saturated hues beside the states would be confetti.
73 */
74let COLORS: Palette = paletteOf(themeOf(DEFAULT_THEME))
75
76/**
77 * The theme those colours were derived from.
78 *
79 * The palette answers what to paint a thing in, which is all the drawing needs.
80 * The settings dialog needs one fact the derivation loses: which of the themes
81 * is the one on, so its own row can be marked in a list of all six.
82 */
83let THEME: Theme = themeOf(DEFAULT_THEME)
84
85/**
86 * Paints in this theme from here on.
87 *
88 * The palette is a module binding rather than something threaded through every
89 * call: it is read at draw time by about two hundred sites, and a theme is a
90 * property of the pane rather than of any one of them.
91 */
92export function useTheme(name: string): void {
93 THEME = themeOf(name)
94 COLORS = paletteOf(THEME)
95}
96
97/**
98 * The brightest point of a breathing rule or a live bar.
99 *
100 * Derived from the palette rather than fixed: the warm cream it used to be was
101 * chosen against one theme's yellow, and under another it read as a second
102 * colour entering the drawing rather than as the same colour lit.
103 */
104function glowOf(): Rgb {
105 return mix(COLORS.running, COLORS.text, 0.55)
106}
107
108/**
109 * The colour of a rule, a border or an empty track: structure the drawing needs
110 * and the reader should not have to look at.
111 *
112 * Mixed toward the palette's own grey, and never toward a hue. It used to be
113 * mixed toward the accent, which is the blue the wires are drawn in — so a
114 * phase border and the line crossing it were the same colour, and the drawing
115 * read as though the two meant the same thing. Structure is grey here and
116 * everywhere; a colour on this pane is a fact about the work.
117 *
118 * It is still mixed from the ground rather than fixed: the flat dark grey it
119 * replaced was picked against the pane's own ground, where it all but
120 * disappeared, and on any other ground it read as a full-width smear left over
121 * from another program rather than as a line this drawing meant to put there.
122 */
123export function quietOf(ground: Rgb): Rgb {
124 return mix(ground === DEFAULT_COLOR ? COLORS.track : ground, COLORS.dim, 0.38)
125}
126
127/**
128 * The ground a block of quoted material sits on: what an agent was asked, what
129 * it called, what it answered.
130 *
131 * One step off the pane's own ground and no more. The block has to read as
132 * something set into the pane rather than written on it, and a panel that
133 * announces itself is a second frame around text that already has a heading
134 * and a rule.
135 */
136function quoteOf(ground: Rgb): Rgb {
137 return mix(ground === DEFAULT_COLOR ? COLORS.track : ground, COLORS.track, 0.6)
138}
139
140/**
141 * How a repeat is written wherever one is shown: on the node that made it, and
142 * on the caption of the phase that looped.
143 *
144 * Two cells, because the node has two to spare and the fact is worth them: a
145 * phase with three agents against one file has either opened three lenses at
146 * once or gone round three times, and the drawing cannot say which by position.
147 */
148const LOOP = 0x21bb
149
150function loopMark(count: number | undefined): string {
151 return count === undefined || count < 1 ? '' : `${String.fromCodePoint(LOOP)}${count}`
152}
153
154/** The most passes any one piece of this phase's work took. */
155function deepest(agents: AgentRow[], passes: Map<string, Pass>): number {
156 return Math.max(0, ...agents.map(a => passes.get(a.agentId)?.total ?? 0))
157}
158
159const DASH_H = 0x2504
160const DASH_V = 0x2506
161const BULLET = 0x25ae
162/** One cell of a palette, three of them to a theme in the settings dialog. */
163const SWATCH = 0x25ae
164const ARROW_RIGHT = 0x25b8
165const ARROW_DOWN = 0x25be
166const ARROW_UP = 0x25b4
167const ARROW_LEFT = 0x25c2
168/** The rule down a node's left edge, in place of a frame around it. */
169/**
170 * The two weights a card's frame is drawn in: light for a node, heavy for the
171 * one whose detail is open. Weight rather than colour alone, so the open card
172 * is still the open card in a terminal that renders the palette differently.
173 */
174const LIGHT = { across: 0x2500, down: 0x2502, tl: 0x256d, tr: 0x256e, bl: 0x2570, br: 0x256f }
175/** Where a crossbar meets the wall of the box it is inside: `├` and `┤`. */
176const SHELF_L = 0x251c
177const SHELF_R = 0x2524
178const HEAVY = { across: 0x2501, down: 0x2503, tl: 0x250f, tr: 0x2513, bl: 0x2517, br: 0x251b }
179
180/**
181 * The dashed register: the strip of phases still ahead, and the rules a list
182 * row is divided by. A dash here is a double one, so it is never a carry.
183 */
184const SEAM_V = 0x254e
185const SEAM_H = 0x254c
186
187/**
188 * The boundary between two phases, laid out across.
189 *
190 * Continuous, where the dashed seam above is what it used to be. On a terminal
191 * the dash did not hold that line apart from the wires: an inferred carry is
192 * drawn in the triple dash, and at the size a cell renders, two dash patterns
193 * two columns apart are one grey shimmer. The gutter carried four verticals —
194 * two wires, a barrier spine and this — and a reader could not say which of
195 * them bounded a phase and which joined two agents.
196 *
197 * So the boundary runs whole and the wires keep the dash, which now means one
198 * thing on this pane: a carry read off the labels rather than proved by the
199 * text. The boundary gives way wherever a wire crosses it — the wire stays
200 * whole and the border breaks — and a one-cell gap in a straight grey line is
201 * a gap the eye closes without being asked.
202 */
203const BOUND_V = 0x2502
204
205/** The state bar down the left of a list row, where there is no room for a card. */
206const RULE = 0x258c
207
208/** The stroke between one piece of the header and the next. */
209const SEP_V = 0x2502
210/** The header's closing rule: run through, filled where work has landed. */
211const RULE_DONE = 0x2501
212const RULE_LEFT = 0x2500
213/** The same two strokes, dashed: a band that is another workflow's run. */
214const SEAM_DONE = 0x254d
215const SEAM_LEFT = 0x254c
216
217/**
218 * The stroke a phase's rule is drawn in.
219 *
220 * Dashed where the phase is a nested run. The pane already spends that stroke
221 * on work which is not this run's own — the phases it skipped, the strip
222 * naming the ones still ahead — and a nested run is the third of those: its
223 * agents belong to a workflow this one called, and they are on the pane
224 * because the call happened here, not because this run ran them.
225 *
226 * It costs no cells, it reads at a glance down a column of seventeen rules,
227 * and it is the same fact in every layout, because every layout draws a phase
228 * as a rule with a name in it. The alternative was a bracket in the margin,
229 * which needs two columns the narrow panes do not have and cannot be drawn at
230 * all beside a band laid out across.
231 */
232function ruleOf(nested: boolean, landed: boolean): number {
233 return nested ? (landed ? SEAM_DONE : SEAM_LEFT) : landed ? RULE_DONE : RULE_LEFT
234}
235/** The run name's caret: shut it points down at the list, open it points back. */
236const CARET_DOWN = 0x25be
237const CARET_UP = 0x25b4
238
239/** The detail dialog's close mark, at the dialog's own top corner. */
240const CLOSE_MARK = 0x2715
241
242/**
243 * The mark that says a figure is a duration.
244 *
245 * A card twenty cells wide has room for `1m54s 38k` and none for the words, and
246 * without a mark the two figures are told apart by colour alone — which leaves
247 * `1m` a minute on one card and `1.1m` a million tokens on the next. So the
248 * duration is marked and the token count is not: an hourglass says what it
249 * stands against without being read as anything else, and whatever is beside it
250 * unmarked is the count. The mark is written against its figure with no space
251 * between, so the pair reads as one word rather than as a glyph and a number.
252 *
253 * The token count carries the same kind of mark. The first one tried was a
254 * diamond, which stands for nothing in particular — a reader who has not been
255 * told cannot work out what it means, and there is nowhere in the pane that
256 * tells them. `\u2211` is not that: a token count is a total, and a sum sign is
257 * what a total is written under everywhere else. It costs one cell where the
258 * word `tkns` cost five, so every card can carry it and no figure on the pane
259 * stands without saying what it is a figure of.
260 */
261const CLOCK_MARK = '\u29d6'
262const SPEND_MARK = '\u2211'
263/**
264 * What stands where a token count would, for a row that has none.
265 *
266 * A row with no figure used to draw no figure, which is the same picture a row
267 * that genuinely spent nothing draws — and the two are not the same fact. An
268 * agent's count arrives twice: the engine attributes one to every agent when
269 * the run ends, and until then the pane has whatever it read of that agent's
270 * own transcript. So a finished agent inside a running run can have nothing to
271 * show yet, and drawing nothing said it had been cheap. The dash says the pane
272 * does not know, `\u22110` says it knows the answer is none, and a reader can
273 * tell a reporting gap from a cheap step.
274 */
275const SPEND_NONE = '\u2014'
276const TOOL_MARK = '\u2699'
277const THINK_MARK = '\u25cc'
278const REQUEST_MARK = '\u21c4'
279/** How many agents: two of a thing joined, because that is what it counts. */
280const FLEET_MARK = '\u29c9'
281
282/** Where a phase seam hangs off that rule. */
283const TEE_DONE = 0x252f
284const TEE_LEFT = 0x252c
285const TEE_RIGHT = 0x251c
286/** The cells the surface's own close control takes, top right. */
287const CLOSE_W = 2
288/**
289 * The cells one block of a detail needs to carry a wrapped line without
290 * breaking it mid-phrase.
291 */
292const BLOCK_W = 40
293
294/** Where a node's label was drawn, so the tree can lay a Button over it. */
295export type Hotspot = {
296 agentId: string
297 x: number
298 y: number
299 w: number
300}
301
302/**
303 * The hotspot id the run's own name carries, in place of an agent's.
304 *
305 * The chooser that switches runs used to be a row of its own under the footer,
306 * on screen whether or not anyone was choosing. The run's name is already at
307 * the top of the pane saying which run this is, so the name is the control: it
308 * is the same fact, and pressing what a thing is called to get the others is
309 * how every other chooser in a terminal behaves. The `@` cannot collide with an
310 * agent id, which the engine writes as a plain identifier.
311 */
312export const RUN_PICKER = '@runs'
313
314/** The hotspot key the detail dialog's close mark carries. */
315export const DETAIL_CLOSE = '@close'
316
317/**
318 * The hotspot key the settings button carries.
319 *
320 * Every setting used to be a control of its own in a row under the drawing:
321 * four presses wide, on screen whether or not anyone was changing anything, and
322 * each one a cycle you could only read by pressing it. One button opens them
323 * all, and the row it came from now spends its width saying what the pane is
324 * set to rather than how to change it.
325 */
326export const SETTINGS = '@settings'
327
328/** The hotspot key the settings dialog's close mark carries. */
329export const SETTINGS_CLOSE = '@settings-close'
330
331/**
332 * The hotspot key the pane's own name carries, at the far end of the foot row.
333 *
334 * The name is the button. An `i` in a circle is two cells wide in some
335 * terminals and one in others, and a row that reads well in one terminal and
336 * wraps in the next is worse than one with no icon — the same reason the foot
337 * row's gear is a geometric glyph rather than an emoji. What a reader presses
338 * to find out what this is, is what it is called.
339 */
340export const ABOUT = '@about'
341
342/** The hotspot key the About dialog's close mark carries. */
343export const ABOUT_CLOSE = '@about-close'
344
345/**
346 * Rows the detail dialog can be asked for.
347 *
348 * How tall the dialog opens is a fact about the dialog, so the bounds live
349 * beside the code that draws it — the settings dialog reads them to grey out a
350 * step that would go past either end.
351 */
352export const MIN_DETAIL = 5
353export const MAX_DETAIL = 32
354
355export type PaintOptions = {
356 nowMs: number
357 tick: number
358 orientation?: Orientation
359 /** The agent whose detail dialog is open, if any. */
360 selectedId?: string
361 /** Rows the detail dialog may take; 0 draws none. */
362 detailRows?: number
363 /** The first line to show in each block of the dialog; clamped to each list. */
364 detailScroll?: number[]
365 /** Which of the dialog's tabs is open; clamped to the ones the agent has. */
366 detailTab?: number
367 /**
368 * The tool call opened out of the Calls list, by its own id.
369 *
370 * It takes the dialog the agent had, so the agent's tab and scroll are left
371 * where they were and the way back returns to them.
372 */
373 openCall?: string
374 /** The first line to show in the opened call's argument; clamped to what it holds. */
375 callScroll?: number
376 /** The first line to show in the opened call's output; clamped the same way. */
377 callOutScroll?: number
378 /**
379 * The nested run an open agent was reached from, by its phase.
380 *
381 * Only for the way back: the agent is drawn exactly as it would be had it
382 * been pressed in the graph, with `◂ Back` added to its corner.
383 */
384 fromRun?: string
385 /**
386 * How far the drawing is scrolled inside the body, in cells; clamped to what
387 * the drawing actually overruns by, so a pane that grew scrolls back on its
388 * own rather than leaving the reader on a blank field.
389 */
390 bodyScroll?: { x: number; y: number }
391 /**
392 * Whether the window follows the phase the run is working in. On by default,
393 * and off only while a reader is looking somewhere else. `bodyScroll` is what
394 * the pane shows when it is off.
395 */
396 follow?: boolean
397 /**
398 * Whether the run's name opens the list of the session's other runs, and
399 * whether that list is open. Absent where there is nothing to choose between.
400 */
401 runPicker?: 'shut' | 'open'
402 /**
403 * The session's runs, for the list the name opens. Read only while that list
404 * is open; the menu puts them in its own order.
405 */
406 runs?: RunEntry[]
407 /**
408 * The first row of that list on screen, where it is taller than the pane.
409 *
410 * Absent until a reader scrolls, and absent again once the list is shut: an
411 * unscrolled list seats itself on the run the pane is drawing, so a reader
412 * who opens the list to move away from the run they are on can see where they
413 * are standing without scrolling to find it.
414 */
415 runScroll?: number
416 /** True while the settings dialog is open over the drawing. */
417 settings?: boolean
418 /**
419 * Which setting's list is unrolled inside that dialog, if any.
420 *
421 * The dialog shows what the pane is set to and keeps the rest behind the
422 * control that says it, so one list at a time is open and the pane has to
423 * remember which.
424 */
425 menu?: SettingMenu
426 /** True while the About dialog is open over the drawing. */
427 about?: boolean
428 /**
429 * The nested runs the reader has unfolded, by phase name. A nested run left
430 * shut is one row saying how many agents it held; opened it is the phase every
431 * other phase is.
432 */
433 opened?: string[]
434}
435
436/**
437 * One run as the menu lists it.
438 *
439 * The parts arrive separately rather than as one line because the menu draws
440 * them in three columns and three colours: the state's own glyph, the name and
441 * how far it got, and the clock it started on.
442 */
443export type RunEntry = {
444 id: string
445 /** The glyph for the state the run is in. */
446 mark: string
447 /** What the workflow is called. */
448 name: string
449 /** How many of its agents landed, as `6/10`. */
450 tally: string
451 status: RunStatus
452 /** When it started, for the clock column and for the order. */
453 startedMs: number
454}
455
456/**
457 * How much of the drawing the body is showing, and how much of it is off the
458 * edges. The pane keeps the offset; this is what it clamps the offset against,
459 * so the scroll follows the drawing when the drawing or the pane changes size.
460 */
461export type BodyScroll = {
462 /** Where the body is, in cells from the drawing's own origin. */
463 x: number
464 y: number
465 /** The furthest either can go: the drawing's extent less the body's own. */
466 spanX: number
467 spanY: number
468}
469
470/**
471 * How much of the session's runs a list is showing, and whereabouts in them.
472 *
473 * Reported back the way the detail dialog's panes and the body are: the pane
474 * keeps an offset, the paint clamps it against what it actually drew, and the
475 * pane takes the clamped figure. A list that shrank because a run ended, or a
476 * pane that grew, moves back into view on its own rather than leaving the
477 * reader on a blank field.
478 */
479export type RunWindow = {
480 /** Every row the list has, headings included. */
481 total: number
482 /** How many of them the pane can stand at once. */
483 visible: number
484 /** The first row on screen. */
485 scroll: number
486}
487
488export type PaintResult = {
489 hotspots: Hotspot[]
490 /**
491 * Which way the drawing was read, not which layout drew it.
492 *
493 * `list` is a layout a reader can ask for and never an answer here: the flat
494 * list runs down the pane, so it is drawn on the vertical axis and reports
495 * it. Naming it in this union would offer a caller a case that can never
496 * arrive, and a caller that branched on it to find the list would silently
497 * never match.
498 */
499 orientation: 'horizontal' | 'vertical' | 'timeline'
500 /** The detail list's extent and window, when one is drawn. */
501 detail?: DetailView
502 /** Where the drawing sits in the body, when it is larger than the body. */
503 body?: BodyScroll
504 /** The run list's extent and window, wherever one is drawn. */
505 runList?: RunWindow
506 /**
507 * The phase the run is working in, while it is running.
508 *
509 * What the window follows, and what says when it should stop following: a
510 * reader who scrolled away is left alone until this changes, which is the
511 * run moving on rather than the run merely ticking.
512 */
513 front?: string
514 /**
515 * The layout the drawing was actually made from, scroll and all.
516 *
517 * A rail costs the drawing a column, so the painter's own layout is not
518 * always the one a bare `layout()` at the same size returns — and anything
519 * asking where a box ended up has to ask the painter.
520 */
521 view?: Layout
522}
523
524/**
525 * A running agent with nothing seen from it for this long is called quiet: no
526 * request streaming, no tool call. Long enough that a slow model response is
527 * not accused, short enough that a hung one is noticed before the run is.
528 */
529const QUIET_MS = 45_000
530
531/** The tokens a row can claim: the summary's once it has them, the live sum until then. */
532function tokensOf(agent: AgentRow): number | undefined {
533 return agent.tokens ?? agent.liveTokens
534}
535
536/** How long a running agent has been silent, or 0 when it is not. */
537function quietFor(agent: AgentRow, nowMs: number): number {
538 if (agent.state !== 'running' || agent.isThinking || agent.tools.some(t => t.isRunning)) {
539 return 0
540 }
541
542 const since = nowMs - (agent.activeMs ?? agent.startedMs)
543
544 return since >= QUIET_MS ? since : 0
545}
546
547function colorOf(state: AgentRow['state']): Rgb {
548 return state === 'done'
549 ? COLORS.done
550 : state === 'failed'
551 ? COLORS.failed
552 : state === 'stopped'
553 ? COLORS.stopped
554 : COLORS.running
555}
556
557/**
558 * How far a card's frame is carried toward the state of the agent inside it.
559 *
560 * Four fifths. Just over half was the first setting, and on a dark ground it
561 * was not enough: `done` landed on a muted teal and `running` on an olive, and
562 * a pane of twenty cards read as a pane of grey boxes with coloured ticks in
563 * them. The hue has to survive being drawn one cell wide in a line glyph, which
564 * is a fraction of the ink a word of the same colour puts down.
565 *
566 * It stops short of the state colour itself so the frame stays a boundary: at
567 * full strength the border is the brightest thing on the card and is read
568 * before the name and the figures it encloses.
569 */
570const STATE_EDGE = 0.8
571
572/**
573 * A card's frame, tinted by the state of the agent it encloses.
574 *
575 * The frame was once drawn in the plain boundary tone for every card, because
576 * an earlier attempt at a state-coloured edge failed on three counts: it was
577 * the full state colour, so the border shouted louder than the name it framed;
578 * the stopped state was a grey, so a cut-off card's frame was the same grey as
579 * the phase boundary behind it; and the wires were free to be drawn in a hue a
580 * state was also drawn in, so a border and the line crossing it could agree in
581 * colour while meaning different things.
582 *
583 * All three are now settled. `stopped` is a grey carried toward the text tone,
584 * well clear of the near-ground tone the boundaries are drawn in, so a cut-off
585 * card is read as cut off rather than as a rule. `edgeOf` picks a wire hue
586 * at least 45 degrees off every state hue, so no card's frame can be mistaken
587 * for the wire that lands on it. And the tint stops at `STATE_EDGE`, so the
588 * frame is a boundary that also says something rather than a slab of colour.
589 *
590 * What it buys is the reading a graph is for: which part of the run is done,
591 * which part is working and which part broke, taken in as shapes on a page
592 * before a single word is read.
593 */
594export function frameOf(state: AgentRow['state'], ground: Rgb): Rgb {
595 return mix(quietOf(ground), colorOf(state), STATE_EDGE)
596}
597
598/**
599 * What happened to an agent, in a word, for the timeline.
600 *
601 * A bar is a measurement, and a measurement cannot say whether the thing it
602 * measures finished: three weights of block and a mark one cell wide were
603 * carrying that on their own, and a stopped agent's hollow grey bar read as a
604 * row that had not been drawn yet rather than as work the run cut off. A
605 * running agent is the exception, and says what it is doing instead — which is
606 * the fact that changes while it is watched.
607 */
608function stateWord(state: AgentRow['state']): string {
609 return state === 'running' ? '' : state === 'done' ? 'done' : state === 'failed' ? 'failed' : 'stopped'
610}
611
612/**
613 * The glyph beside a node's name: spinner, tick, cross, or the stopped mark.
614 *
615 * All four are drawn heavy. A state mark is one cell against a name drawn in
616 * several, so a light glyph loses the comparison it is there to win: `\u2718` and
617 * `\u229d` are outlines, and at a glance a pane of them reads as a pane of
618 * identical grey specks. `\u2716` and `\u2298` fill the cell the way `\u2714` does.
619 */
620function markOf(state: AgentRow['state'], tick: number): number {
621 return state === 'running'
622 ? spinnerAt(tick)
623 : state === 'done'
624 ? 0x2714
625 : state === 'failed'
626 ? 0x2716
627 : 0x2298
628}
629
630function elapsed(ms: number): string {
631 if (ms < 1000) {
632 return `${Math.max(0, Math.round(ms))}ms`
633 }
634
635 if (ms < 60_000) {
636 return `${(ms / 1000).toFixed(1)}s`
637 }
638
639 const minutes = Math.floor(ms / 60_000)
640
641 return `${minutes}m${String(Math.floor((ms % 60_000) / 1000)).padStart(2, '0')}s`
642}
643
644/**
645 * A token count in five cells at most: `812`, `38.4k`, `1.1m`.
646 *
647 * A long run spends millions, and `2431.7k` is a figure a reader has to count
648 * the digits of before they know what it is. The scale changes at a thousand of
649 * the unit below it, and one decimal is kept either side of the change so the
650 * figure never jumps from `999k` to `1m`.
651 *
652 * Called with nothing, it writes the dash: every spelling below is built from
653 * this one or from {@link tokensShort}, so the two guards give the whole ladder
654 * a way to say that no count was recorded, each in its own shape.
655 */
656function tokens(n?: number): string {
657 if (n === undefined) {
658 return SPEND_NONE
659 }
660
661 if (n < 1000) {
662 return String(n)
663 }
664
665 const scaled = n >= 1_000_000 ? n / 1_000_000 : n / 1000
666 const unit = n >= 1_000_000 ? 'm' : 'k'
667 // A decimal that is always a zero is a decimal that says nothing: `15.0k` is
668 // `15k` written in two more cells.
669 const figure = scaled.toFixed(1).replace(/\.0$/, '')
670
671 return `${figure}${unit}`
672}
673
674/** The same again with the mark held off the figure, for a card that can hold it. */
675function tokensShortWide(n?: number): string {
676 return tokensShort(n).replace(SPEND_MARK, `${SPEND_MARK} `)
677}
678
679/** The same count with the decimal dropped once it buys nothing: `15k`, `9.4k`. */
680function tokensShort(n?: number): string {
681 if (n === undefined) {
682 return `${SPEND_MARK}${SPEND_NONE}`
683 }
684
685 if (n < 1000) {
686 return `${SPEND_MARK}${n}`
687 }
688
689 const scaled = n >= 1_000_000 ? n / 1_000_000 : n / 1000
690 const unit = n >= 1_000_000 ? 'm' : 'k'
691 const figure = scaled >= 10 ? `${Math.round(scaled)}${unit}` : `${scaled.toFixed(1)}${unit}`
692
693 return `${SPEND_MARK}${figure}`
694}
695
696/** The count under its mark, the way a card glues its clock to its duration. */
697function tokensLong(n?: number): string {
698 return `${SPEND_MARK}${tokens(n)}`
699}
700
701/** The same, with a space, for the two rows wide enough to let it breathe. */
702function tokensWide(n?: number): string {
703 return `${SPEND_MARK} ${tokens(n)}`
704}
705
706/**
707 * The widest spelling of all: the mark, the figure, and the unit written out.
708 *
709 * `∑ 38.4k` says which measurement it is to a reader who knows the pane, and
710 * the mark is the only thing telling them. Where the row has five cells to
711 * spare it says so in words instead, and nobody has to learn the mark to read
712 * the pane's most-quoted figure. It is the first form to go when the room runs
713 * out, because the mark carries the same fact in a fifth of the width.
714 */
715function tokensNamed(n?: number): string {
716 return `${tokensWide(n)} tkns`
717}
718
719/**
720 * How a card writes its token count: with the unit where every card can hold
721 * it, bare where one cannot.
722 *
723 * `17k` beside `⧖12.9s` is two figures in two units, and only one of them says
724 * which it is — the duration carries a clock, the count carried nothing, so a
725 * reader had to know the pane to know what the second figure was. Both carry a
726 * mark now; what is left to choose is the precision, and a decimal costs two
727 * cells that a card of thirty-two columns has and a card of fourteen does not.
728 *
729 * Every card or none. Two spellings of one measurement down a column of cards
730 * is a column that has to be read twice, which is the whole reason the picture
731 * settles its formats once a frame rather than per node. A list needs neither:
732 * its counts are a column, and a column says its unit at the head.
733 */
734function spendingOf(
735 nodes: NodeBox[],
736 nowMs: number,
737 clock: (ms: number) => string,
738 density: 'card' | 'row',
739): (n?: number) => string {
740 if (density !== 'card') {
741 return tokensShort
742 }
743
744 const holds = (form: (n: number) => string) =>
745 nodes.every(node => {
746 const spent = tokensOf(node.agent)
747
748 if (spent === undefined) {
749 return true
750 }
751
752 const took = (node.agent.endedMs ?? nowMs) - node.agent.startedMs
753
754 // The duration, two cells between the figures, and one inside each stroke.
755 return clock(took).length + 2 + form(spent).length <= node.w - 4
756 })
757
758 // Widest first: the decimal, then the cell of air that divides the mark from
759 // the figure, then the figure alone. The air outranks the decimal — `∑ 17k`
760 // is a count with a mark beside it, `∑17.3k` is one token a reader has to
761 // break apart before either half can be read.
762 return [tokensNamed, tokensWide, tokensShortWide, tokensLong, tokensShort].find(holds) ?? tokensShort
763}
764
765/**
766 * A duration in four cells at most — `0.2s`, `4s`, `95s`, `12m` — with the unit
767 * fixed for the whole picture by the longest of them.
768 *
769 * Picked per value instead, a run lasting a quarter of an hour writes `12m`
770 * over one node and `40s` over the next, and the two cannot be compared without
771 * doing the arithmetic first.
772 */
773function tightClockFor(longestMs: number): (ms: number) => string {
774 if (longestMs >= 600_000) {
775 return ms => `${Math.max(1, Math.round(ms / 60_000))}m`
776 }
777
778 if (longestMs >= 950) {
779 return ms => `${Math.max(1, Math.round(ms / 1000))}s`
780 }
781
782 return ms => `${Math.max(1, Math.round(ms / 100)) / 10}s`
783}
784
785/** Text cut to `max` cells, with an ellipsis in the last of them where it was cut. */
786function truncate(s: string, max: number): string {
787 if (max <= 0) {
788 return ''
789 }
790
791 // Cells, not UTF-16 units: an astral character is two units and one cell, a
792 // combining mark is one unit and no cell, and a budget counted in units hands
793 // a label either less room than it has or more room than it fits.
794 if (cells(s) <= max) {
795 return s
796 }
797
798 // One cell is the first letter, not the mark. The mark says a name was cut,
799 // which a reader can see for themselves in a field one cell wide; the letter
800 // says which name it was, and `G` against `D` against `C` is the difference
801 // between a row of phases and a row of marks.
802 if (max === 1) {
803 const first = [...s][0] as string
804
805 return cells(first) === 1 ? first : '…'
806 }
807
808 // The mark is part of the budget, and the characters are measured as they are
809 // kept. Counted in units and with a character kept whatever the budget, a
810 // label cut to one cell came back two wide — and the cell it ran into is the
811 // one the wire leaves the card by, so a name too long for its box rubbed out
812 // the point that says which card the line belongs to.
813 let kept = ''
814 let used = 0
815
816 for (const ch of s) {
817 const w = cells(ch)
818
819 if (used + w > max - 1) {
820 break
821 }
822
823 kept += ch
824 used += w
825 }
826
827 return `${kept}…`
828}
829
830/**
831 * The marker a workflow engine writes in front of a nested run's name, and the
832 * one this pane writes when that run is open.
833 */
834const FOLD_SHUT = '\u25b8 '
835const FOLD_OPEN = '\u25be '
836
837/**
838 * A nested run's name, in the cells it has, and whatever the cells could not
839 * take.
840 *
841 * `code-review:ai-review-agentic` is not one name. It is a plugin and a
842 * workflow, and cutting it from the right — which is all `truncate` can do —
843 * spends the half that varies to keep the half that does not. Every nested run
844 * of one plugin shares its plugin half, so `code-review:ai-review-age…` and
845 * `code-review:ai-review-quick…` are two runs a reader cannot tell apart at any
846 * width where either is cut at all.
847 *
848 * So the name gives way in a fixed order, as the header's own fields do:
849 *
850 * 1. **A half that repeats the other goes first**, at every width. The pipeline
851 * runs `test-coverage:test-coverage` and `security-reviewer:security-review`;
852 * the second word of each is the first one again, and a name written twice is
853 * a name a reader reads twice and learns nothing by. The longer half is kept,
854 * because it is the one carrying the extra letters.
855 * 2. **Then the plugin half.** It is repeated down the run wherever that plugin
856 * appears, and it is the half a reader can infer from the one that is left.
857 * 3. **Only then is the workflow half cut**, from the right, as any other name
858 * is.
859 *
860 * A name with no halves to it — `Base Branch Health`, most agent labels — skips
861 * to step 3, which is the plain truncation it always had.
862 */
863export function runName(s: string, width: number): string {
864 const mark = s.startsWith(FOLD_SHUT) || s.startsWith(FOLD_OPEN) ? s.slice(0, 2) : ''
865 const name = s.slice(mark.length)
866 const at = name.indexOf(':')
867 const room = Math.max(1, width - cells(mark))
868
869 if (at <= 0 || at === name.length - 1) {
870 return mark + truncate(name, room)
871 }
872
873 const plugin = name.slice(0, at)
874 const workflow = name.slice(at + 1)
875 // One half saying the other over again: `test-coverage:test-coverage`, and
876 // `security-reviewer:security-review`, which is the same word with its ending
877 // filed off. Either way the longer of the two is the whole of what the pair
878 // had to say.
879 const doubled = plugin.startsWith(workflow) || workflow.startsWith(plugin)
880 const said = doubled ? (plugin.length >= workflow.length ? plugin : workflow) : name
881
882 if (cells(said) <= room) {
883 return mark + said
884 }
885
886 if (!doubled && cells(workflow) <= room) {
887 return mark + workflow
888 }
889
890 // Room for neither half whole. The plugin half has already gone by this
891 // point, so what is cut is the workflow half — cutting the pair would spend
892 // the last cells on the half step 2 decided was the one to lose.
893 return mark + truncate(doubled ? said : workflow, room)
894}
895
896/** Wraps text to a width, breaking at spaces where it can. */
897/** Frames each end of a moving label is held at, and frames between steps. */
898const SLIDE_HOLD = 9
899const SLIDE_EVERY = 3
900
901/**
902 * A label longer than the cells it has, moved along a little at a time so the
903 * whole of it can be read.
904 *
905 * It travels to its end, waits, and travels back — it does not run round. A
906 * label that wraps shows its tail and its head in the same cells for as many
907 * frames as the label is long, and `edge:down paint:correctness` caught
908 * mid-wrap reads as `.: tness paint:`, which is not a label at all. Going
909 * back the way it came, every frame is a piece of the real name.
910 *
911 * Only the labels of agents that are working move, and only while the run is
912 * live — a pane of finished agents all sliding at once would be the busiest
913 * thing on screen and none of it would mean anything. Both ends are held for
914 * about a second, because a name that never stops moving is a name that has to
915 * be caught rather than read.
916 *
917 * `tick` below zero is the still frame: a run that has ended draws no more
918 * frames, so a label left halfway through its travel would stay there.
919 */
920function slide(s: string, width: number, tick: number): string {
921 if (s.length <= width || width <= 0 || tick < 0) {
922 return truncate(s, width)
923 }
924
925 const travel = s.length - width
926 const step = Math.floor(tick / SLIDE_EVERY) % (2 * (travel + SLIDE_HOLD))
927 const at =
928 step < SLIDE_HOLD
929 ? 0
930 : step < SLIDE_HOLD + travel
931 ? step - SLIDE_HOLD
932 : step < 2 * SLIDE_HOLD + travel
933 ? travel
934 : 2 * SLIDE_HOLD + 2 * travel - step
935
936 return s.slice(at, at + width)
937}
938
939function wrap(s: string, width: number, lines: number): string[] {
940 const words = s.replace(/\s+/g, ' ').trim().split(' ')
941 const out: string[] = []
942 let line = ''
943
944 for (const word of words) {
945 if (line && line.length + word.length + 1 > width) {
946 out.push(line)
947 line = word
948
949 if (out.length === lines) {
950 break
951 }
952 } else {
953 line = line ? `${line} ${word}` : word
954 }
955 }
956
957 if (out.length < lines && line) {
958 out.push(line)
959 }
960
961 return out.slice(0, lines).map(l => truncate(l, width))
962}
963
964/**
965 * The same, where the first line is narrower than the rest.
966 *
967 * A call's first line shares its row with the figures the call cost, and every
968 * line after it has the block to itself. Wrapped to the narrow width throughout,
969 * a long command broke about a third early on every continuation and the column
970 * carried a ragged margin of blank cells no figure ever stood in.
971 */
972function wrapAfter(s: string, first: number, rest: number, lines: number): string[] {
973 const words = s.replace(/\s+/g, ' ').trim().split(' ')
974 const out: string[] = []
975 let line = ''
976
977 const room = () => (out.length === 0 ? first : rest)
978
979 for (const word of words) {
980 if (line && line.length + word.length + 1 > room()) {
981 out.push(line)
982 line = word
983
984 if (out.length === lines) {
985 break
986 }
987 } else {
988 line = line ? `${line} ${word}` : word
989 }
990 }
991
992 if (out.length < lines && line) {
993 out.push(line)
994 }
995
996 return out.slice(0, lines).map((l, i) => truncate(l, i === 0 ? first : rest))
997}
998
999/**
1000 * Somebody else's text, wrapped the way they wrote it.
1001 *
1002 * Every other wrap on the pane flattens whitespace, which is right for a node's
1003 * tail and wrong for a document: a heredoc, a `gh api` call with a flag per
1004 * line, a markdown brief with headings and lists, or a patch argument is
1005 * *structured* by its line breaks, and run together it becomes one unreadable
1006 * paragraph that happens to contain the right characters. So the source's own
1007 * lines are kept and each is wrapped inside itself.
1008 *
1009 * Past `cap` the remaining lines are counted rather than drawn, so the block
1010 * still says how much there was.
1011 */
1012function written(text: string, first: number, rest: number, cap: number): string[] {
1013 if (!text) {
1014 return []
1015 }
1016
1017 const out: string[] = []
1018
1019 for (const line of text.split('\n')) {
1020 if (out.length >= cap) {
1021 break
1022 }
1023
1024 if (!line.trim()) {
1025 // A blank line in the middle of an argument is the writer's own spacing
1026 // and is kept; one before the first drawn line is not, since it would
1027 // leave the call's name on a row with nothing beside it.
1028 if (out.length > 0) {
1029 out.push('')
1030 }
1031
1032 continue
1033 }
1034
1035 out.push(...wrapAfter(line, out.length === 0 ? first : rest, rest, cap - out.length))
1036 }
1037
1038 const over = text.split('\n').length - out.length
1039
1040 return over > 0 && out.length >= cap ? [...out, `… ${over} more lines`] : out
1041}
1042
1043/**
1044 * A light travelling along a wire into work that is running.
1045 *
1046 * A spinner says an agent is busy. Nothing said that the work now in it came
1047 * along that line from the agent before, and on a graph of thirty still strokes
1048 * the one that is carrying something is the one a reader wants. So the cells
1049 * keep their glyphs and one of them, moving, is lit.
1050 */
1051function paintFlowing(c: Canvas, cells: { x: number; y: number }[], tick: number): void {
1052 if (tick < 0 || cells.length === 0) {
1053 return
1054 }
1055
1056 // One cell every other frame: at a frame each eighth of a second, a step per
1057 // frame crosses a gutter three times a second, which reads as a flicker
1058 // rather than as something moving along a line.
1059 const step = Math.floor(tick / 2)
1060 const at = cells[((step % cells.length) + cells.length) % cells.length]
1061
1062 c.put(at.x, at.y, c.at(at.x, at.y), mix(COLORS.running, glowOf(), 0.6))
1063}
1064
1065function pulse(at: number, length: number, tick: number): number {
1066 const head = (((tick * 0.7) % length) + length) % length
1067 const distance = Math.min(Math.abs(at - head), length - Math.abs(at - head))
1068
1069 return Math.max(0, 1 - distance / 3)
1070}
1071
1072/**
1073 * What the agent is doing right now, and nothing once it has stopped doing it.
1074 *
1075 * It used to end with the head of what the agent answered. A node is the pane's
1076 * own drawing of a run, and the agent's prose is another program's words: set
1077 * in a node's spare cells it read as the pane talking, it was a sentence cut
1078 * off wherever the row ran out, and the phrase it cut to was as likely to be
1079 * `Let's run the existing` as anything worth the row. What an agent said is in
1080 * its dialog, whole, where it can be read as a sentence. The row says what is
1081 * happening, which is the part that changes while it is watched.
1082 */
1083function tailOf(agent: AgentRow, nowMs: number): string {
1084 const busy = agent.tools.find(t => t.isRunning)
1085
1086 if (agent.state === 'running' && busy) {
1087 return busy.count > 1 ? `${busy.name} ×${busy.count}` : busy.name
1088 }
1089
1090 // That it is thinking, not the tail of what it is thinking. A slice off the
1091 // end of a half-written sentence reads as a fault in the pane; the whole of
1092 // it is in the dialog, where it can be read as a sentence.
1093 if (agent.state === 'running' && agent.isThinking) {
1094 return 'thinking'
1095 }
1096
1097 const quiet = quietFor(agent, nowMs)
1098
1099 if (quiet > 0) {
1100 return `quiet ${elapsed(quiet)}`
1101 }
1102
1103 if (agent.state === 'running' && agent.tools.length > 0) {
1104 const calls = agent.tools.reduce((sum, t) => sum + t.count, 0)
1105
1106 return `${calls} tool ${calls === 1 ? 'call' : 'calls'}`
1107 }
1108
1109 return ''
1110}
1111
1112/**
1113 * What a running agent is doing, for its card's bottom edge, or nothing where
1114 * the card has nothing to add to the mark in its corner.
1115 *
1116 * The same marks the rest of the pane gives the same two things — a tool call
1117 * and a turn of thinking — so a card is read with the vocabulary the run line
1118 * and the detail dialog already taught. Going quiet has no mark, because nothing
1119 * in the drawing means quiet; it has a duration instead, which is the whole of
1120 * what is worrying about it.
1121 */
1122function doingOf(agent: AgentRow, nowMs: number): string {
1123 if (agent.state !== 'running') {
1124 return ''
1125 }
1126
1127 const busy = agent.tools.find(t => t.isRunning)
1128
1129 if (busy) {
1130 return `${TOOL_MARK} ${busy.count > 1 ? `${busy.name} ×${busy.count}` : busy.name}`
1131 }
1132
1133 if (agent.isThinking) {
1134 return `${THINK_MARK} thinking`
1135 }
1136
1137 const quiet = quietFor(agent, nowMs)
1138
1139 if (quiet > 0) {
1140 return `quiet ${elapsed(quiet)}`
1141 }
1142
1143 const calls = agent.tools.reduce((sum, t) => sum + t.count, 0)
1144
1145 return calls > 0 ? `${TOOL_MARK} ${calls}` : ''
1146}
1147
1148/** The colour a tail is drawn in: a quiet agent's is the warning, the rest fade toward its state. */
1149function tailColorOf(agent: AgentRow, nowMs: number, color: Rgb): Rgb {
1150 if (quietFor(agent, nowMs) > 0) {
1151 return mix(COLORS.failed, COLORS.running, 0.5)
1152 }
1153
1154 const live = agent.state === 'running' && (agent.tools.length > 0 || agent.isThinking)
1155
1156 return mix(COLORS.dim, color, live ? 0.6 : 0.45)
1157}
1158
1159/**
1160 * A node's name with the phase it already sits under taken off the front.
1161 *
1162 * Every lane is captioned with its phase, so `gather:rivers` in a column headed
1163 * Gather spends seven of its cells repeating the header — and in a six-phase
1164 * run those are the cells that would have carried `rivers`, the only part that
1165 * tells one agent of a phase from its four siblings.
1166 */
1167function nodeLabel(agent: AgentRow): string {
1168 const phase = agent.phase?.trim()
1169
1170 if (!phase || !agent.label.toLowerCase().startsWith(phase.toLowerCase())) {
1171 return agent.label
1172 }
1173
1174 const rest = agent.label.slice(phase.length).replace(/^[\s:/_-]+/, '')
1175
1176 // A label that is only its phase keeps it; the alternative is an empty node.
1177 return rest.length > 0 ? rest : agent.label
1178}
1179
1180/** One field of a node's facts row, in its category's colour. */
1181type Field = { text: string; color: Rgb }
1182
1183/**
1184 * What an agent spent and what it ran on, in the cells available.
1185 *
1186 * The token count and the model are the two facts the graph states nowhere
1187 * else, so on a card they are the two that stay. A card too narrow for all
1188 * three gives up the clock first — the timeline draws every duration to scale
1189 * and the detail dialog writes it out, so it is the one fact losing a place
1190 * here does not lose outright.
1191 *
1192 * Down a list the order turns over. A lane's rows are one model's work almost
1193 * every time, so the model becomes a column of the same word repeated, which
1194 * costs eight cells a row to say what one line at the top of the lane would
1195 * say once. The clock is the field that differs row to row, and a list is read
1196 * by comparing rows.
1197 */
1198function factsOf(
1199 agent: AgentRow,
1200 took: number,hooks/press.ts 660 lines1/**
2 * What a press does to the view, with nothing of the session in it.
3 *
4 * The plugin's own press handler had all of this inside it, wound together
5 * with the engine calls that persist a setting and read an agent's transcript.
6 * That made the pane's behaviour reachable only from a live session: the one
7 * place it could be exercised was the place it was hardest to look at. Here
8 * the decision is a function over a plain object, so the plugin, the browser
9 * tool in `dev/` and a test can all press the same buttons and get the same
10 * view back.
11 *
12 * What the caller must still do is returned rather than done: a run to show, an
13 * agent whose transcript wants reading, settings worth remembering. Those need
14 * the engine, and the engine is the part that cannot be had outside a session.
15 */
16
17import type { Orientation } from './layout'
18import {
19 ABOUT,
20 ABOUT_CLOSE,
21 BAND_OPEN,
22 CALL_BACK,
23 CALL_OPEN,
24 CALL_PANE,
25 CALL_OUT_PANE,
26 DETAIL_CLOSE,
27 DETAIL_TAB,
28 MAX_DETAIL,
29 MIN_DETAIL,
30 PASS_OPEN,
31 RUN_BACK,
32 RUN_OPEN,
33 RUN_PICKER,
34 SETTING_MENUS,
35 SETTINGS,
36 SETTINGS_CLOSE,
37 useTheme,
38 type BodyScroll,
39 type DetailView,
40 type RunWindow,
41 type SettingMenu,
42} from './paint'
43import { nextTheme, THEMES } from './theme'
44
45// The bounds belong to the dialog they size, and the settings dialog greys out
46// a step that would go past either of them. Re-exported so the plugin still has
47// one place to read what a press can do.
48export { BAND_OPEN, MAX_DETAIL, MIN_DETAIL, SETTING_MENUS, type BodyScroll, type SettingMenu } from './paint'
49
50/** Everything a press can change. The plugin's own state is a superset of it. */
51export type PaneView = {
52 orientation: Orientation
53 theme: string
54 detailRows: number
55 /**
56 * What the detail dialog is open on: an agent by its id, or a nested run by
57 * `@run:<phase>`, which has no agent of its own.
58 */
59 selectedId: string | null
60 /**
61 * The nested run an open agent was reached from, where it was.
62 *
63 * Only the way back. Cleared whenever the reading moves anywhere the run's
64 * list is not behind, so `◂ Back` is never offered to a dialog that did not
65 * come from one.
66 */
67 fromRun: string | null
68 /**
69 * The tool call opened out of that agent's Calls list, by its own id.
70 *
71 * It takes the dialog the agent had rather than opening a box inside it, so
72 * the agent's tab and every tab's place are untouched while it is open and
73 * the way back is a press rather than a search.
74 */
75 openCall: string | null
76 /** The first line on screen in the opened call. */
77 callScroll: number
78 callOutScroll: number
79 /** The first line on screen in each block of the dialog. */
80 detailScroll: number[]
81 /**
82 * Which of the dialog's tabs is on top.
83 *
84 * It survives the dialog being shut and another node opened: a reader working
85 * down a failed phase is reading the same tab of each agent, and a dialog that
86 * opened on the prompt every time made them press twice per node. Out of
87 * range for the agent now open, the paint clamps it and reports back which tab
88 * it settled on.
89 */
90 detailTab: number
91 /**
92 * Where the body is in a drawing larger than it, in cells.
93 *
94 * The pane keeps the offset and the paint clamps it, so a pane that grows, a
95 * run that ends, or a layout that changes shape moves the drawing back into
96 * view on its own rather than leaving the reader on a blank field.
97 */
98 bodyScroll: { x: number; y: number }
99 /**
100 * Whether the body follows the phase the run is working in.
101 *
102 * On until a reader scrolls, and on again the moment the run enters a
103 * different phase — so looking back at what a finished phase did is a peek
104 * that ends when there is something new to see, rather than a state the
105 * reader has to remember to leave.
106 */
107 following: boolean
108 /** The phase the last paint said the run was working in, to see it change. */
109 front: string | null
110 /**
111 * The nested runs the reader has unfolded, by phase name.
112 *
113 * Kept as a list rather than a set so a view is a plain object a test can
114 * write out whole, the way every other field here is.
115 */
116 opened: string[]
117 /** True while the run name's list is unrolled under the top bar. */
118 picking: boolean
119 /**
120 * The first row on screen in the session's run list, where it is taller than
121 * the room it has.
122 *
123 * `null` until a reader moves it, and `null` again once the list is shut.
124 * That is what lets an unscrolled list seat itself on the run the pane is
125 * drawing: a reader who opens the list to move off the run they are on can
126 * see where they are standing without having to scroll to find it.
127 */
128 runScroll: number | null
129 /** True while the settings dialog is open over the drawing. */
130 settings: boolean
131 /** Which setting's list is unrolled inside it, of the one that can be. */
132 menu: SettingMenu | null
133 /** True while the About dialog is open over the drawing. */
134 about: boolean
135}
136
137/** What the caller has to do about the press, once the view is settled. */
138export type PressResult = {
139 /** A run the reader asked for by name. */
140 run?: string
141 /** An agent whose detail just opened, and whose transcript is wanted. */
142 opened?: string
143 /** Settings the press changed, for whoever keeps them between sessions. */
144 store?: ('theme' | 'detailRows' | 'orientation')[]
145}
146
147/** The orientations the layout control walks, in the order it walks them. */
148export const ORIENTATIONS: Orientation[] = ['horizontal', 'vertical', 'timeline', 'list', 'auto']
149
150export function nextOrientation(current: Orientation): Orientation {
151 const at = ORIENTATIONS.indexOf(current)
152
153 return ORIENTATIONS[(at + 1) % ORIENTATIONS.length]
154}
155
156/**
157 * Opens a node's detail dialog, or shuts the open one.
158 *
159 * The layout is not touched either way. A strip took its rows from the graph,
160 * so opening one had to put the graph into the timeline first — half a diagram
161 * is worse than a list — and pressing a node redrew every other node smaller.
162 * A dialog is over the drawing rather than in it, so the drawing a reader
163 * asked a question about is the one still behind the answer.
164 */
165export function showDetail(view: PaneView, agentId: string | null, fromRun: string | null = null): void {
166 view.selectedId = agentId
167 view.fromRun = fromRun
168 // A call belongs to the agent it was made by. Left open across a move to
169 // another node, the dialog came up on a call the node in the title never
170 // made — or, where the new agent happened to have a call of the same id, on
171 // the wrong one silently.
172 view.openCall = null
173 view.callScroll = 0
174 view.callOutScroll = 0
175}
176
177/** Moves one block of the dialog, as far as the block has anywhere to go. */
178export function scrollDetail(view: PaneView, detail: DetailView | null, pane: number, by: number): void {
179 if (!Number.isInteger(pane)) {
180 return
181 }
182
183 // An opened call has a list of its own and keeps its own place in it. It is
184 // not one of the tabs — the tabs are still there behind it, at the lines the
185 // reader left them on — so it is moved by its own number rather than by an
186 // index into theirs.
187 if (pane === CALL_PANE || pane === CALL_OUT_PANE) {
188 const out = pane === CALL_OUT_PANE
189 const held = out ? detail?.out : detail?.call
190 const end = held ? Math.max(0, held.total - held.visible) : 0
191 const at = out ? view.callOutScroll : view.callScroll
192 const to = Math.max(0, Math.min(end, at + by))
193
194 if (out) {
195 view.callOutScroll = to
196 } else {
197 view.callScroll = to
198 }
199
200 return
201 }
202
203 if (pane < 0) {
204 return
205 }
206
207 const shown = detail?.panes[pane]
208 const last = shown ? Math.max(0, shown.total - shown.visible) : 0
209 const at = view.detailScroll[pane] ?? 0
210
211 view.detailScroll[pane] = Math.max(0, Math.min(last, at + by))
212}
213
214/** A row of the list, and a lane of the graph: one press, one thing more. */
215const BODY_STEP_Y = 3
216const BODY_STEP_X = 8
217// Three rows, like the dialog's rail: a run list is read row by row, and the
218// group a reader was looking at stays on screen across a press.
219const RUNS_STEP = 3
220
221/**
222 * The wheel over the pane, which moves whatever the reader is looking at.
223 *
224 * The pane paints one screenful and keeps its own window, so the engine's own
225 * scroll never has anywhere to go — its tree is exactly as tall as its body.
226 * `ui.scroll` still fires, and this is what answers it: the rows the wheel asked
227 * for, applied to the pane's window instead of the engine's.
228 *
229 * A dialog takes it while one is open, because a dialog is modal here and the
230 * drawing behind it takes no presses either. `by` arrives signed and already
231 * accelerated — a tick at rest is one row, a burst is more — so it is used as
232 * given rather than multiplied up to the arrow's step. An arrow is pressed once
233 * for a deliberate move; a wheel is turned until the reader sees what they want.
234 *
235 * Which way it moves is decided here, because the event does not say. A scroll
236 * carries rows and nothing else — one signed axis, and no shift-wheel on the
237 * terminal to widen it — so `over`, the body row the pointer was on, is what
238 * the sideways move is read from. Two things ask for one: the wheel over the
239 * rail along the foot, which moves what that rail moves, the way a wheel over
240 * any scrollbar does; and a drawing that can only go sideways, which takes the
241 * wheel wherever it is turned. The flow layout is the second case exactly — it
242 * overruns by two hundred columns and by no rows at all — and without the rule
243 * the widest drawing the pane makes would be the one drawing whose wheel did
244 * nothing.
245 *
246 * Sideways, a tick is the arrow's own step rather than the row's. The two are
247 * not the same distance: a cell is about half as wide as it is tall, and two
248 * hundred columns at a column a tick is a reader turning the wheel two hundred
249 * times to reach the end of a run.
250 */
251export function wheel(
252 view: PaneView,
253 context: {
254 detail?: DetailView | null
255 body?: BodyScroll | null
256 foot?: number | null
257 runList?: RunWindow | null
258 },
259 by: number,
260 over?: number,
261): void {
262 if (by === 0) {
263 return
264 }
265
266 // The run list first, because the caller only offers one when the list is
267 // the thing on screen: the menu unrolled under the bar, or the idle pane,
268 // where the list *is* the pane and a wheel that moved the drawing behind it
269 // would move nothing at all.
270 if (context.runList) {
271 scrollRuns(view, context.runList, by)
272
273 return
274 }
275
276 if (view.selectedId) {
277 // A nested run's list is the whole of its dialog and keeps its place under
278 // pane zero. Moved by `detailTab` instead, the wheel wrote into whichever
279 // tab the last agent was left on — a pane the run's dialog does not have —
280 // and the list under the pointer stayed where it was.
281 // An opened call is two compartments with a shelf between them, and the
282 // wheel moves the one the pointer is over. Moved by whichever is bigger, a
283 // reader with the pointer on a three-line command turned the wheel and
284 // watched the answer move instead.
285 const split = context.detail?.split
286 const onArg = typeof split === 'number' && typeof over === 'number' && over < split
287 const pane = view.selectedId.startsWith(RUN_OPEN)
288 ? 0
289 : view.openCall
290 ? onArg || split === undefined
291 ? CALL_PANE
292 : CALL_OUT_PANE
293 : view.detailTab
294
295 scrollDetail(view, context.detail ?? null, pane, by)
296
297 return
298 }
299
300 const body = context.body ?? null
301 const onRail = typeof context.foot === 'number' && over === context.foot
302 const onlySideways = (body?.spanY ?? 0) === 0 && (body?.spanX ?? 0) > 0
303
304 if (onRail || onlySideways) {
305 scrollBody(view, body, by * BODY_STEP_X, 0)
306
307 return
308 }
309
310 scrollBody(view, body, 0, by)
311}
312
313/**
314 * What a paint just decided, taken back into the view.
315 *
316 * Two things: the offset the paint actually drew at, so the next press steps
317 * from what is on the pane rather than from a figure the follow made stale —
318 * and the phase the run is working in, so a *change* of it can be seen.
319 *
320 * The change is what ends a peek. A reader who scrolled back to a phase that
321 * finished is left there while the run gets on with the same phase it was in,
322 * and is taken to the front when the run enters a different one. The other
323 * rules were both worse: snapping back on every tick makes the pane unreadable
324 * while a run is live, and never snapping back needs a *resume* control, which
325 * is a control nobody presses and a pane left showing the past.
326 */
327export function drew(view: PaneView, drawn: { body?: BodyScroll; front?: string }): void {
328 view.bodyScroll = { x: drawn.body?.x ?? 0, y: drawn.body?.y ?? 0 }
329
330 if (drawn.front !== undefined && drawn.front !== view.front) {
331 view.following = true
332 }
333
334 view.front = drawn.front ?? null
335}
336
337/**
338 * Moves the drawing under the body, as far as the drawing has anywhere to go.
339 *
340 * Clamped against what the last paint reported rather than left to run: a
341 * reader who held the down arrow past the end of a run would otherwise have to
342 * press up the same number of times before anything moved.
343 */
344export function scrollBody(view: PaneView, body: BodyScroll | null, dx: number, dy: number): void {
345 const spanX = body?.spanX ?? 0
346 const spanY = body?.spanY ?? 0
347
348 view.bodyScroll = {
349 x: Math.max(0, Math.min(spanX, view.bodyScroll.x + dx)),
350 y: Math.max(0, Math.min(spanY, view.bodyScroll.y + dy)),
351 }
352 // Moving the body by hand is what stops it following. The offset it moves
353 // from is the one the pane holds, which every paint writes back to what it
354 // actually drew — so a first press against a following body steps from what
355 // is on the pane rather than from wherever the reader last left off.
356 view.following = false
357}
358
359/**
360 * Moves the session's run list, as far as the list has anywhere to go.
361 *
362 * Clamped against what the last paint reported, like the body and the dialog's
363 * panes. The offset it steps from is the one the paint drew at rather than the
364 * one the pane holds, so a first press against a list still seated on the run
365 * being drawn moves from what is on screen instead of from the top.
366 */
367export function scrollRuns(view: PaneView, runList: RunWindow | null, by: number): void {
368 if (!runList) {
369 return
370 }
371
372 const most = Math.max(0, runList.total - runList.visible)
373
374 view.runScroll = Math.max(0, Math.min(most, (view.runScroll ?? runList.scroll) + by))
375}
376
377/**
378 * The press, applied.
379 *
380 * `hasRun` is whether there is a drawing under the controls at all: the
381 * settings and the run picker work from the idle view too, and everything else
382 * needs a run to act on.
383 */
384export function applyPress(
385 view: PaneView,
386 pressed: string,
387 context: {
388 hasRun: boolean
389 detail?: DetailView | null
390 body?: BodyScroll | null
391 runList?: RunWindow | null
392 },
393): PressResult {
394 if (pressed === 'runs-up' || pressed === 'runs-down') {
395 scrollRuns(view, context.runList ?? null, pressed === 'runs-up' ? -RUNS_STEP : RUNS_STEP)
396
397 return {}
398 }
399
400 if (pressed === RUN_PICKER) {
401 view.picking = !view.picking
402 // Where the reader had scrolled to belongs to the list that was open, not
403 // to the next one: a list reopened ten rows down, on runs that have since
404 // moved, is a list that lost the run the pane is drawing.
405 view.runScroll = null
406 view.settings = false
407 view.menu = null
408 view.about = false
409
410 return {}
411 }
412
413 if (pressed.startsWith('run:')) {
414 view.picking = false
415 view.runScroll = null
416 view.bodyScroll = { x: 0, y: 0 }
417 view.following = true
418 view.opened = []
419
420 return { run: pressed.slice(4) }
421 }
422
423 // Two things over the drawing at once is one too many: the menu drops from
424 // the bar and the settings dialog sits under it, so whichever opens last
425 // would be read through the other.
426 if (pressed === SETTINGS) {
427 view.settings = !view.settings
428 view.picking = false
429 view.menu = null
430 view.about = false
431
432 return {}
433 }
434
435 if (pressed === SETTINGS_CLOSE) {
436 view.settings = false
437 view.menu = null
438
439 return {}
440 }
441
442 // The two ends of the foot row open onto the same cell of pane, so they take
443 // turns: what the pane is set to and what the pane is are two answers, and a
444 // reader who asked for the second is done with the first.
445 if (pressed === ABOUT) {
446 view.about = !view.about
447 view.settings = false
448 view.picking = false
449 view.menu = null
450
451 return {}
452 }
453
454 if (pressed === ABOUT_CLOSE) {
455 view.about = false
456
457 return {}
458 }
459
460 // A setting says what it is set to and keeps the rest behind that: pressing
461 // the control unrolls its list, and pressing it again rolls the list up. One
462 // list at a time, because two lists open over each other is two lists a
463 // reader has to shut before they can read either.
464 if (pressed.startsWith('open:')) {
465 const want = pressed.slice('open:'.length) as SettingMenu
466
467 if (!SETTING_MENUS.includes(want)) {
468 return {}
469 }
470
471 view.menu = view.menu === want ? null : want
472
473 return {}
474 }
475
476 // Every setting is named with the value it is being set to, so a press says
477 // what it does rather than what it moves on from. A control that cycles can
478 // only be read by pressing it: `Theme: gruvbox` says where you are and gives
479 // no way to ask for `nord` except five more presses past it.
480 if (pressed.startsWith('set:orientation:')) {
481 const want = pressed.slice('set:orientation:'.length) as Orientation
482
483 if (!ORIENTATIONS.includes(want)) {
484 return {}
485 }
486
487 view.orientation = want
488 view.menu = null
489 // A different axis is a different drawing, and a cell offset into the one
490 // it replaced means nothing in it.
491 view.bodyScroll = { x: 0, y: 0 }
492 view.following = true
493
494 return { store: ['orientation'] }
495 }
496
497 if (pressed.startsWith('set:theme:')) {
498 const want = pressed.slice('set:theme:'.length)
499
500 if (!THEMES.some(t => t.name === want)) {
501 return {}
502 }
503
504 view.theme = want
505 view.menu = null
506 useTheme(view.theme)
507
508 return { store: ['theme'] }
509 }
510
511 if (pressed === 'theme') {
512 view.theme = nextTheme(view.theme)
513 useTheme(view.theme)
514
515 return { store: ['theme'] }
516 }
517
518 if (pressed === 'detail-taller' || pressed === 'detail-shorter') {
519 const step = pressed === 'detail-taller' ? 2 : -2
520
521 view.detailRows = Math.max(MIN_DETAIL, Math.min(MAX_DETAIL, view.detailRows + step))
522
523 return { store: ['detailRows'] }
524 }
525
526 if (pressed === 'orientation') {
527 view.orientation = nextOrientation(view.orientation)
528 view.bodyScroll = { x: 0, y: 0 }
529 view.following = true
530
531 return { store: ['orientation'] }
532 }
533
534 if (!context.hasRun) {
535 return {}
536 }
537
538 if (pressed === DETAIL_CLOSE) {
539 showDetail(view, null)
540
541 return {}
542 }
543
544 // A row of the Calls list, opened out. The agent's own dialog stays exactly
545 // as it was — same tab, same line — because the call takes its rectangle
546 // rather than its state, and `◂ Back` puts the reader back on the row.
547 if (pressed.startsWith(CALL_OPEN)) {
548 view.openCall = pressed.slice(CALL_OPEN.length)
549 view.callScroll = 0
550 view.callOutScroll = 0
551
552 return {}
553 }
554
555 if (pressed === CALL_BACK) {
556 view.openCall = null
557 view.callScroll = 0
558 view.callOutScroll = 0
559
560 return {}
561 }
562
563 // A nested run's own name. There is no agent behind it — the row stands for a
564 // whole workflow — so the dialog reads the phase, and every agent in it is a
565 // row of the list.
566 if (pressed.startsWith(RUN_OPEN)) {
567 showDetail(view, view.selectedId === pressed ? null : pressed)
568 view.detailScroll = []
569
570 return {}
571 }
572
573 if (pressed === RUN_BACK) {
574 const back = view.fromRun
575
576 showDetail(view, back === null ? null : `${RUN_OPEN}${back}`)
577 view.detailScroll = []
578
579 return {}
580 }
581
582 // A trip on the dialog's own strip. The tab is left alone — a reader
583 // comparing what two attempts were asked is on the Prompt of both — and the
584 // way back to a nested run's list goes with it, since a strip inside that
585 // reading is not a way out of it.
586 if (pressed.startsWith(PASS_OPEN)) {
587 const agentId = pressed.slice(PASS_OPEN.length)
588
589 showDetail(view, agentId, view.fromRun)
590 view.detailScroll = []
591
592 return { opened: agentId }
593 }
594
595 if (pressed.startsWith(DETAIL_TAB)) {
596 const want = Number(pressed.slice(DETAIL_TAB.length))
597
598 if (Number.isInteger(want) && want >= 0) {
599 view.detailTab = want
600 }
601
602 return {}
603 }
604
605 // A nested run is a whole workflow inside one band of this one. Shut, it is a
606 // row saying how many agents it held; opened, it is the phase every other
607 // phase is. The band keeps its name either way, so the same press folds it
608 // back up.
609 if (pressed.startsWith(BAND_OPEN)) {
610 const phase = pressed.slice(BAND_OPEN.length)
611
612 view.opened = view.opened.includes(phase)
613 ? // Folding the run back takes the steps a reader folded inside it with
614 // it. Left behind, they were state nothing on the pane could see: the
615 // run came back on its next press with three of its steps mysteriously
616 // rolled up, minutes after the reader folded them. A step key is keyed
617 // under the run's own name, which is what makes them findable here.
618 view.opened.filter(open => open !== phase && !open.startsWith(`${phase}\u0000`))
619 : [...view.opened, phase]
620 // The offset stays: unfolding a band adds rows *inside* it, so everything
621 // above it is where it was and the reader is still looking at the band they
622 // pressed. What stops is the following — a reader who opened a nested run
623 // is reading that run, and a pane that pulled them back to the front the
624 // moment the phase changed would close what they had just opened.
625 view.following = false
626
627 return {}
628 }
629
630 if (pressed === 'body-up' || pressed === 'body-down') {
631 scrollBody(view, context.body ?? null, 0, pressed === 'body-up' ? -BODY_STEP_Y : BODY_STEP_Y)
632
633 return {}
634 }
635
636 if (pressed === 'body-left' || pressed === 'body-right') {
637 scrollBody(view, context.body ?? null, pressed === 'body-left' ? -BODY_STEP_X : BODY_STEP_X, 0)
638
639 return {}
640 }
641
642 if (pressed.startsWith('detail-up:') || pressed.startsWith('detail-down:')) {
643 const pane = Number(pressed.slice(pressed.indexOf(':') + 1))
644
645 scrollDetail(view, context.detail ?? null, pane, pressed.startsWith('detail-up') ? -3 : 3)
646
647 return {}
648 }
649
650 // Pressed from inside a nested run's list, the agent remembers the run: the
651 // list is the only place in the pane an agent can be reached from that the
652 // reader cannot see behind the dialog, so it is the only one worth a way back.
653 const from = view.selectedId?.startsWith(RUN_OPEN) === true ? view.selectedId.slice(RUN_OPEN.length) : null
654
655 showDetail(view, view.selectedId === pressed ? null : pressed, from)
656 view.detailScroll = []
657
658 return view.selectedId ? { opened: view.selectedId } : {}
659}
660hooks/theme.ts 442 lines1/**
2 * The palettes the pane can be drawn in.
3 *
4 * A theme is declared as the ten colours a terminal theme actually ships — a
5 * ground, a surface, a foreground, a grey, and the six hues — and the palette
6 * the drawing uses is derived from those. Declaring the derived roles per theme
7 * would be sixty values to keep in step, and the first time one of them drifted
8 * the pane would read as a different theme in one corner.
9 */
10
11import { mix, rgb, type Rgb } from './canvas'
12
13export type Theme = {
14 name: string
15 /** What the pane is painted on. */
16 bg: Rgb
17 /** One step up from the ground: rules, empty tracks, a card's far edge. */
18 surface: Rgb
19 fg: Rgb
20 grey: Rgb
21 blue: Rgb
22 green: Rgb
23 yellow: Rgb
24 red: Rgb
25 purple: Rgb
26 cyan: Rgb
27}
28
29/** The roles the drawing asks for, by what each one says rather than by hue. */
30export type Palette = {
31 /** An agent that is still going. */
32 running: Rgb
33 /** One that landed. */
34 done: Rgb
35 /** One that did not. */
36 failed: Rgb
37 /** One the run was killed out from under: cut off, not broken. */
38 stopped: Rgb
39 /** One that never started. */
40 idle: Rgb
41 /** Text that is there to be looked past. */
42 dim: Rgb
43 text: Rgb
44 accent: Rgb
45 /** The ground of an empty meter, and of a rule. */
46 track: Rgb
47 /** How long it took. */
48 clock: Rgb
49 /** What it spent. */
50 spend: Rgb
51 /** What it ran on. */
52 model: Rgb
53 /**
54 * What one agent feeding another is drawn in.
55 *
56 * A connector used to take the colours of the two agents it joined, which put
57 * it in the same hue as the frames it ran between and made the two read as
58 * one mark. What an edge has to say is that it is an edge; which agents it
59 * joins, and how they ended, is said by the cards at its ends.
60 *
61 * It is the one line on the pane with a hue of its own. Rules, borders and
62 * empty tracks are grey, so a line that carries work is told from a line that
63 * divides the drawing by colour alone, before either is traced.
64 */
65 wire: Rgb
66}
67
68export const THEMES: Theme[] = [
69 {
70 name: 'tokyo-night',
71 bg: rgb(0x15, 0x18, 0x1e),
72 surface: rgb(0x2e, 0x34, 0x40),
73 fg: rgb(0xdf, 0xe3, 0xea),
74 grey: rgb(0x7b, 0x84, 0x92),
75 blue: rgb(0x7a, 0xa2, 0xf7),
76 green: rgb(0x4c, 0xc3, 0x8a),
77 yellow: rgb(0xf2, 0xb3, 0x3d),
78 red: rgb(0xf2, 0x63, 0x5f),
79 purple: rgb(0xa8, 0x8f, 0xd6),
80 cyan: rgb(0x5f, 0xa8, 0xb8),
81 },
82 {
83 name: 'catppuccin',
84 bg: rgb(0x1e, 0x1e, 0x2e),
85 surface: rgb(0x31, 0x32, 0x44),
86 fg: rgb(0xcd, 0xd6, 0xf4),
87 grey: rgb(0x7f, 0x84, 0x9c),
88 blue: rgb(0x89, 0xb4, 0xfa),
89 green: rgb(0xa6, 0xe3, 0xa1),
90 yellow: rgb(0xf9, 0xe2, 0xaf),
91 red: rgb(0xf3, 0x8b, 0xa8),
92 purple: rgb(0xcb, 0xa6, 0xf7),
93 cyan: rgb(0x94, 0xe2, 0xd5),
94 },
95 {
96 name: 'gruvbox',
97 bg: rgb(0x28, 0x28, 0x28),
98 surface: rgb(0x3c, 0x38, 0x36),
99 fg: rgb(0xeb, 0xdb, 0xb2),
100 grey: rgb(0x92, 0x83, 0x74),
101 blue: rgb(0x83, 0xa5, 0x98),
102 green: rgb(0xb8, 0xbb, 0x26),
103 yellow: rgb(0xfa, 0xbd, 0x2f),
104 red: rgb(0xfb, 0x49, 0x34),
105 purple: rgb(0xd3, 0x86, 0x9b),
106 cyan: rgb(0x8e, 0xc0, 0x7c),
107 },
108 {
109 name: 'nord',
110 bg: rgb(0x2e, 0x34, 0x40),
111 surface: rgb(0x3b, 0x42, 0x52),
112 fg: rgb(0xe5, 0xe9, 0xf0),
113 grey: rgb(0x7b, 0x88, 0x9c),
114 blue: rgb(0x88, 0xc0, 0xd0),
115 green: rgb(0xa3, 0xbe, 0x8c),
116 yellow: rgb(0xeb, 0xcb, 0x8b),
117 red: rgb(0xbf, 0x61, 0x6a),
118 purple: rgb(0xb4, 0x8e, 0xad),
119 cyan: rgb(0x8f, 0xbc, 0xbb),
120 },
121 {
122 name: 'dracula',
123 bg: rgb(0x28, 0x2a, 0x36),
124 surface: rgb(0x44, 0x47, 0x5a),
125 fg: rgb(0xf8, 0xf8, 0xf2),
126 grey: rgb(0x62, 0x72, 0xa4),
127 blue: rgb(0xbd, 0x93, 0xf9),
128 green: rgb(0x50, 0xfa, 0x7b),
129 yellow: rgb(0xf1, 0xfa, 0x8c),
130 red: rgb(0xff, 0x55, 0x55),
131 purple: rgb(0xff, 0x79, 0xc6),
132 cyan: rgb(0x8b, 0xe9, 0xfd),
133 },
134 {
135 name: 'solarized',
136 bg: rgb(0x00, 0x2b, 0x36),
137 surface: rgb(0x07, 0x36, 0x42),
138 fg: rgb(0x93, 0xa1, 0xa1),
139 grey: rgb(0x58, 0x6e, 0x75),
140 blue: rgb(0x26, 0x8b, 0xd2),
141 green: rgb(0x85, 0x99, 0x00),
142 yellow: rgb(0xb5, 0x89, 0x00),
143 red: rgb(0xdc, 0x32, 0x2f),
144 purple: rgb(0x6c, 0x71, 0xc4),
145 cyan: rgb(0x2a, 0xa1, 0x98),
146 },
147 {
148 name: 'monokai',
149 bg: rgb(0x27, 0x28, 0x22),
150 surface: rgb(0x3e, 0x3d, 0x32),
151 fg: rgb(0xf8, 0xf8, 0xf2),
152 grey: rgb(0x75, 0x71, 0x5e),
153 blue: rgb(0x66, 0xd9, 0xef),
154 green: rgb(0xa6, 0xe2, 0x2e),
155 yellow: rgb(0xfd, 0x97, 0x1f),
156 red: rgb(0xf9, 0x26, 0x72),
157 purple: rgb(0xae, 0x81, 0xff),
158 cyan: rgb(0xa1, 0xef, 0xe4),
159 },
160 {
161 name: 'vscode-dark',
162 bg: rgb(0x1e, 0x1e, 0x1e),
163 surface: rgb(0x2d, 0x2d, 0x30),
164 fg: rgb(0xd4, 0xd4, 0xd4),
165 grey: rgb(0x80, 0x80, 0x80),
166 blue: rgb(0x3b, 0x8e, 0xea),
167 green: rgb(0x23, 0xd1, 0x8b),
168 yellow: rgb(0xf5, 0xf5, 0x43),
169 red: rgb(0xf1, 0x4c, 0x4c),
170 purple: rgb(0xd6, 0x70, 0xd6),
171 cyan: rgb(0x29, 0xb8, 0xdb),
172 },
173 // What insiderone.com is actually set in: near-black type colour `#261a28`,
174 // the warm off-white `#efebe4` it sets that type on rather than plain white,
175 // and orange as the one accent — the `#f4482b` glow behind its cards and the
176 // `#ff6900` of its calls to action.
177 //
178 // The `--blue` and `--navyblue` custom properties the page also declares are
179 // Bootstrap's own defaults carried through the theme, not the brand: nothing
180 // on the page is drawn in either. Read off the declarations rather than off
181 // what the page uses them for, this theme came out indigo on navy, which is
182 // not a palette anyone would recognise as this site's.
183 //
184 // Orange crowds three of the pane's roles at once — the accent, the running
185 // tone and the failed tone are all warm — so the accent keeps the brand's
186 // orange and the other two are pushed apart from it: gold for running, a true
187 // red for failed. A drawing where *this agent is running* and *this one broke*
188 // are two shades of the same orange says neither.
189 {
190 name: 'insider-one',
191 bg: rgb(0x26, 0x1a, 0x28),
192 surface: rgb(0x3b, 0x2b, 0x3d),
193 fg: rgb(0xef, 0xeb, 0xe4),
194 grey: rgb(0x8d, 0x83, 0x91),
195 blue: rgb(0xff, 0x69, 0x00),
196 green: rgb(0x28, 0xa7, 0x45),
197 yellow: rgb(0xfc, 0xb9, 0x00),
198 red: rgb(0xdc, 0x35, 0x45),
199 purple: rgb(0x6f, 0x42, 0xc1),
200 cyan: rgb(0x4e, 0xdc, 0xce),
201 },
202 // Light grounds. Every role is pulled toward the foreground until it clears
203 // the ground it is drawn on, and on these the foreground is the dark one —
204 // so a theme's own bright colours come out darkened here rather than washed
205 // out, and `paletteOf` needs no case for which way round the pane is.
206 {
207 name: 'github-light',
208 bg: rgb(0xff, 0xff, 0xff),
209 surface: rgb(0xd8, 0xde, 0xe4),
210 fg: rgb(0x1f, 0x23, 0x28),
211 grey: rgb(0x65, 0x6d, 0x76),
212 blue: rgb(0x09, 0x69, 0xda),
213 green: rgb(0x1a, 0x7f, 0x37),
214 yellow: rgb(0x9a, 0x67, 0x00),
215 red: rgb(0xcf, 0x22, 0x2e),
216 purple: rgb(0x82, 0x50, 0xdf),
217 cyan: rgb(0x11, 0x77, 0x79),
218 },
219 {
220 name: 'solarized-light',
221 bg: rgb(0xfd, 0xf6, 0xe3),
222 surface: rgb(0xee, 0xe8, 0xd5),
223 fg: rgb(0x58, 0x6e, 0x75),
224 grey: rgb(0x93, 0xa1, 0xa1),
225 blue: rgb(0x26, 0x8b, 0xd2),
226 green: rgb(0x85, 0x99, 0x00),
227 yellow: rgb(0xb5, 0x89, 0x00),
228 red: rgb(0xdc, 0x32, 0x2f),
229 purple: rgb(0x6c, 0x71, 0xc4),
230 cyan: rgb(0x2a, 0xa1, 0x98),
231 },
232 // The same brand tokens the other way up: their warm off-white as the ground
233 // and the plum they set their dark type in as the foreground.
234 // The site's own pairing, the way the site itself uses it: the warm off-white
235 // as the ground, the near-black as the type, and orange as the one accent.
236 {
237 name: 'insider-one-light',
238 bg: rgb(0xef, 0xeb, 0xe4),
239 surface: rgb(0xde, 0xd7, 0xcc),
240 fg: rgb(0x26, 0x1a, 0x28),
241 grey: rgb(0x6b, 0x64, 0x70),
242 blue: rgb(0xd2, 0x41, 0x0a),
243 green: rgb(0x1f, 0x7a, 0x3d),
244 yellow: rgb(0x8a, 0x5a, 0x00),
245 red: rgb(0xc1, 0x27, 0x2d),
246 purple: rgb(0x6f, 0x42, 0xc1),
247 cyan: rgb(0x00, 0x80, 0x7a),
248 },
249]
250
251export const DEFAULT_THEME = THEMES[0].name
252
253export function themeOf(name: string | undefined): Theme {
254 return THEMES.find(t => t.name === name) ?? THEMES[0]
255}
256
257/** The name after this one, so one control can walk the list. */
258export function nextTheme(name: string): string {
259 const at = THEMES.findIndex(t => t.name === name)
260
261 return THEMES[(at + 1 + THEMES.length) % THEMES.length].name
262}
263
264/**
265 * How far apart two colours are, by the ratio WCAG defines.
266 *
267 * The only measure of legibility anyone can check the pane against: 4.5 is the
268 * bar for text, 3.0 for a line or a glyph. A terminal theme is not designed
269 * against it — several of the ones here put their own grey under 3.0 on their
270 * own ground — so the pane measures what it derives and lifts what falls short.
271 */
272function apart(a: Rgb, b: Rgb): number {
273 const channel = (v: number) => {
274 const s = v / 255
275
276 return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
277 }
278 const light = (c: Rgb) =>
279 0.2126 * channel((c >> 16) & 0xff) + 0.7152 * channel((c >> 8) & 0xff) + 0.0722 * channel(c & 0xff)
280 const [hi, lo] = [light(a), light(b)].sort((x, y) => y - x)
281
282 return (hi + 0.05) / (lo + 0.05)
283}
284
285/**
286 * A role pulled away from the ground until it clears it.
287 *
288 * Away from the ground rather than toward the foreground, and one small step at
289 * a time, so a colour that already reads keeps its hue exactly and one that
290 * does not gives up the least it can. Toward the foreground was the rule once,
291 * and on a light theme the foreground is a dark blue-grey: solarized's yellow
292 * mixed with it cleared the bar as `#827a40`, which is olive. A reader who
293 * picked a palette by name should get that palette's hues, lighter or darker
294 * where the theme itself is not legible, not a second palette derived from its
295 * text colour.
296 *
297 * Which way is away is read off the ground: black on a light one, white on a
298 * dark one, so the same rule serves both without a special case.
299 */
300function readable(color: Rgb, bg: Rgb, floor: number): Rgb {
301 const white = rgb(0xff, 0xff, 0xff)
302 const black = rgb(0, 0, 0)
303 const away = apart(white, bg) >= apart(black, bg) ? white : black
304 let lifted = color
305
306 for (let step = 1; step <= 20 && apart(lifted, bg) < floor; step++) {
307 lifted = mix(color, away, step / 20)
308 }
309
310 return lifted
311}
312
313/**
314 * The floor every role but one has to clear.
315 *
316 * 3.0 rather than 4.5: the pane is a drawing, its text is a glyph at a time
317 * against a fixed ground, and the roles under this bar are the ones a reader
318 * cannot see at all rather than the ones they have to look at twice. Held to
319 * 4.5 the quiet roles stop being quiet, and the layer that is meant to be read
320 * past starts competing with the layer that is meant to be read.
321 */
322const FLOOR = 3
323
324/**
325 * The floor for the roles that are read rather than looked past.
326 *
327 * Four states, three figures and the accent: what a node's rule says happened,
328 * what the run cost, what it ran on, and which node the reader picked. These
329 * are the answers a reader opened the pane for, and a couple of the palettes
330 * put them barely over the graphics bar — solarized drew `done` and `running`
331 * at 3.04 and 3.06 on its light ground, two figures a reader has to lean in
332 * for. A point of ratio over the bar costs those palettes a little of their
333 * softness and buys back the facts.
334 *
335 * Not 4.5: the roles are glyphs and short figures against one fixed ground,
336 * not paragraphs, and 4.5 pulls a muted palette so far toward its own text
337 * colour that `done` and `model` stop being green and cyan.
338 */
339const READ = 4
340
341/**
342 * How far the edges have to stand off the state tones, in degrees of hue.
343 *
344 * Ten of the twelve palettes hold their blue more than 55 degrees off the
345 * nearest of green, gold and red, and the two that do not are not close: the
346 * Insider palettes set their accent to the brand's orange, which lands 19
347 * degrees from their own gold and their own red. So the bar is drawn where the
348 * gap actually is, between 45 and 55, and no palette moves except the two.
349 */
350const APART_H = 45
351
352/** Where a colour sits on the wheel, or -1 for a grey, which has no angle. */
353function hueOf(color: Rgb): number {
354 const [r, g, b] = [(color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff].map(c => c / 255) as [
355 number,
356 number,
357 number,
358 ]
359 const top = Math.max(r, g, b)
360 const span = top - Math.min(r, g, b)
361
362 if (span === 0) {
363 return -1
364 }
365
366 const sixth = top === r ? ((g - b) / span) % 6 : top === g ? (b - r) / span + 2 : (r - g) / span + 4
367
368 return (sixth * 60 + 360) % 360
369}
370
371/** The shorter way round the wheel between two colours. A grey is far from everything. */
372function turn(a: Rgb, b: Rgb): number {
373 const [x, y] = [hueOf(a), hueOf(b)]
374
375 if (x < 0 || y < 0) {
376 return 180
377 }
378
379 const gap = Math.abs(x - y) % 360
380
381 return gap > 180 ? 360 - gap : gap
382}
383
384/**
385 * The hue the edges are drawn in.
386 *
387 * An edge is not a state. What it has to say is that two agents are joined, and
388 * a reader who has to decide whether an orange line means *connected* or means
389 * *this one is running* is reading the drawing twice. Normally the theme's blue
390 * is nowhere near its greens, golds and reds and the question never comes up;
391 * where a palette spends its accent on a warm brand colour, the edges take
392 * whichever of the two hues left stands furthest from every state instead.
393 */
394function edgeOf(theme: Theme): Rgb {
395 const states = [theme.green, theme.yellow, theme.red]
396 const clearance = (color: Rgb) => Math.min(...states.map(state => turn(color, state)))
397
398 if (clearance(theme.blue) >= APART_H) {
399 return theme.blue
400 }
401
402 return clearance(theme.purple) >= clearance(theme.cyan) ? theme.purple : theme.cyan
403}
404
405export function paletteOf(theme: Theme): Palette {
406 const lift = (color: Rgb, floor = FLOOR) => readable(color, theme.bg, floor)
407
408 return {
409 running: lift(theme.yellow, READ),
410 done: lift(theme.green, READ),
411 failed: lift(theme.red, READ),
412 // Cut off is neutral, not a failure: an agent the run never let finish did
413 // nothing wrong, and a pane that draws it in a red says two different
414 // things in one hue. It is the grey carried toward the text tone rather
415 // than the plain grey, which keeps it clear of the boundary the phases and
416 // the card rules are drawn in — that tone sits near the ground, and this
417 // one sits near the words, so the two are never read as each other.
418 stopped: lift(mix(theme.grey, theme.fg, 0.3), READ),
419 // Between the ground and the grey: a state that says nothing happened
420 // should sit under the text that says what did. It stays clear of the tone
421 // the borders and rules are drawn in, though — a node a reader has to look
422 // twice at to tell from the boundary beside it is a node drawn wrong.
423 idle: lift(mix(theme.grey, theme.surface, 0.2)),
424 dim: lift(theme.grey),
425 text: lift(theme.fg, READ),
426 accent: lift(theme.blue, READ),
427 // The one role that is not lifted. A track is the ground of an empty meter
428 // and the tone of a rule: what it has to say is that nothing is there, and
429 // a track a reader can see clearly is a meter that looks half full when it
430 // is empty.
431 track: theme.surface,
432 clock: lift(mix(theme.fg, theme.grey, 0.6), READ),
433 spend: lift(theme.purple, READ),
434 model: lift(theme.cyan, READ),
435 wire: lift(mix(edgeOf(theme), theme.grey, 0.2)),
436 }
437}
438
439export function hexOf(color: Rgb): string {
440 return `#${(color & 0xffffff).toString(16).padStart(6, '0')}`
441}
442hooks/tree.ts 275 lines1/**
2 * Draws the canvas as elements, and makes its nodes clickable.
3 *
4 * `Raster` — a cell buffer with a code point and two colours per cell — landed
5 * in Claude Code 2.1.271. On a build without it the same canvas is drawn as one
6 * Box per row holding a Text per run of same-coloured cells: more elements, and
7 * redrawn through `ui.invalidate` rather than `$.ui.blit`, but the same picture.
8 *
9 * The rows are also where a node becomes clickable. A hotspot names the cells a
10 * node's label occupies, and the span covering it is emitted as a Button of the
11 * same width instead of a Text, keyed by the agent's id — so the grid keeps its
12 * alignment and a press names which node was pressed.
13 */
14
15import { Canvas, cellColor, DEFAULT_COLOR, type Rgb } from './canvas'
16import type { Hotspot } from './paint'
17
18export type Segment = {
19 text: string
20 fg: Rgb
21 bg: Rgb
22 /** Set when these cells are a node's label, and so a Button. */
23 agentId?: string
24}
25
26/** One element constructor of a surface's table, read loosely. */
27type Element = (props: Record<string, unknown>) => unknown
28
29/**
30 * One color as an element takes it, rounded to what a Raster would draw it as:
31 * the two kinds of row are drawn by different parts of the surface, and a
32 * ground that differs by a few values between them bands the pane.
33 */
34function hex(value: Rgb): string | undefined {
35 if (value === DEFAULT_COLOR) {
36 return undefined
37 }
38
39 return `#${cellColor(value).toString(16).padStart(6, '0')}`
40}
41
42/**
43 * One row as runs of same-coloured cells, with the trailing default-coloured
44 * blanks dropped: a row of spaces is an empty array, and the row still takes its
45 * line because the Box that holds it is there.
46 *
47 * A run also breaks at each hotspot edge, so a label's cells end up in a segment
48 * of their own — and only at those edges, since a hotspot becomes one Button and
49 * a Button carries no colour. Splitting on a colour change inside one would give
50 * the row several Buttons under the same key with nothing to tell them apart.
51 */
52export function segmentsOf(canvas: Canvas, y: number, hotspots: Hotspot[] = []): Segment[] {
53 const onThisRow = hotspots.filter(h => h.y === y && h.w > 0)
54 const breaks = new Set<number>()
55
56 for (const spot of onThisRow) {
57 breaks.add(spot.x)
58 breaks.add(spot.x + spot.w)
59 }
60
61 const ownerAt = (x: number) => onThisRow.find(h => x >= h.x && x < h.x + h.w)?.agentId
62
63 const segments: Segment[] = []
64 let current: Segment | null = null
65 let lastInked = -1
66
67 for (let x = 0; x < canvas.columns; x++) {
68 const cell = canvas.cell(x, y)
69 const char = String.fromCodePoint(cell.code || 0x20)
70 // A space on the canvas's own ground paints nothing the row's Box has not
71 // already painted, so it is blank for trimming: every cell carries a
72 // background, and without this every row ran the full width in elements the
73 // surface then had to draw.
74 const isBlank =
75 (cell.code === 0x20 || cell.code === 0) &&
76 (cell.bg === DEFAULT_COLOR || cell.bg === canvas.background)
77 const owner = ownerAt(x)
78
79 if (
80 current &&
81 !breaks.has(x) &&
82 current.agentId === owner &&
83 (owner !== undefined || (current.fg === cell.fg && current.bg === cell.bg))
84 ) {
85 current.text += char
86 } else {
87 if (current) {
88 segments.push(current)
89 }
90
91 current = { text: char, fg: cell.fg, bg: cell.bg, agentId: owner }
92 }
93
94 // `current` is pushed at index `segments.length`, so that is the index this
95 // cell ends up in — known only once the run it joins is settled.
96 if (!isBlank) {
97 lastInked = segments.length
98 }
99 }
100
101 if (current) {
102 segments.push(current)
103 }
104
105 const kept = lastInked < 0 ? [] : segments.slice(0, lastInked + 1)
106
107 return kept.filter(s => s.text.length > 0)
108}
109
110export type RowElements = {
111 Box: Element
112 Text: Element
113 /** Absent on a surface without buttons; nodes then draw as plain text. */
114 Button?: Element
115 /** Present from 2.1.271; the rows that hold no node label are drawn with it. */
116 Raster?: Element
117}
118
119/**
120 * The canvas as a drawing whose node labels can be pressed.
121 *
122 * `Raster` is a leaf — "no children, `hover` or `onPress`" — so a pane drawn as
123 * one Raster has nothing to click: the picture is cells, and a cell is not an
124 * element. Nor can a Button be laid over it, since the surface's boxes are a
125 * flex column with no way to overlap.
126 *
127 * So the canvas is cut into bands at the rows that carry a node's label. Those
128 * rows are drawn as elements, where the label's cells become a Button; every
129 * other row — the frames, the edges, the header, the detail dialog, which is
130 * most of the picture — stays in a Raster and costs one element per band.
131 *
132 * Each Raster is keyed by the row it starts at, so a blit can still name one.
133 */
134export type Band = {
135 /** The Raster's key, or undefined for a band drawn as pressable rows. */
136 key?: string
137 from: number
138 rows: number
139}
140
141/**
142 * The bands a drawing is cut into: runs of Raster rows, split at every row that
143 * carries a node's label.
144 *
145 * The ticker blits the Raster bands between renders, so it needs the same split
146 * the last render used — hence a function of its own rather than a local in the
147 * tree builder.
148 */
149export function bandsOf(rows: number, hotspots: Hotspot[]): Band[] {
150 const pressable = [...new Set(hotspots.map(h => h.y))].filter(y => y >= 0 && y < rows).sort((a, b) => a - b)
151 const bands: Band[] = []
152 let from = 0
153
154 for (const y of pressable) {
155 if (y > from) {
156 bands.push({ key: `dag:${from}`, from, rows: y - from })
157 }
158
159 bands.push({ from: y, rows: 1 })
160 from = y + 1
161 }
162
163 if (from < rows) {
164 bands.push({ key: `dag:${from}`, from, rows: rows - from })
165 }
166
167 return bands
168}
169
170export type Picture = {
171 /** The drawing, as the surface's own elements. */
172 children: unknown[]
173 /** How it was cut up, so a later frame can blit the same Rasters by key. */
174 bands: Band[]
175}
176
177export function pictureOf(
178 canvas: Canvas,
179 elements: RowElements,
180 hotspots: Hotspot[] = [],
181 onNode?: (agentId: string) => void,
182): Picture {
183 const { Raster } = elements
184
185 if (!Raster) {
186 return { children: rowsOf(canvas, elements, hotspots, onNode), bands: [] }
187 }
188
189 const raster = (from: number, rows: number, key: string) =>
190 Raster({ key, columns: canvas.columns, rows, cells: canvas.encode(from, rows) })
191
192 // Nothing to press: the whole drawing is one Raster, which is both the
193 // cheapest tree and the one a build before 2.1.271 never had.
194 if (!elements.Button || !onNode || hotspots.length === 0) {
195 return { children: [raster(0, canvas.rows, 'dag')], bands: [{ key: 'dag', from: 0, rows: canvas.rows }] }
196 }
197
198 const bands = bandsOf(canvas.rows, hotspots)
199
200 return {
201 children: bands.flatMap(band =>
202 band.key
203 ? [raster(band.from, band.rows, band.key)]
204 : rowsOf(canvas, elements, hotspots, onNode, band.from, band.from + band.rows),
205 ),
206 bands,
207 }
208}
209
210/**
211 * The canvas as a column of rows.
212 *
213 * @param canvas what to draw
214 * @param elements the surface's Box, Text and (for clickable nodes) Button
215 * @param hotspots the label spans `paint` reported
216 * @param onNode what a press on a node calls, with that node's agent id
217 */
218export function rowsOf(
219 canvas: Canvas,
220 elements: RowElements,
221 hotspots: Hotspot[] = [],
222 onNode?: (agentId: string) => void,
223 fromRow = 0,
224 toRow = canvas.rows,
225): unknown[] {
226 const { Box, Text, Button } = elements
227 const rows: unknown[] = []
228
229 // The row carries the canvas ground, not just the cells: a Button takes no
230 // background of its own, so a node's label would otherwise show the terminal
231 // through the pane's ground while the cells around it sat on it.
232 const ground = hex(canvas.background)
233
234 for (let y = fromRow; y < toRow; y++) {
235 const segments = segmentsOf(canvas, y, hotspots)
236
237 rows.push(
238 Box({
239 flexDirection: 'row',
240 // As wide as the drawing, not as wide as the seat. A row left to itself
241 // stretches to the pane's own width, which is the width the surface
242 // gives the body rather than the width the canvas was made at — so the
243 // rows carrying a node's label ran their ground a few cells past every
244 // Raster beside them, and the drawing had a step down its right edge on
245 // those rows alone.
246 width: canvas.columns,
247 ...(ground ? { backgroundColor: ground } : {}),
248 children: segments.map(s => {
249 if (s.agentId && Button && onNode) {
250 const agentId = s.agentId
251
252 return Button({
253 // The key names the element, so a `ui.press` hook can tell which
254 // node was pressed without keeping a map of paths.
255 key: `node:${agentId}`,
256 label: s.text,
257 plain: true,
258 onPress: () => onNode(agentId),
259 })
260 }
261
262 return Text({
263 color: hex(s.fg),
264 backgroundColor: hex(s.bg),
265 wrap: 'truncate-end',
266 children: s.text,
267 })
268 }),
269 }),
270 )
271 }
272
273 return rows
274}
275hooks/shape.ts 1273 lines1/**
2 * What the pane knows about a run's *shape* — which agent feeds which.
3 *
4 * Nothing records a workflow's edges. The journal names an agent's label and
5 * phase and nothing else, and the run summary adds numbers, not dependencies.
6 * So the graph is derived, and it is drawn in two registers so the difference
7 * stays visible:
8 *
9 * - a **barrier** between consecutive phases, which is a fact: `phase('Merge')`
10 * runs after everything the script awaited in `Collect`, and every agent of
11 * the later phase is downstream of every agent of the earlier one;
12 * - a **carry**, an inferred one-to-one edge across that barrier, read off the
13 * labels of a `pipeline()` (`review:bugs` feeding `verify:bugs`). Drawn
14 * dimmed and dashed, because a label convention is a hint, not a record.
15 */
16
17import { modelName, type AgentRow, type RunState } from './journal'
18
19export type Carry = {
20 fromId: string
21 toId: string
22 /** True when the text itself proves it: drawn solid rather than dashed. */
23 confirmed?: boolean
24}
25
26/**
27 * Cells of an answer long enough that finding them in a prompt is evidence and
28 * not coincidence. Two sentences of boilerplate can collide; forty-eight
29 * characters of one agent's actual output do not.
30 */
31const WINDOW = 48
32
33function flattened(s: string | undefined): string {
34 return (s ?? '').replace(/\s+/g, ' ').trim()
35}
36
37/**
38 * Edges read off the text rather than off the names: a workflow passes a result
39 * along by putting it in the next agent's prompt, so an agent whose answer
40 * appears verbatim in another's prompt *did* feed it. That is a record, not a
41 * convention, which is why these draw solid.
42 *
43 * Both sides come from the run summary the pane already reads — `promptPreview`
44 * and `resultPreview` — so this costs no file the pane was not opening anyway.
45 * They are capped around four hundred characters, so a prompt that quotes its
46 * source late (an agent joining five others' work) may hide the overlap past
47 * the cap and go unfound. Unfound is the safe way to be wrong here: the label
48 * carry is still drawn, dashed, and says the same thing more weakly.
49 */
50export function flowsOf(run: RunState): Carry[] {
51 const answers = run.agents
52 .map(a => ({ agent: a, text: flattened(a.result ?? a.resultPreview) }))
53 .filter(a => a.text.length >= WINDOW)
54
55 if (answers.length === 0) {
56 return []
57 }
58
59 const flows: Carry[] = []
60
61 for (const target of run.agents) {
62 const prompt = flattened(target.prompt)
63
64 if (prompt.length < WINDOW) {
65 continue
66 }
67
68 for (const { agent, text } of answers) {
69 // Nothing feeds itself, and nothing that started later can have.
70 if (agent.agentId === target.agentId || agent.startedMs > target.startedMs) {
71 continue
72 }
73
74 // Windows along the answer, not just its opening: a prompt often quotes
75 // one part of what it was given — the weakness out of a critique, not the
76 // score in front of it — so a match can sit anywhere in the text.
77 let carried = false
78
79 for (let at = 0; at + WINDOW <= text.length && !carried; at += 24) {
80 carried = prompt.includes(text.slice(at, at + WINDOW))
81 }
82
83 if (carried) {
84 flows.push({ fromId: agent.agentId, toId: target.agentId, confirmed: true })
85 }
86 }
87 }
88
89 return flows
90}
91
92/**
93 * Every edge the pane will draw: the ones the text proves, then the ones the
94 * labels suggest for the pairs it did not.
95 */
96/**
97 * The last answer `edgesOf` gave, against the run and the state it gave it for.
98 *
99 * Matching every answer against every prompt is cheap once and wasteful sixty
100 * times a second, and the graph asks for it twice a frame — once to order the
101 * lanes, once to draw them. The stamp changes whenever an agent arrives or its
102 * text grows, which is exactly when the edges can have changed.
103 */
104let memo: { run: RunState; stamp: string; edges: Carry[] } | null = null
105
106function stampOf(run: RunState): string {
107 let stamp = ''
108
109 for (const a of run.agents) {
110 stamp += `${a.agentId}:${(a.result ?? a.resultPreview ?? '').length}:${(a.prompt ?? '').length};`
111 }
112
113 return stamp
114}
115
116export function edgesOf(run: RunState): Carry[] {
117 const stamp = stampOf(run)
118
119 if (memo && memo.run === run && memo.stamp === stamp) {
120 return memo.edges
121 }
122
123 const edges = computeEdges(run)
124
125 memo = { run, stamp, edges }
126
127 return edges
128}
129
130function computeEdges(run: RunState): Carry[] {
131 const flows = flowsOf(run)
132 // Only the same pair is a duplicate. An agent fed by two others really has
133 // two edges — a revision takes both the draft and the critique of it — and
134 // dropping the second because the first was proven would hide a dependency,
135 // as well as the ordering signal the lane sort reads off it.
136 const proven = new Set(flows.map(f => `${f.fromId}>${f.toId}`))
137 const found = [...flows, ...carriesOf(run).filter(c => !proven.has(`${c.fromId}>${c.toId}`))]
138 const drawn = new Set(found.map(e => `${e.fromId}>${e.toId}`))
139
140 return thinned([...found, ...repeatsOf(run, found).filter(e => !drawn.has(`${e.fromId}>${e.toId}`))])
141}
142
143/**
144 * The hops a loop makes inside one phase.
145 *
146 * A phase that keeps going at one piece of work until a pass comes back empty
147 * spawns an agent per pass, and every one of them traces back to the same
148 * source in the phase before. Read literally that is a fan-out: one node with
149 * three lines leaving it, two of which have to cross the columns between. Read
150 * as what it is — a loop — it is a chain, where each pass is fed by the one
151 * before it and only the first is fed from outside. Drawn that way the crossing
152 * lines become a step between neighbours, and the reduction drops the bare
153 * edges the chain now accounts for.
154 *
155 * Passes that overlap in time are not a loop. Three lenses opened at once are a
156 * fan-out and stay one, since nothing the second did could have been asked for
157 * by the first.
158 */
159function repeatsOf(run: RunState, edges: Carry[]): Carry[] {
160 const lanes = lanesOf(run)
161 const hops: Carry[] = []
162
163 for (let i = 1; i < lanes.length; i++) {
164 const above = new Set(lanes[i - 1].agents.map(a => a.agentId))
165 const here = new Map(lanes[i].agents.map(a => [a.agentId, a]))
166 const groups = new Map<string, AgentRow[]>()
167
168 for (const e of edges) {
169 const target = here.get(e.toId)
170
171 if (!above.has(e.fromId) || target === undefined) {
172 continue
173 }
174
175 groups.set(e.fromId, [...(groups.get(e.fromId) ?? []), target])
176 }
177
178 for (const group of groups.values()) {
179 const order = [...new Set(group)].sort((a, b) => a.startedMs - b.startedMs)
180
181 if (order.length < 2 || !inSequence(order)) {
182 continue
183 }
184
185 for (let pass = 1; pass < order.length; pass++) {
186 hops.push({ fromId: order[pass - 1].agentId, toId: order[pass].agentId })
187 }
188 }
189 }
190
191 return hops
192}
193
194/**
195 * Drops every edge a longer path already accounts for — the transitive
196 * reduction of the carry graph.
197 *
198 * A workflow passes the same text down a chain, so a run whose fourth stage
199 * quotes what its second produced yields both `2→3→4` and a bare `2→4`. Every
200 * one of those bare edges has a lane of nodes in its way and has to be routed
201 * around them, and it says nothing the two hops either side of it did not
202 * already say. The drawing keeps the hops and drops the shortcut.
203 *
204 * The edges run forward in time (`flowsOf` refuses a source that started later,
205 * `carriesOf` only pairs a lane with the one before it), so the walk cannot
206 * loop.
207 */
208function thinned(edges: Carry[]): Carry[] {
209 const out = new Map<string, Carry[]>()
210
211 for (const e of edges) {
212 out.set(e.fromId, [...(out.get(e.fromId) ?? []), e])
213 }
214
215 /** True when `target` is reachable from `from` over edges this strong. */
216 const reaches = (from: string, target: string, confirmed: boolean, seen: Set<string>): boolean => {
217 if (from === target) {
218 return true
219 }
220
221 if (seen.has(from)) {
222 return false
223 }
224
225 seen.add(from)
226
227 return (out.get(from) ?? [])
228 .filter(e => !confirmed || e.confirmed === true)
229 .some(e => reaches(e.toId, target, confirmed, seen))
230 }
231
232 // A proven edge is only dropped for a path that is proven the whole way: a
233 // guessed hop standing in for a recorded one would trade a fact for a hint.
234 return edges.filter(
235 e =>
236 !(out.get(e.fromId) ?? [])
237 .filter(step => step.toId !== e.toId && (e.confirmed !== true || step.confirmed === true))
238 .some(step => reaches(step.toId, e.toId, e.confirmed === true, new Set())),
239 )
240}
241
242/**
243 * The run's agents grouped by phase, each lane ordered so a carry runs straight
244 * across.
245 *
246 * An agent's place in the journal is when it started, not what it follows, so a
247 * lane left in that order sends its carries diagonally across each other. Each
248 * lane after the first is therefore sorted by where its source sits in the lane
249 * before it — the barycentre step of a layered graph drawing, with one parent
250 * per node it is just a sort. A node with no carry keeps its arrival order,
251 * after the ones that have one.
252 */
253export function orderedLanes(run: RunState, opened?: Set<string>): FoldedLane[] {
254 const lanes = foldedLanes(run, opened)
255 const edges = edgesOf(run)
256
257 for (let i = 1; i < lanes.length; i++) {
258 // The rows are folds, so the sort is over the agent each fold stands for.
259 // A fold's other passes ran in other trips round the loop and have no place
260 // of their own in a lane that draws each piece of work once.
261 const above = new Map(lanes[i - 1].folds.map((f, index) => [f.agent.agentId, index]))
262 const arrival = new Map(lanes[i].folds.map((f, index) => [f.agent.agentId, index]))
263 const ties = tiesOf(
264 lanes[i].folds.map(f => f.agent),
265 above,
266 edges,
267 )
268
269 const rankOf = (a: AgentRow) => {
270 const row = ties.above.get(a.agentId)
271
272 // Carried nodes take their source's row; the rest sit below, in the order
273 // they arrived, so an added agent never reshuffles the ones above it.
274 return row === undefined ? lanes[i - 1].folds.length + (arrival.get(a.agentId) ?? 0) : (above.get(row) ?? 0)
275 }
276
277 lanes[i].folds = [...lanes[i].folds].sort(
278 (x, y) =>
279 rankOf(x.agent) - rankOf(y.agent) ||
280 (ties.depth.get(x.agent.agentId) ?? 0) - (ties.depth.get(y.agent.agentId) ?? 0) ||
281 (arrival.get(x.agent.agentId) ?? 0) - (arrival.get(y.agent.agentId) ?? 0),
282 )
283 }
284
285 return lanes
286}
287
288/**
289 * What each agent of one lane follows: the agent in the lane above it was fed
290 * from, and how many steps inside its own lane it stands from the first of its
291 * chain.
292 *
293 * A second pass over the same file is fed by the first pass, not by the survey
294 * that started them — an edge inside one lane. Left out, that pass had no
295 * source in the lane above, went to the end of the lane, and drew its carry
296 * diagonally across every column between.
297 *
298 * Exported for the sake of three guards that no run can reach. The edges
299 * `edgesOf` hands it are proven-first, never self-referential and always
300 * forward in time, so a fixture built out of a run cannot ask this what it does
301 * with a guessed edge that arrives before a proven one, with an agent that
302 * feeds itself, or with a cycle. The guards are here because the argument is a
303 * list of edges rather than a run, and the only way to hold them to their word
304 * is to hand them such a list.
305 */
306export function tiesOf(
307 agents: AgentRow[],
308 above: Map<string, number>,
309 edges: Carry[],
310): { above: Map<string, string>; depth: Map<string, number> } {
311 const here = new Set(agents.map(a => a.agentId))
312 // A proven edge outranks a guessed one, and only the lane directly above can
313 // place a node in a row.
314 const sorted = [...edges].sort((a, b) => Number(!!b.confirmed) - Number(!!a.confirmed))
315 const fromAbove = new Map<string, string>()
316 const fromHere = new Map<string, string>()
317
318 for (const e of sorted) {
319 if (above.has(e.fromId) && !fromAbove.has(e.toId)) {
320 fromAbove.set(e.toId, e.fromId)
321 }
322
323 if (here.has(e.fromId) && here.has(e.toId) && e.fromId !== e.toId && !fromHere.has(e.toId)) {
324 fromHere.set(e.toId, e.fromId)
325 }
326 }
327
328 const source = new Map<string, string>()
329 const depth = new Map<string, number>()
330
331 for (const agent of agents) {
332 let at = agent.agentId
333 let steps = 0
334
335 // Walk back through the lane's own chain to the pass that began it, which
336 // is the one the lane above fed. A cycle cannot outlast the lane's length.
337 while (fromHere.has(at) && steps <= agents.length) {
338 at = fromHere.get(at) as string
339 steps++
340 }
341
342 depth.set(agent.agentId, steps)
343
344 const root = fromAbove.get(at)
345
346 if (root !== undefined) {
347 source.set(agent.agentId, root)
348 }
349 }
350
351 return { above: source, depth }
352}
353
354/** Where an agent stands in its phase's repeats of one piece of work. */
355export type Pass = {
356 /** 1 for the first go at it. */
357 index: number
358 /** How many goes there were in all. */
359 total: number
360}
361
362/**
363 * The repeats a run's phases made, one entry per agent that is part of one.
364 *
365 * A loop shows from outside as one phase entered again: the pipeline reaches its
366 * last gate, the gate refuses, and the run goes back to `Develop` and comes
367 * down the same phases a second time. Which agents are repeats of each other is
368 * read off `foldedLanes` — same phase, same label, different entry — so it is a
369 * record of what the run did rather than a reading of what the labels imply.
370 *
371 * Running side by side disqualifies them, and falls out of the same rule: two
372 * agents of one label inside a single entry are a fan-out that reuses a name,
373 * and `foldsOf` gives them a row each. Three lenses opened at once are three
374 * different things done once, not one thing done three times.
375 */
376export function passesOf(run: RunState): Map<string, Pass> {
377 const passes = new Map<string, Pass>()
378
379 for (const lane of foldedLanes(run)) {
380 for (const fold of lane.folds) {
381 if (fold.passes.length < 2) {
382 continue
383 }
384
385 fold.passes.forEach((pass, at) => passes.set(pass.agent.agentId, { index: at + 1, total: fold.passes.length }))
386 }
387 }
388
389 return passes
390}
391
392/**
393 * Every trip through the piece of work one agent belongs to, that agent's own
394 * among them — and nothing where the work was done once.
395 *
396 * {@link passesOf} answers the drawing's question, which is how many marks a
397 * card carries. This answers the dialog's: a reader looking at the third trip
398 * through `Develop` wants the first, and the card behind the dialog has room
399 * for four marks out of ten — or, at a card's narrowest, for none of them.
400 *
401 * Read with every nested run unfolded, whatever the reader has opened in the
402 * drawing. An agent inside a shut nested run is still reachable — the run's row
403 * opens a list and a row of that list opens the agent — so its passes have to
404 * be found whether or not the run is drawn open behind the dialog.
405 */
406export function passesFor(run: RunState, agentId: string): FoldPass[] {
407 const opened = new Set(
408 run.agents.map(agent => phaseKeyOf(agent.phase)).filter(phase => phase.startsWith(NESTED)),
409 )
410
411 for (const lane of foldedLanes(run, opened)) {
412 for (const fold of lane.folds) {
413 if (fold.passes.length > 1 && fold.passes.some(pass => pass.agent.agentId === agentId)) {
414 return fold.passes
415 }
416 }
417 }
418
419 return []
420}
421
422/**
423 * A phase's agents grouped into the waves they ran in: each wave the agents
424 * that overlapped, the waves in the order they went.
425 *
426 * Agents of a phase that overlap are a fan: whatever the phase before produced
427 * was there for all of them at once, and a line from each source into each of
428 * them is the truth about how the work reached them. Agents that ran one after
429 * another are a chain. The second could not have begun until the first had
430 * landed, so what it was given includes what the first answered, and a drawing
431 * that feeds every one of them from the phase above says the phase started them
432 * together — which the two clocks on their own cards disprove.
433 *
434 * Real phases are rarely either. A nested run opened out into the phase that
435 * called it is a dozen agents that planned, then fanned out, then gathered:
436 * three waves, not one fan and not a chain of twelve. Read as a fan it takes a
437 * line from the phase above into all twelve and a line out of all twelve into
438 * the phase below — twenty-four wires through two gutters, saying that the
439 * calling phase started twelve agents at once and that the phase after waited
440 * on each of them, neither of which happened. Read as waves it takes a line
441 * into the first wave and a line out of the last, which is what did happen.
442 *
443 * A single wave is a fan and a phase of waves of one is a chain, so nothing
444 * needs to ask which of the three a phase is: the waves say it.
445 */
446export function wavesOf(lane: { agents: AgentRow[] }): AgentRow[][] {
447 const SLACK = 250
448 const order = [...lane.agents].sort((a, b) => a.startedMs - b.startedMs)
449 const waves: AgentRow[][] = []
450 // The furthest the wave being built runs to. An agent still working runs to
451 // the end of the run as far as anything after it is concerned, so everything
452 // that started while it was going belongs to its wave.
453 let until = -Infinity
454
455 for (const agent of order) {
456 const wave = waves[waves.length - 1]
457
458 if (wave === undefined || agent.startedMs + SLACK >= until) {
459 waves.push([agent])
460 until = agent.endedMs ?? Infinity
461 continue
462 }
463
464 wave.push(agent)
465 until = Math.max(until, agent.endedMs ?? Infinity)
466 }
467
468 return waves
469}
470
471/**
472 * A phase's agents in the order they ran, where they ran one at a time — and
473 * nothing where any two of them overlapped.
474 *
475 * The degenerate shape of {@link wavesOf}: every wave one agent. It is worth a
476 * name of its own because a chain is the one shape whose agents hand work to
477 * each other one for one, so the hop from each pass to the next can be drawn as
478 * a wire. Between waves of several there is no such pairing to draw.
479 *
480 * Nothing for a phase of one, which is neither a chain nor a fan.
481 */
482export function chainOf(lane: { agents: AgentRow[] }): AgentRow[] | null {
483 const waves = wavesOf(lane)
484
485 return waves.length > 1 && waves.every(wave => wave.length === 1) ? waves.map(wave => wave[0]) : null
486}
487
488/** True where each agent started only once the one before it had landed. */
489function inSequence(order: AgentRow[]): boolean {
490 const SLACK = 250
491
492 for (let i = 1; i < order.length; i++) {
493 const before = order[i - 1].endedMs
494
495 if (before === undefined || order[i].startedMs + SLACK < before) {
496 return false
497 }
498 }
499
500 return true
501}
502
503/**
504 * An edge that skips a lane, with every other edge between the same two phases
505 * folded into it.
506 *
507 * Such an edge has a whole band of nodes in its way and cannot be drawn where
508 * it belongs. Bundling is what keeps it affordable: five topics carrying their
509 * revision past the measuring step are one fact about the run, drawn once.
510 */
511export type Skip = {
512 fromLane: number
513 toLane: number
514 count: number
515 /**
516 * The edges the bundle stands for, so a rail can be hung off one of them
517 * rather than off the lanes as a whole. Two phases that ran side by side have
518 * no barrier between them and so no bus for a rail to land anywhere on;
519 * landing on the lane's nearest node then names a source that has nothing to
520 * do with what the rail carries.
521 */
522 edges: Carry[]
523 /** True only when every edge in the bundle is one the text proved. */
524 confirmed: boolean
525}
526
527/**
528 * True when the later lane really runs after the earlier one — every agent of
529 * it started after the last agent of the earlier one landed.
530 *
531 * Lane order is the order the phases were declared, which is not the order they
532 * ran in. A `pipeline()` that branches opens `Review` and `Check` at the same
533 * moment on different files; drawing a barrier between them because one is
534 * declared after the other says the second waited for the first, and a reader
535 * who can see both started at thirteen seconds knows that is false.
536 *
537 * An earlier lane still running fails the test, which is the answer that keeps
538 * the drawing honest while the run is in flight and does not change once it
539 * finishes.
540 */
541export function follows(
542 earlier: { agents: AgentRow[] },
543 later: { agents: AgentRow[] },
544): boolean {
545 if (earlier.agents.length === 0 || later.agents.length === 0) {
546 return false
547 }
548
549 // A phase that opens the instant the one before it closes still followed it.
550 const SLACK = 250
551
552 let last = 0
553
554 for (const agent of earlier.agents) {
555 if (agent.endedMs === undefined) {
556 return false
557 }
558
559 last = Math.max(last, agent.endedMs)
560 }
561
562 return later.agents.every(a => a.startedMs + SLACK >= last)
563}
564
565/**
566 * For every node fed from further back than the band above it, the phases that
567 * fed it, in the order the run entered them.
568 *
569 * A node fed from two phases back and from four names both: which of them a
570 * reader wants is the question they arrived with, and the pane cannot guess it.
571 *
572 * The phase is named by its place in the run rather than by its place in the
573 * picture. The drawing holds only the phases it could fit, so taken as a place
574 * every skip in a run with one phase left out named the phase one along.
575 *
576 * Named, not keyed: a nested run's phase name arrives with the engine's `\u25b8`
577 * on the front, and `\u25b8` is this pane's press-to-unfold mark everywhere else a
578 * reader meets it. Left on, a card fed from a nested run read `\u25b8 ai-review`
579 * beside it, which is a control a reader cannot press — the mark belongs to the
580 * band's own caption, where the press is. The dialog's title strips it for the
581 * same reason.
582 */
583export function sourcesOf(run: RunState): Map<string, string[]> {
584 const lanes = lanesOf(run)
585 const out = new Map<string, string[]>()
586
587 for (const skip of skipsOf(run)) {
588 const named = lanes[skip.fromLane]?.phase
589 const phase = named?.startsWith(NESTED) ? named.slice(NESTED.length) : named
590
591 if (phase === undefined) {
592 continue
593 }
594
595 for (const edge of skip.edges) {
596 const held = out.get(edge.toId) ?? []
597
598 if (!held.includes(phase)) {
599 out.set(edge.toId, [...held, phase])
600 }
601 }
602 }
603
604 return out
605}
606
607/** The run's lane-skipping edges, bundled by the pair of phases they join. */
608export function skipsOf(run: RunState): Skip[] {
609 const lanes = lanesOf(run)
610 const laneOf = new Map<string, number>()
611
612 lanes.forEach((lane, index) => {
613 for (const agent of lane.agents) {
614 laneOf.set(agent.agentId, index)
615 }
616 })
617
618 const bundles = new Map<string, Skip>()
619
620 for (const edge of edgesOf(run)) {
621 const fromLane = laneOf.get(edge.fromId)
622 const toLane = laneOf.get(edge.toId)
623
624 if (fromLane === undefined || toLane === undefined || toLane - fromLane <= 1) {
625 continue
626 }
627
628 const held = bundles.get(`${fromLane}>${toLane}`)
629
630 if (held) {
631 held.count++
632 held.edges.push(edge)
633 held.confirmed = held.confirmed && edge.confirmed === true
634 } else {
635 bundles.set(`${fromLane}>${toLane}`, {
636 fromLane,
637 toLane,
638 count: 1,
639 edges: [edge],
640 confirmed: edge.confirmed === true,
641 })
642 }
643 }
644
645 return [...bundles.values()]
646}
647
648/**
649 * A phase's name without the marker the engine hangs on a re-entry.
650 *
651 * A workflow that runs a nested one three times writes three phases —
652 * `▸ code-review:ai-review-agentic`, then the same with ` #2` and ` #3`. They
653 * are one phase entered three times, not three phases, and drawn as three the
654 * pane said a run had fifty steps where it had sixteen and put the third trip
655 * round the loop in its own band at the bottom of the drawing, after the phase
656 * the run actually finished in.
657 */
658export function phaseKeyOf(phase: string): string {
659 return phase.replace(/ #\d+$/, '')
660}
661
662/**
663 * The run's agents grouped by phase, in the order the run went through them.
664 *
665 * The script's own `meta.phases` is a *declaration*, and a workflow that rewinds
666 * does not follow it: the loop's second trip re-enters `Develop` long after the
667 * script's list has moved past it, and a nested run announces phases the list
668 * never held at all. Ordered as declared, the pane put the three phases of the
669 * review panel below `Run Feedback` — after the phase the run ended in — and a
670 * reader following the drawing down the page read the run out of order.
671 *
672 * So a phase the run entered is placed by when it was first entered, and one it
673 * declared but never reached keeps its declared place: behind whichever entered
674 * phase it was declared after. That is the only position a phase with no clock
675 * of its own can be given, and it is the one the script promised.
676 */
677export function lanesOf(run: RunState): { phase: string; agents: AgentRow[] }[] {
678 const first = new Map<string, number>()
679
680 for (const agent of run.agents) {
681 const key = phaseKeyOf(agent.phase)
682 const at = first.get(key)
683
684 if (at === undefined || agent.startedMs < at) {
685 first.set(key, agent.startedMs)
686 }
687 }
688
689 const seen = new Set<string>()
690 const lanes: { phase: string; agents: AgentRow[]; at: number }[] = []
691 let behind = 0
692
693 const push = (phase: string, declared: boolean) => {
694 const key = phaseKeyOf(phase)
695
696 if (seen.has(key)) {
697 return
698 }
699
700 const at = first.get(key)
701
702 if (at !== undefined) {
703 behind = at
704 }
705
706 seen.add(key)
707 lanes.push({
708 phase: key,
709 agents: run.agents.filter(a => phaseKeyOf(a.phase) === key),
710 // A phase nothing entered stands at the same moment as the one it was
711 // declared behind, and the declared order breaks the tie. Nudging it a
712 // fraction later instead put it after every phase that started in the
713 // same millisecond, which is every phase of a run replayed from a file.
714 at: at ?? behind,
715 })
716
717 void declared
718 }
719
720 run.phases.forEach(phase => push(phase, true))
721 run.agents.forEach(a => push(a.phase, false))
722
723 // A phase the script declared but no agent ever entered still draws, so a
724 // run in flight shows what is still ahead of it.
725 return lanes
726 .map((lane, index) => ({ lane, index }))
727 .sort((a, b) => a.lane.at - b.lane.at || a.index - b.index)
728 .map(({ lane }) => ({ phase: lane.phase, agents: lane.agents }))
729}
730
731/**
732 * Each time the run was in a phase, in the order it entered them.
733 *
734 * A workflow that rewinds comes back to a phase it has already been through, so
735 * the agents of one phase arrive in blocks: everything `Develop` ran the first
736 * time, then the phases after it, then `Develop` again. Reading the blocks off
737 * the order the journal announced the agents in is a *record* of how many times
738 * the run entered each phase — the same standing a barrier has, and a stronger
739 * one than any reading of the labels — which is why the passes a node shows are
740 * counted here rather than inferred.
741 *
742 * Agents of two phases that overlap in time still fall in the block of whichever
743 * phase was announced around them. That is what a workflow does when a phase
744 * starts before the one before it has finished landing, and the block it makes
745 * is still the entry both of them belong to.
746 */
747export function entriesOf(run: RunState): Map<string, AgentRow[][]> {
748 const order = [...run.agents].sort((a, b) => a.startedMs - b.startedMs)
749 const entries = new Map<string, AgentRow[][]>()
750 let last: string | null = null
751
752 for (const agent of order) {
753 const key = phaseKeyOf(agent.phase)
754 const blocks = entries.get(key) ?? []
755
756 if (key !== last || blocks.length === 0) {
757 blocks.push([])
758 }
759
760 blocks[blocks.length - 1].push(agent)
761 entries.set(key, blocks)
762 last = key
763 }
764
765 return entries
766}
767
768/** One agent of a fold, and which entry into the phase it belongs to. */
769export type FoldPass = {
770 /** 1 for the run's first trip through this phase. */
771 index: number
772 agent: AgentRow
773}
774
775/**
776 * One piece of a phase's work, and every time the run did it.
777 *
778 * A phase entered three times with one agent in it each time is one thing that
779 * happened three times, not three things — and drawn as three rows carrying the
780 * same word, a reader could not tell which `Develop` was which except by its
781 * clock. Folded, it is one row with a mark per pass, and the marks say at a
782 * glance which trip failed.
783 */
784export type Fold = {
785 label: string
786 /** The pass whose figures the row carries: the last one to have started. */
787 agent: AgentRow
788 /** Every pass, in entry order. One entry where the work ran once. */
789 passes: FoldPass[]
790 /**
791 * What is behind the row, where the row stands for a whole nested run: how
792 * many agents it had in all, and the phase to unfold to see them. A row that
793 * hides thirteen agents has to say that it does, or it reads as one agent
794 * that was slow — and saying it is where the reader will press to look, so
795 * the count carries the phase name the press needs.
796 */
797 inside?: { agents: number; phase: string; alone?: boolean }
798}
799
800/** A phase, folded: its passes counted, its repeated work on one row each. */
801export type FoldedLane = {
802 phase: string
803 index: number
804 /** How many times the run entered this phase. */
805 entries: number
806 folds: Fold[]
807 /** Every agent of the phase, whatever the folds show. */
808 agents: AgentRow[]
809 /** True for a phase that is itself a nested workflow run. */
810 nested: boolean
811 /** True where the whole phase is drawn as one row, its labels not shown. */
812 shut?: boolean
813 /**
814 * A shut nested run as one made-up row per trip through it.
815 *
816 * The layouts that draw structure fold a nested run into a single node with a
817 * mark per pass, because what they are drawing is where the work sits in the
818 * graph. A timeline is drawing *when*, and a run entered three times is three
819 * separate stretches of clock with the calling run's own work in the gaps —
820 * so one row spanning the first start to the last end would draw a bar over
821 * an hour the nested run spent not running.
822 */
823 spans?: AgentRow[]
824}
825
826/** The marker a workflow engine puts in front of a nested run's phase name. */
827const NESTED = '\u25b8 '
828
829/**
830 * What joins a nested run's name to the number of a step inside it, in the key
831 * a press on that step folds and unfolds.
832 *
833 * The keys a reader has opened are one set, holding both phase names and step
834 * keys, so a step key must be a string no phase name can be. A phase name is
835 * whatever the workflow called it and the engine already writes `#2` and `#3`
836 * on the end of one for the second and third trip through it, so `#` is the one
837 * separator that could collide. A null joins them instead: it is in no name any
838 * engine writes, and the key is never drawn — it goes into a press and nowhere
839 * else.
840 *
841 * A step key in the set means that step is *folded*, which is the opposite of
842 * what a phase name in the same set means. Opening the run used to open only
843 * its steps' headings: a reader who pressed `▸` on a fourteen-agent panel got
844 * six rows reading `6 at once`, and had to press six more times to see the
845 * panel. Opening is one press now — the whole run — and the step keys record
846 * the folding a reader has done since, which is the rarer thing to want and so
847 * the one worth spending a key on.
848 */
849const STEP_SHUT = '\u0000'
850
851/**
852 * The run as rows: one per piece of work rather than one per agent.
853 *
854 * `opened` names the nested phases a reader has asked to see inside. A nested
855 * workflow is a run of its own — the agentic review panel is fourteen agents
856 * with their own fan-out and their own passes — and inlining all of it put
857 * another workflow's structure in the middle of this one's, three times over.
858 * Shut, it is one row that says how many agents and what they cost; opened, it
859 * is the same phase every other phase is.
860 */
861export function foldedLanes(run: RunState, opened: Set<string> = new Set()): FoldedLane[] {
862 const entries = entriesOf(run)
863
864 return lanesOf(run).map((lane, index) => {
865 const blocks = entries.get(lane.phase) ?? []
866 const nested = lane.phase.startsWith(NESTED)
867 const shut = nested && !opened.has(lane.phase) && lane.agents.length > 0
868 const base = {
869 phase: lane.phase,
870 index,
871 entries: blocks.length,
872 agents: lane.agents,
873 nested,
874 }
875
876 if (shut) {
877 // One row for the whole nested run, with a mark for each time the run
878 // entered it. What stands for a pass is the agent that decided it: the
879 // last to land, since a sub-run that failed fails at its end.
880 const name = lane.phase.slice(NESTED.length)
881
882 return {
883 ...base,
884 shut: true,
885 folds: [
886 {
887 label: name,
888 agent: wholeOf(name, blocks[blocks.length - 1] ?? lane.agents),
889 passes: blocks.map((block, at) => ({ index: at + 1, agent: decidedBy(block) })),
890 inside: { agents: lane.agents.length, phase: lane.phase },
891 },
892 ],
893 spans: (blocks.length > 0 ? blocks : [lane.agents]).map(block => wholeOf(name, block)),
894 }
895 }
896
897 const folds = foldsOf(blocks)
898
899 return { ...base, folds: nested ? steppedFolds(lane.phase, blocks, folds, opened) : folds }
900 })
901}
902
903/**
904 * An opened nested run's rows grouped into the steps it took, with a step of
905 * several rows standing as one until a reader asks to see inside it.
906 *
907 * A nested run opened out is the only place in the drawing where one phase
908 * holds another workflow's whole shape. The review panel is a claim, a context
909 * pass, six reviewers at once, a synthesis and two writes — six steps — and
910 * drawn as twelve cards down one column it is twelve things in a row, with
911 * nothing saying that six of them happened at the same time. The reader has to
912 * read twelve clocks to find that out, and the column is twice as tall as the
913 * run it is drawing.
914 *
915 * So a step of several rows can stand as one row saying how many, and the way
916 * inside is the way inside a nested run: the count, pressed. It is the same
917 * device one level down, which is the point — a reader who has already opened
918 * the run knows what `▸ 6 agents` does.
919 *
920 * It starts opened, though, and `shut` names the steps a reader has folded
921 * since. Starting folded made opening a nested run a partial answer: the press
922 * that said *show me this run* produced six rows reading `6 at once` and six
923 * more presses between the reader and the run. The fold is for a reader who has
924 * seen the run and wants one wide step out of the way, which is a thing they do
925 * after looking, not before.
926 *
927 * Only inside a nested run. A phase of the calling run that fanned out to six
928 * agents is six cards because six cards is what the graph is *for*; folding
929 * them would leave a drawing of a workflow with no work in it.
930 */
931function steppedFolds(phase: string, blocks: AgentRow[][], folds: Fold[], shut: Set<string>): Fold[] {
932 // The trip that did the most is the one that shows the run's shape. Trips are
933 // not alike — a second trip that found nothing to do is a claim, a context
934 // pass and a write — so the waves of any one of them would call some other
935 // trip's work a step it was never part of. Read off the fullest trip, a row
936 // that trip never ran belongs to no step and stands on its own, which is the
937 // one thing that is true about it whatever the rest of the run did.
938 const spine = blocks.reduce((best, block) => (block.length > best.length ? block : best), [] as AgentRow[])
939 const step = new Map<string, number>()
940
941 wavesOf({ agents: spine }).forEach((wave, at) => {
942 for (const agent of wave) {
943 if (!step.has(agent.label)) {
944 step.set(agent.label, at)
945 }
946 }
947 })
948
949 const groups: Fold[][] = []
950 let last: number | undefined
951
952 for (const fold of folds) {
953 const at = step.get(fold.label)
954
955 if (groups.length === 0 || at === undefined || at !== last) {
956 groups.push([])
957 }
958
959 groups[groups.length - 1].push(fold)
960 last = at
961 }
962
963 return groups.flatMap((group, at) => {
964 const key = `${phase}${STEP_SHUT}${at}`
965
966 if (group.length < 2 || !shut.has(key)) {
967 return group
968 }
969
970 // Every agent behind the step, over every trip — the number the row has to
971 // say, since that is what a press on it brings into view.
972 const behind = group.flatMap(fold => fold.passes.map(pass => pass.agent))
973 // The count is of the work, not of the agents: six reviewers run three
974 // times over is six things that happened at once, not eighteen. The agents
975 // are counted under it, where a nested run counts its own.
976 const label = `${group.length} at once`
977 const trips = new Map<number, AgentRow[]>()
978
979 for (const fold of group) {
980 for (const pass of fold.passes) {
981 trips.set(pass.index, [...(trips.get(pass.index) ?? []), pass.agent])
982 }
983 }
984
985 const passes = [...trips.entries()].sort(([one], [two]) => one - two)
986 // The figures are one trip's, the way every folded row's are: the last trip
987 // through, since that is the state the run finished in. Read off all three
988 // at once the clock would run from the first trip's start to the last one's
989 // end — three quarters of an hour for six agents that took a minute, most
990 // of it the calling run doing something else entirely.
991 const latest = passes[passes.length - 1]?.[1] ?? behind
992
993 return [
994 {
995 label,
996 agent: wholeOf(label, latest),
997 passes: passes.map(([index, block]) => ({ index, agent: decidedBy(block) })),
998 inside: { agents: behind.length, phase: key, alone: true },
999 },
1000 ]
1001 })
1002}
1003
1004/**
1005 * One block of a nested run as a single agent: the span it took, what it spent
1006 * in all, and the state it came back in.
1007 *
1008 * It is a made-up row, and it says so by carrying the workflow's name rather
1009 * than any agent's. Its id is the id of the agent that decided the block, so
1010 * pressing it opens that agent's own detail — the one a reader wants when a
1011 * sub-run comes back red — and every wire drawn to the block lands on it.
1012 *
1013 * Made rather than aggregated at the drawing: a clock, a spend and a model tag
1014 * are read off an `AgentRow` by four different parts of the painter, and a row
1015 * that had to be special-cased in each of them would be a row that agrees with
1016 * the ones around it in three places out of four.
1017 */
1018function wholeOf(label: string, block: AgentRow[]): AgentRow {
1019 const decided = decidedBy(block)
1020 const ended = block.every(a => a.endedMs !== undefined)
1021
1022 return {
1023 ...decided,
1024 label,
1025 tools: [],
1026 startedMs: Math.min(...block.map(a => a.startedMs)),
1027 ...(ended ? { endedMs: Math.max(...block.map(a => a.endedMs ?? a.startedMs)) } : { endedMs: undefined }),
1028 ...(spentIn(block) === undefined ? {} : { tokens: spentIn(block) }),
1029 ...ranOn(block),
1030 liveTokens: undefined,
1031 calls: undefined,
1032 resultPreview: undefined,
1033 result: undefined,
1034 prompt: undefined,
1035 }
1036}
1037
1038/**
1039 * What a made-up row says it ran on: the one model behind it, or how many.
1040 *
1041 * The row used to take the deciding agent's model along with the rest of it,
1042 * which is the last agent to land. A sixteen-agent review panel whose seats ran
1043 * on Opus and whose closing step was a one-command persist came out labelled
1044 * with the persist step's model, and a reader comparing two runs by what they
1045 * were drawn on was reading the model of whichever agent happened to finish
1046 * last.
1047 *
1048 * Naming the commonest instead would be the same fault with better odds. So a
1049 * block that ran on more than one says that, in place of a name it cannot give:
1050 * the count is the fact, and the agents behind it are one press away. A block
1051 * of one keeps the name.
1052 *
1053 * A block that named one model, or none, is left as it was found: the deciding
1054 * agent's model is that one model or the same silence, and the drawing falls
1055 * back to the run's own for a silence, as it does for any agent that has not
1056 * said yet.
1057 */
1058function ranOn(block: AgentRow[]): { model?: string } {
1059 const names = new Set(
1060 block
1061 .map(agent => modelName(agent.model))
1062 .filter(name => name !== ''),
1063 )
1064
1065 return names.size > 1 ? { model: `${names.size} models` } : {}
1066}
1067
1068/** What a set of agents spent in all, where any of them has said. */
1069function spentIn(agents: AgentRow[]): number | undefined {
1070 const known = agents.map(a => a.tokens ?? a.liveTokens).filter((n): n is number => n !== undefined)
1071
1072 return known.length > 0 ? known.reduce((sum, n) => sum + n, 0) : undefined
1073}
1074
1075/**
1076 * The agent a block of work is judged by: the one that failed, or the last to
1077 * land.
1078 *
1079 * A nested run drawn as one mark has to say whether that trip through it came
1080 * back clean, and one agent of fourteen failing is the whole trip failing —
1081 * that is what the outer workflow does with it. So a failure anywhere in the
1082 * block is what the mark reports, and a block that came back clean reports the
1083 * agent that closed it.
1084 */
1085function decidedBy(block: AgentRow[]): AgentRow {
1086 const failed = block.find(a => a.state === 'failed')
1087
1088 if (failed) {
1089 return failed
1090 }
1091
1092 const running = block.find(a => a.state === 'running')
1093
1094 return running ?? [...block].sort((a, b) => (a.endedMs ?? a.startedMs) - (b.endedMs ?? b.startedMs)).pop() ?? block[0]
1095}
1096
1097/**
1098 * A phase's blocks folded by label: one row per piece of work, in the order the
1099 * work first appeared.
1100 *
1101 * Two agents of one label inside a single entry are two different things that
1102 * happened at once — a fan-out that reuses a name — and they stay two rows. It
1103 * is across entries that a shared label means a repeat, because an entry is a
1104 * trip round the loop and the trip is what repeated.
1105 */
1106function foldsOf(blocks: AgentRow[][]): Fold[] {
1107 const folds = new Map<string, Fold>()
1108
1109 for (const block of blocks) {
1110 const taken = new Map<string, Fold[]>()
1111
1112 for (const agent of [...block].sort((a, b) => a.startedMs - b.startedMs)) {
1113 const open = taken.get(agent.label) ?? []
1114 // Inside one entry a label can mean two different things. A gate that
1115 // failed and was run again is the same work a second time and joins the
1116 // row it repeats; two agents of one name running side by side are a
1117 // fan-out that reuses the name, and each takes a row of its own. What
1118 // tells them apart is the clock: a repeat cannot have begun until the
1119 // thing it repeats had landed.
1120 const after = open.find(f => started(f.agent, agent))
1121
1122 if (after) {
1123 after.passes.push({ index: after.passes.length + 1, agent })
1124 after.agent = agent
1125
1126 continue
1127 }
1128
1129 const fold = folds.get(`${agent.label}\u0000${open.length}`)
1130
1131 if (fold) {
1132 fold.passes.push({ index: fold.passes.length + 1, agent })
1133 fold.agent = agent
1134 open.push(fold)
1135 } else {
1136 const made = { label: agent.label, agent, passes: [{ index: 1, agent }] }
1137
1138 folds.set(`${agent.label}\u0000${open.length}`, made)
1139 open.push(made)
1140 }
1141
1142 taken.set(agent.label, open)
1143 }
1144 }
1145
1146 return [...folds.values()]
1147}
1148
1149/** True where the second agent could only have begun once the first had landed. */
1150function started(before: AgentRow, after: AgentRow): boolean {
1151 const SLACK = 250
1152
1153 return before.endedMs !== undefined && after.startedMs + SLACK >= before.endedMs
1154}
1155
1156/** The tokens of a label, split on the separators workflow labels use. */
1157function tokensOf(label: string): string[] {
1158 return label
1159 .toLowerCase()
1160 .split(/[:/\-_\s.]+/)
1161 .filter(t => t.length > 1)
1162}
1163
1164/** How many agents of a lane carry each label token. */
1165function countIn(agents: AgentRow[]): Map<string, number> {
1166 const counts = new Map<string, number>()
1167
1168 for (const a of agents) {
1169 for (const t of new Set(tokensOf(a.label))) {
1170 counts.set(t, (counts.get(t) ?? 0) + 1)
1171 }
1172 }
1173
1174 return counts
1175}
1176
1177/**
1178 * Pairs each agent with the one that named it — the agent of an earlier lane
1179 * whose label shares a token no other agent of that lane has.
1180 *
1181 * Uniqueness is asked of the source only. One agent feeding several is the
1182 * ordinary shape of a run: a `pipeline()` stage that opens three lenses on one
1183 * file writes `review:paint:correctness`, `review:paint:edges` and
1184 * `review:paint:drift`, and all three do come from `survey:paint`. Asking the
1185 * target side to be unique too refused every one of those, which left the lane
1186 * where the work fans out — the lane a reader most wants explained — with no
1187 * edges at all.
1188 *
1189 * The search walks back a lane at a time and stops at the first lane that has
1190 * anything to say about the target:
1191 *
1192 * - one agent holds the token: that is the source, and the edge is drawn;
1193 * - several hold it: the source is in this lane and the labels cannot say
1194 * which, so nothing is drawn. The barrier between the phases already says
1195 * the weaker thing that is still true;
1196 * - none hold it: keep walking. A `pipeline()` that branches sends one file to
1197 * `Review` and another to `Check`, so the lane a node follows is not always
1198 * the lane in front of it.
1199 */
1200export function carriesOf(run: RunState): Carry[] {