SLOPSHOPPER

flowpane

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…

newpaneguardcommandtimer
★ 9v0.10.0MITupdated 2026-09-22mpolatcan/flowpane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flowpane
│ ┃ workflow ✕ › fix the failing auth test and add an audit log call │ ┃ FlowPane - Dynamic Workflow Visuali │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ▮ FlowPane │ nothing running ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /flowpane │ ┃ ⎿ flowpane: No workflow is running in this session. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · workflow
FlowPane - Dynamic Workflow Visualizer ──────────────────────────────────────────────────────── ▮ FlowPane │ nothing running ──────────────────────────────────────────────────────── ──────────────────────────────────────────────────────── ⚙ Settings │ Layout: fits FlowPane 0.10.0
README

flowpane — a live graph of a Claude Code workflow run

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.

Screenshots

Phases acrossPhases down
The graph with its phases laid out across the paneThe same run with its phases stacked down the pane
TimelineA node's detail
One bar per agent against the clockThe dialog a node opens, over the graph

What it draws

  • Every agent as a node, framed in its own state — green for done, yellow for running, red for failed, grey for one the run cut off — with its name, how long it took, what it spent and the model it ran on.
  • The shape of the run: phases as columns or bands, fan-outs folded to fit, loops marked with the number of passes, chains that stopped early left short, and a phase the run never entered drawn as skipped rather than as failed.
  • What is happening now: a travelling light along the live wires, the tool an agent is calling written on its card, and a running total of tokens, tool calls and models on the run line.
  • What an agent was asked and answered: press a node for a dialog carrying its prompt, every tool call with its input, and its result.
  • The session's other runs: press the run's name for a menu of them, grouped by state and scrolling where there are more than the pane can stand; with nothing running the pane lists them in place of the graph.
  • A nested workflow as a run of its own: a phase whose agents belong to a workflow this one called is drawn in the dashed register every layout uses for work that is not this run's, folded to one node with a mark per trip. Press its ▸ and every agent inside stands separately, each with its own detail.
  • Four layouts — across, down, timeline or list: phases laid across the pane, stacked down it, drawn as bars against the clock, or given a row per agent — and twelve palettes, nine dark and three light. A node is the same size in all of them at every size of pane; what the pane cannot hold it scrolls to.

Install

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:

  • Function hooks are early access, so CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 is required.
  • Under /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.

Controls

Everything sits under the drawing. Click a button, or Tab to it and press Enter.

ControlWhat it does
A node's labelopens that agent's detail dialog; click again to close
⚙ Settingsopens the settings dialog over the drawing
flowpane 0.10.0the 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 valueunrolls that setting's list where it stands; press it again to roll the list up
The graph, with any dialog opentakes no presses — it is pushed back behind the dialog until the dialog shuts
Layoutacross, 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
Themetwelve 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 dialogshows 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 rowopens that attempt's detail: ✔ 1 ✔ 2 ✖ 3 is one row and three presses
The ▸ on a nested run's name, or its ▸ 23 agentsunfolds 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 panemoves the drawing, or the open dialog while one is open
The wheel, over the rail along the footmoves the drawing sideways — as does the wheel anywhere over a drawing that only goes that way
The run's nameopens 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 panedraws it. With nothing running the pane lists the session's runs in place of the graph, grouped and timed the same way

Settings

/plugin configure flowpane, or pluginConfigs in settings.json:

FieldValuesWhat it does
orientationhorizontal, vertical, timeline, list, or auto (default) — the last spelled fits in /flowpane and in the settingshorizontal 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.
detailRows5–32 (default 24)Rows the detail dialog takes when a node is selected, capped at what the pane can inset.
paneRows6–80 (0 = let the surface decide)Rows the pane asks for. A dock beside the transcript usually picks its own height.
themetokyo-night (default), catppuccin, gruvbox, nord, dracula, solarized, monokai, vscode-dark, insider-one, github-light, solarized-light, insider-one-lightThe 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 demo workflow

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.

Working on it

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.

Documentation

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:

DocumentWhat it covers
docs/pane.mdWhich seat the pane takes, and the idle pane
docs/controls.mdEvery control, the bottom row, and the dialogs
docs/settings.mdThe four settings, and how a theme becomes a palette
docs/header.mdThe run line, the phase names, and what moves
docs/nodes.mdWhat a node says, and the four states
docs/graph.mdWhat the files state, what the pane infers, and how each is drawn
docs/detail.mdThe dialog a node opens
docs/data.mdThe files a run writes, and recovering a run already going
docs/demo-workflow.mdThe audit run under dev/
docs/engine.mdWhat the function-hooks API allows, and what it does not
docs/development.mdThe dev tools, and what the tests measure

Status

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.

License

MIT. See LICENSE.

Source 10 files
hooks/register.ts 1843 lines
1/**
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 lines
1/**
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}
593
hooks/journal.ts 1028 lines
1/**
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}
1028
hooks/about.ts 155 lines
1/**
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}
155
hooks/layout.ts 872 lines
1/**
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}
872
hooks/paint.ts 9533 lines
1/**
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 lines
1/**
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}
660
hooks/theme.ts 442 lines
1/**
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}
442
hooks/tree.ts 275 lines
1/**
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}
275
hooks/shape.ts 1273 lines
1/**
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[] {