A side pane that shows Claude Code's agent loop as it runs: every hook event, a flow chart of the loop, a span timeline of each turn, and the tokens and…

A teaching tool for Claude Code's agent loop, built on Claude Code mods.

Mods are a new kind of Claude Code plugin: TypeScript that hooks the events Claude Code raises at every step of its loop, and can draw its own panes in the terminal. hook-flow demonstrates what that technology can do as of October 2026, on Claude Code 2.1.294–2.1.296. The mod API is new and may change, so treat this as a snapshot of that moment rather than a maintained product.
It's meant for learning. It opens a side pane that shows what happens between pressing Enter and getting an answer:
Click any step to read what it is and what a mod could do there. The code is kept small and commented so you can read how each piece is built.
hook-flow only observes. It never changes what Claude Code does, and it adds nothing to the model's context. It was written as reference material for a post on legible.co.
In a Claude Code terminal session:
/plugin install hook-flow --marketplace legibleco/hook-flow
Answer y to add the marketplace, press Enter to install at user scope, and accept the default settings. Then open the pane:
/flow
The pane also opens by itself at the start of a session when the terminal is wide enough.
Requires Claude Code 2.1.294 or later, in a terminal (built and tested on 2.1.294–2.1.296). hook-flow is a mod, a plugin built on Claude Code's function hooks, so older versions can't load it. Hover and wheel zoom need a terminal that reports mouse movement; everything else works from the keyboard.
Flow chart. The loop as boxes: prompt → context → turn start → model ⇄ tool call → permission → transcript → turn done, plus side boxes for subagents, compaction, slash commands and UI. A box glows when one of its events fires, the arrow between two boxes lights as the loop moves along it, and each box keeps a count.
Explainer. Click a box (or press e) and the bottom of the pane turns into a two-column guide: the steps on the left, and on the right what this step is, which events it covers, what a hook here can do, a short code example, and what the step has seen this session.
Timeline. Press v. The latest turn is laid out in time, one row per step, each event a bar from when its hook started to when it returned. Hover a bar to light it and everything that ran inside it; click to pin it and see its timing, data size and the plugins on it. The wheel zooms around the pointer.
Replay. Press t to play the last turn back on the flow chart, one step at a time.
Header and stats. A running dollar odometer and context gauge, a table of every event with a sparkline, count, average time and size, the plugins that took part in the last tool call, and tokens per model request.
The pane takes keys once it has focus: click it, or press ctrl+x tab. A green keys badge shows when it has them, and esc hands them back.
| Where | Key | Does |
|---|---|---|
| Anywhere | p / r / u / v | pause, reset counts, count UI events, switch flow ⇄ timeline |
| Flow chart | e, or click a box | open the explainer |
t | replay the last turn | |
| Explainer | ↑ ↓ | step through the boxes |
1–8, a c s i | jump to a box | |
x | close | |
| Timeline | hover, click | light a span, pin it |
wheel, z / o / f | zoom in, out, fit the whole turn | |
↑ ↓, b / n | step through the turn's spans | |
e / x | explain the pinned span's step, unpin |
Set at install, or later in /config:
| Setting | Default | |
|---|---|---|
| Count UI events | off | Also count ui.* events (renders, presses, scrolls). They're noisy, and while this is on every draw on screen calls into the plugin. |
| Cost alert ($) | 0.5 | Flash the odometer and show a toast when one turn costs at least this much. |
Claude Code raises an event at every step of its loop, and a plugin hooks one with on(event, hook). Each hook gets ($, e, next): the engine interface, the event's input, and next, which runs everything beneath it. hook-flow hooks every event that isn't UI with one hook that notes the time, calls next(e), and measures again when it returns. That's all a span is: a hook's start and finish around next. Simplified:
on('!ui.*', async ($, e, next) => {
const startedAt = Date.now()
flow.hit(next.event, startedAt) // light the box
const result = await next(e) // the rest of the loop runs here
measure(next.event, startedAt, e, result) // time, size, plugins
return result // unchanged
})
The pane is drawn by a ui.render hook. The flow chart and timeline are drawn by a small surface module, chart.tsx, which receives mouse clicks and hovers and posts them back to the hooks module.
| File | |
|---|---|
hooks/register.tsx | The hooks: the observer above, token and cost tracking, the /flow command, and the pane |
hooks/flow.ts | The flow chart: boxes, arrows, which event lights which box |
hooks/timeline.ts | The timeline: span layout in lanes, zoom, hit-testing |
hooks/explain.ts | The explainer text for each box |
hooks/chart.tsx | The surface module that draws the chart and timeline and reports the pointer |
hooks/register.test.ts | Tests, run against Claude Code's own engine |
Everything stays on your machine. The plugin makes no network calls, and nothing it records reaches the model.
Clone the repository and, from its folder, start a session with your copy loaded; it reloads when you save:
claude --plugin-dir .
Check a change with:
claude plugin validate .
claude plugin test .
Claude Code writes the API's type definitions into .claude-plugin/types/ the first time it loads the plugin. After that, npx -p typescript tsc -p . type-checks it, and an editor gets completions. Those files are generated, so they're not checked in.
AGENTS.md has the conventions for changing the code with a coding agent.
hooks/register.tsx 798 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer, TraceEntry } from 'claude-code'
3
4import type { Snapshot } from '../types'
5import { EXPLAIN, NODE_BY_KEY } from './explain'
6import type { ChartProps } from './chart'
7import { FLOW_COLUMNS, FLOW_ROWS, Flow, NODES, nodeAt, nodeOf } from './flow'
8import { LABEL_WIDTH, follow, heightOf, insideOf, layout, seconds, spansAt, timelineRows, wholeOf, zoomBy } from './timeline'
9import type { Span, Window } from './timeline'
10
11const PANE = 'hook-flow'
12const SECONDS = 12
13const BARS = '▁▂▃▄▅▆▇█'
14const EIGHTHS = ' ▏▎▍▌▋▊▉█'
15
16// The pane reads `tick`, so a write redraws the pane alone; $.ui.invalidate
17// would redraw every site this plugin hooks, every site on screen with UI on.
18const tick = atom({ plugin: 'hook-flow', key: 'tick' } as const, 0)
19const snapshot = atom({ plugin: 'hook-flow', key: 'snapshot' } as const, null as Snapshot | null)
20
21export type EventStats = {
22 n: number
23 ms: number
24 bytes: number
25 lastAt: number
26 perSecond: Map<number, number>
27 plugins: Set<string>
28}
29export type Step = { input: number; output: number; cached: number }
30export type Turn = { usd: number; tokens: number }
31export type Seen = { event: string; node: string; detail: string; at: number }
32export type Call = { tool: string; ms: number; links: { plugin: string; ms: number; outcome: string }[] }
33
34export const stats = new Map<string, EventStats>()
35export const steps: Step[] = []
36export const turns: Turn[] = []
37export const flow = new Flow()
38let view: 'flow' | 'timeline' = 'flow'
39let selected: string | undefined
40export const seen = new Map<string, Seen>()
41export const history = new Map<string, Seen[]>()
42let recording: Seen[] = []
43export let lastTurnSteps: Seen[] = []
44let replay: { steps: Seen[]; index: number; flow: Flow; timer: Timer } | undefined
45let lastCall: Call | undefined
46export let spans: Span[] = []
47let isRecordingSpans = false
48let turnDepth = 0
49let nextSpanId = 1
50let hoveredId: number | undefined
51let hoverCount = 0
52let pinnedId: number | undefined
53let zoom: Window | undefined
54let paneOffset = 0
55// The pane's rows above the chart: the odometer, then the buttons.
56const CHART_TOP = 2
57let sessionUsd: number | undefined
58let shownUsd = 0
59let flashUntil = 0
60let contextPercent: number | undefined
61let isDirty = false
62let isPaused = false
63let lastActivityAt = 0
64let chartColumns = 0
65let odometer: Timer | undefined
66
67export const sizeOf = (value: unknown): number => {
68 try {
69 return JSON.stringify(value)?.length ?? 0
70 } catch {
71 return 0
72 }
73}
74
75export const record = (event: string, ms: number, bytes: number, plugins: string[], now: number) => {
76 const one = stats.get(event) ?? {
77 n: 0, ms: 0, bytes: 0, lastAt: 0, perSecond: new Map<number, number>(), plugins: new Set<string>(),
78 }
79 const second = Math.floor(now / 1000)
80 one.n += 1
81 one.ms += ms
82 one.bytes += bytes
83 one.lastAt = now
84 one.perSecond.set(second, (one.perSecond.get(second) ?? 0) + 1)
85 for (const old of one.perSecond.keys()) if (old <= second - SECONDS) one.perSecond.delete(old)
86 for (const plugin of plugins) one.plugins.add(plugin)
87 stats.set(event, one)
88 lastActivityAt = now
89 isDirty = true
90}
91
92export const sparkline = (values: readonly number[]): string => {
93 const top = Math.max(...values, 0)
94 return values.map(v => (v === 0 ? ' ' : BARS[Math.min(7, Math.floor((v / top) * 7.999))])).join('')
95}
96
97// A horizontal bar `width` cells wide at most, in eighths of a cell.
98export const bar = (value: number, top: number, width: number): string => {
99 const eighths = top <= 0 ? 0 : Math.round((Math.min(value, top) / top) * width * 8)
100 return '█'.repeat(Math.floor(eighths / 8)) + (eighths % 8 ? EIGHTHS[eighths % 8] : '')
101}
102
103const recentSeconds = (one: EventStats, now: number) => {
104 const second = Math.floor(now / 1000)
105 return Array.from({ length: SECONDS }, (_, i) => one.perSecond.get(second - SECONDS + 1 + i) ?? 0)
106}
107
108const short = (n: number): string =>
109 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${Math.round(n)}`
110
111const duration = (ms: number): string => (ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`)
112
113const pad = (text: string, width: number) =>
114 text.length > width ? `${text.slice(0, width - 1)}…` : text.padEnd(width)
115
116export const reset = () => {
117 stats.clear()
118 steps.length = 0
119 turns.length = 0
120 lastCall = undefined
121 isDirty = true
122}
123
124const measure = (event: string, startedAt: number, e: unknown, result: unknown, trace: readonly TraceEntry[]) => {
125 const plugins = trace.map(link => link.plugin).filter(p => p !== 'engine' && p !== 'hook-flow')
126 const bytes = sizeOf(e) + sizeOf(result)
127 record(event, Date.now() - startedAt, bytes, plugins, Date.now())
128 return { bytes, plugins }
129}
130
131// The spans of the turn in progress, or of the last one: a prompt starts a
132// fresh set, and the turn that answers it completing closes it.
133export const startSpan = (event: string, detail: string, now: number): Span | undefined => {
134 const node = nodeOf(event)
135 if (!node || node === 'ui') return undefined
136 if (event === 'prompt.submit') {
137 spans = []
138 isRecordingSpans = true
139 turnDepth = 0
140 hoveredId = undefined
141 pinnedId = undefined
142 zoom = undefined
143 }
144 if (!isRecordingSpans || spans.length >= 600) return undefined
145 if (event === 'turn.start') turnDepth += 1
146 const span: Span = { id: nextSpanId++, event, node, detail, start: now, bytes: 0, plugins: [] }
147 spans.push(span)
148 return span
149}
150
151export const endSpan = (span: Span | undefined, bytes: number, plugins: string[], now: number) => {
152 if (!span) return
153 span.end = now
154 span.bytes = bytes
155 span.plugins = plugins
156 if (span.event === 'turn.complete') {
157 turnDepth -= 1
158 if (turnDepth <= 0) isRecordingSpans = false
159 }
160}
161
162export const toSnapshot = (): Snapshot => ({
163 events: [...stats].map(([event, one]) => ({
164 event, n: one.n, ms: one.ms, bytes: one.bytes, lastAt: one.lastAt,
165 perSecond: [...one.perSecond], plugins: [...one.plugins],
166 })),
167 steps: [...steps],
168 turns: [...turns],
169 isPaused,
170})
171
172export const fromSnapshot = (saved: Snapshot) => {
173 reset()
174 for (const one of saved.events) {
175 stats.set(one.event, { ...one, perSecond: new Map(one.perSecond), plugins: new Set(one.plugins) })
176 }
177 steps.push(...saved.steps)
178 turns.push(...saved.turns)
179 isPaused = saved.isPaused
180}
181
182// Flips the UI option. The engine reloads the module with the new value,
183// which drops module variables, so the counts go to $.state first.
184async function toggleUi($: EngineInterface, isOn: boolean) {
185 await update($, snapshot, () => toSnapshot())
186 const row = (await $.config.list()).find(one => one.key.endsWith('.watchUi') && one.key.startsWith('hook-flow'))
187 const done = row && (await $.config.set({ key: row.key, value: !isOn }))
188 if (!done || 'deny' in done) {
189 await update($, snapshot, () => null)
190 $.ui.toast(`hook-flow: couldn't switch UI events${done && 'deny' in done ? `: ${done.deny}` : ''}`)
191 }
192}
193
194// A box's count: every event it stands for, from the per-event stats; in a
195// replay, the steps replayed so far.
196export const countOf = (id: string): number => {
197 let n = 0
198 if (replay) {
199 for (const step of replay.steps.slice(0, replay.index + 1)) if (step.node === id) n += 1
200 return n
201 }
202 for (const [event, one] of stats) if (nodeOf(event) === id) n += one.n
203 return n
204}
205
206const keyOf = (id: string) => EXPLAIN[id]?.key ?? ''
207
208// The last thing each box saw, and the steps of the turn in progress: a
209// prompt starts a recording, the turn's completion files it for replay.
210export const note = (event: string, detail: string, now: number) => {
211 const node = nodeOf(event)
212 if (!node) return
213 const step: Seen = { event, node, detail, at: now }
214 seen.set(node, step)
215 history.set(node, [step, ...(history.get(node) ?? [])].slice(0, 5))
216 if (node === 'ui') return
217 if (event === 'prompt.submit') recording = []
218 recording.push(step)
219 if (recording.length > 400) recording.shift()
220 if (event === 'turn.complete') {
221 lastTurnSteps = recording
222 recording = []
223 }
224}
225
226const activeFlow = () => replay?.flow ?? flow
227
228// The chart and the timeline need their full width; narrower, neither shows.
229const shownView = (columns: number) => (columns < FLOW_COLUMNS ? 'none' : view)
230
231export const chartProps = (columns: number, now: number): ChartProps => ({
232 // The keys that jump to a box only work, and only show, while explaining.
233 rows: activeFlow().rows(columns, now, countOf, selected ? keyOf : undefined, selected),
234 isHot: activeFlow().isHot(now),
235})
236
237const nodeColor = (id: string) => NODES.find(n => n.id === id)?.color ?? 0xdfe6e9
238const hex = (n: number) => `#${n.toString(16).padStart(6, '0')}`
239
240// The span the timeline lights: the one under the pointer, else the pinned one.
241const focusSpan = () => spans.find(s => s.id === (hoveredId ?? pinnedId))
242
243const readout = (focus: Span | undefined, now: number): string => {
244 if (spans.length === 0) return 'Waiting for a prompt…'
245 if (!focus) return `${spans.length} events · hover a bar · click to pin`
246 const stack = focus.id === hoveredId && hoverCount > 1 ? `×${hoverCount} ` : ''
247 const t0 = spans[0]?.start ?? focus.start
248 return `${stack}${focus.event}${focus.detail ? ` · ${focus.detail}` : ''} · ${seconds((focus.end ?? now) - focus.start)} · +${seconds(focus.start - t0)}`
249}
250
251export const shapeOf = (columns: number, now: number) => layout(spans, columns, now, zoom)
252
253// Zooms the timeline around a point of its field (a fraction), by default
254// the pinned span when it shows, else the middle.
255export const zoomTimeline = (factor: number, columns: number, now: number, anchor?: number) => {
256 const shape = shapeOf(columns, now)
257 const pinned = spans.find(s => s.id === pinnedId)
258 const mid = pinned ? ((pinned.start + (pinned.end ?? now)) / 2 - shape.t0) / (shape.t1 - shape.t0) : 0.5
259 zoom = zoomBy(zoom, shape.whole, factor, anchor ?? Math.max(0, Math.min(1, mid)))
260}
261
262export const zoomOf = () => zoom
263
264// The timeline's rows and, beneath them, a line on the span in focus.
265export const timelineProps = (columns: number, now: number): ChartProps => {
266 const focus = focusSpan()
267 const rows = timelineRows(spans, shapeOf(columns, now), columns, now, focus)
268 rows.push([[pad(readout(focus, now), columns), focus ? nodeColor(focus.node) : 0x7f8c8d]])
269 return { rows, isHot: isRecordingSpans }
270}
271
272// Moves the pin along the turn, in the order the spans started.
273export const pinFrom = (by: number) => {
274 if (spans.length === 0) return
275 const at = spans.findIndex(s => s.id === pinnedId)
276 const next = at < 0 ? (by > 0 ? 0 : spans.length - 1) : Math.max(0, Math.min(spans.length - 1, at + Math.sign(by)))
277 pinnedId = spans[next]?.id
278 const pinned = spans[next]
279 if (pinned) zoom = follow(zoom, pinned, Date.now())
280}
281
282const NARROW = 52
283const WIDE = 72
284
285// Opens or closes explain mode on a box; the pane asks for more room while
286// it explains, and gives it back after (a width the person dragged wins).
287function select($: EngineInterface, id: string | undefined) {
288 const wasOpen = selected !== undefined
289 selected = selected === id ? undefined : id
290 const isOpen = selected !== undefined
291 if (wasOpen !== isOpen) {
292 $.ui.open({ id: PANE, title: 'Hook flow', columns: isOpen ? WIDE : NARROW }).catch(() => undefined)
293 }
294 return update($, tick, n => n + 1)
295}
296
297// The boxes in loop order, as their keys run: what ↑ and ↓ step through.
298export const ORDER = Object.keys(EXPLAIN)
299
300export const stepFrom = (from: string | undefined, by: number): string => {
301 const at = from === undefined ? -1 : ORDER.indexOf(from)
302 const next = (at + Math.sign(by) + ORDER.length) % ORDER.length
303 return ORDER[next] ?? ORDER[0] ?? 'prompt'
304}
305
306// Where explain mode opens: the box that fired last, else the prompt.
307const latestBox = () => {
308 let best: Seen | undefined
309 for (const one of seen.values()) if (one.node !== 'ui' && (!best || one.at > best.at)) best = one
310 return best?.node ?? 'prompt'
311}
312
313function stopReplay() {
314 replay?.timer.cancel()
315 replay = undefined
316}
317
318// Plays the last finished turn back one step every 450 ms on a chart of its
319// own, so each step lights in order; pressing again stops it.
320function toggleReplay($: EngineInterface) {
321 if (replay) {
322 stopReplay()
323 return update($, tick, n => n + 1)
324 }
325 if (lastTurnSteps.length === 0) {
326 $.ui.toast('hook-flow: no finished turn to replay yet')
327 return
328 }
329 view = 'flow'
330 const steps = lastTurnSteps
331 const played = new Flow()
332 const timer = $.clock.every(450, () => {
333 if (!replay) return
334 replay.index += 1
335 const step = replay.steps[replay.index]
336 if (!step) {
337 stopReplay()
338 } else {
339 replay.flow.hit(step.event, Date.now())
340 }
341 void update($, tick, n => n + 1)
342 })
343 replay = { steps, index: -1, flow: played, timer }
344 return update($, tick, n => n + 1)
345}
346
347// Rolls the shown total toward the real one, a few redraws a second.
348function rollOdometer($: EngineInterface) {
349 if (odometer) return
350 odometer = $.clock.every(60, () => {
351 const target = sessionUsd ?? 0
352 shownUsd += (target - shownUsd) * 0.3
353 if (Math.abs(target - shownUsd) < 0.0005) {
354 shownUsd = target
355 odometer?.cancel()
356 odometer = undefined
357 }
358 void update($, tick, n => n + 1)
359 })
360}
361
362export const register: Register = (on, options) => {
363 const isWatchingUi = options.watchUi === true
364 const costAlert = typeof options.costAlert === 'number' ? options.costAlert : 0.5
365
366 // Every event but the ui.* ones, timed and sized; our own $ calls are left
367 // out, so the widget never counts itself.
368 on('!ui.*', async ($, e, next) => {
369 const isOwn = isPaused || next.origin.plugin === 'hook-flow'
370 const startedAt = Date.now()
371 let span: Span | undefined
372 if (!isOwn) {
373 flow.hit(next.event, startedAt)
374 const detail = next.is('tool.call', e) ? e.tool
375 : next.is('turn.step', e) ? `${e.model} · ${e.messageCount} messages`
376 : next.is('prompt.submit', e) ? `${e.text.length} characters`
377 : next.is('agent.spawn', e) ? (e.subagentType ?? 'agent')
378 : next.is('command.run', e) ? `/${e.command}`
379 : ''
380 note(next.event, detail, startedAt)
381 span = startSpan(next.event, detail, startedAt)
382 }
383 const result = await next(e)
384 if (!isOwn) {
385 const { bytes, plugins } = measure(next.event, startedAt, e, result, next.trace)
386 endSpan(span, bytes, plugins, Date.now())
387 if (next.is('tool.call', e)) {
388 lastCall = {
389 tool: e.tool,
390 ms: Date.now() - startedAt,
391 links: next.trace.map(link => ({ plugin: link.plugin, ms: link.ms, outcome: link.outcome })),
392 }
393 }
394 }
395 return result
396 }).catch(($, e, next) => next(e))
397
398 // The ui.* events, only while the option is on: registered at all, this
399 // hook makes every site on screen call into the module as it draws.
400 if (isWatchingUi) {
401 on('ui.*', async ($, e, next) => {
402 const isOwn =
403 isPaused ||
404 next.origin.plugin === 'hook-flow' ||
405 (next.is('ui.render', e) && e.requestId === PANE) ||
406 (next.is('ui.message', e) && e.requestId === PANE)
407 const startedAt = Date.now()
408 if (!isOwn) {
409 flow.hit(next.event, startedAt)
410 note(next.event, next.is('ui.render', e) ? e.component : '', startedAt)
411 }
412 const result = await next(e)
413 if (!isOwn) {
414 measure(next.event, startedAt, e, result, next.trace)
415 }
416 return result
417 }).catch(($, e, next) => next(e))
418 }
419
420 on('turn.step', async function* ($, e, next) {
421 const result = yield* next(e)
422 if (result.usage && !isPaused) {
423 const u = result.usage
424 steps.push({
425 input: u.input_tokens + u.cache_creation_input_tokens,
426 cached: u.cache_read_input_tokens,
427 output: u.output_tokens,
428 })
429 if (steps.length > 200) steps.shift()
430 contextPercent = (await $.session.usage()).context.percent ?? contextPercent
431 isDirty = true
432 }
433 return result
434 })
435
436 on('turn.complete', async ($, e, next) => {
437 const done = await next(e)
438 const usage = await $.session.usage()
439 const usd = usage.cost?.usd ?? sessionUsd ?? 0
440 const tokens = steps.reduce((sum, s) => sum + s.input + s.output, 0)
441 const spent = usd - (sessionUsd ?? usd)
442 turns.push({ usd: spent, tokens })
443 if (turns.length > 100) turns.shift()
444 sessionUsd = usd
445 contextPercent = usage.context.percent
446 if (spent >= costAlert) {
447 flashUntil = Date.now() + 3000
448 $.ui.toast(`hook-flow: that turn cost $${spent.toFixed(2)}`)
449 }
450 rollOdometer($)
451 isDirty = true
452 return done
453 })
454
455 on('session.start', async ($, e, next) => {
456 const saved = await read($, snapshot)
457 if (saved) {
458 fromSnapshot(saved)
459 await update($, snapshot, () => null)
460 }
461 const usage = await $.session.usage()
462 sessionUsd = usage.cost?.usd
463 shownUsd = sessionUsd ?? 0
464 contextPercent = usage.context.percent
465 await $.command.register({ name: 'flow', description: 'Show the hook-flow pane: every hook event, tokens and $' })
466 // The table redraws at most twice a second, and keeps sliding its
467 // sparklines while anything happened in the window they show.
468 $.clock.every(500, () => {
469 const isSliding = Date.now() - lastActivityAt < SECONDS * 1000
470 if (isDirty || isSliding || Date.now() < flashUntil + 500) {
471 isDirty = false
472 void update($, tick, n => n + 1)
473 }
474 })
475 void $.ui.open({ id: PANE, title: 'Hook flow', columns: selected ? WIDE : NARROW })
476 return next(e)
477 })
478
479 // The charts' posts: a frame request answered with fresh rows; on the flow
480 // chart a click opens the box under it; on the timeline a hover lights the
481 // span under it and a click pins it.
482 on('ui.message', { requestId: PANE }, async ($, e, next) => {
483 const data = e.data as { kind?: string; x?: number; y?: number } | null
484 const now = Date.now()
485 // A click on the chart asks for the pane's keys, so the pane's own focus
486 // (which the badge shows, and Escape hands back) is the only one there is.
487 // Claude Code grants it over an empty prompt; otherwise the keys stay put.
488 if (data?.kind === 'click') void $.ui.open({ id: PANE, title: 'Hook flow', focus: true }).catch(() => undefined)
489 // One Client draws both views, so switching keeps it; the view says which.
490 if (shownView(chartColumns) === 'timeline') {
491 const isAt = typeof data?.x === 'number' && typeof data.y === 'number'
492 const under = isAt && data.x! >= 0 ? spansAt(shapeOf(chartColumns, now), data.x!, data.y!) : []
493 if (data?.kind === 'hover') {
494 hoveredId = under[0]?.id
495 hoverCount = under.length
496 }
497 if (data?.kind === 'click' && under[0]) {
498 pinnedId = pinnedId === under[0].id ? undefined : under[0].id
499 await update($, tick, n => n + 1)
500 }
501 return { props: timelineProps(chartColumns, now) }
502 }
503 if (data?.kind === 'hover') return {}
504 if (data?.kind === 'frame') return { props: chartProps(chartColumns, now) }
505 if (data?.kind === 'click' && typeof data.x === 'number' && typeof data.y === 'number') {
506 const id = nodeAt(data.x, data.y, chartColumns)
507 if (id) await select($, id)
508 return {}
509 }
510 return next(e)
511 })
512
513 // In explain mode the scroll keys step through the boxes instead of
514 // scrolling; the wheel (it carries a pointer) still scrolls the pane.
515 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
516 // The wheel over the timeline zooms it around the pointer: up in, down out.
517 if (e.pointer && e.by !== 0 && shownView(chartColumns) === 'timeline') {
518 const now = Date.now()
519 const shape = shapeOf(chartColumns, now)
520 const row = e.pointer.row + paneOffset - CHART_TOP
521 const x = e.pointer.column - LABEL_WIDTH
522 if (row >= 0 && row < heightOf(shape) && x >= 0) {
523 zoomTimeline(e.by < 0 ? 0.7 : 1 / 0.7, chartColumns, now, x / shape.field)
524 await update($, tick, n => n + 1)
525 return {}
526 }
527 }
528 if (e.pointer !== undefined || e.by === 0) return next(e)
529 if (!selected && shownView(chartColumns) === 'timeline' && spans.length > 0) {
530 pinFrom(e.by)
531 await update($, tick, n => n + 1)
532 return {}
533 }
534 if (!selected) return next(e)
535 selected = stepFrom(selected, e.by)
536 await update($, tick, n => n + 1)
537 return {}
538 })
539
540 on('command.run', { command: 'flow' }, async $ => {
541 await $.ui.open({ id: PANE, title: 'Hook flow', columns: selected ? WIDE : NARROW })
542 return { text: 'Hook flow pane opened.' }
543 })
544
545 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
546 const { Box, Text, Button, Code } = $.ui.resolve(e)
547 const count = await read($, tick)
548 const now = Date.now()
549 const width = Math.max(30, e.props.bodyColumns)
550 const nameWidth = width - SECONDS - 18
551 const callRows = lastCall ? Math.min(4, lastCall.links.length) + 1 : 1
552 const hasChart = e.surface === 'terminal'
553 const hasKeys = e.props.isFocused
554 const shown = shownView(width)
555 const shape = shapeOf(width, now)
556 paneOffset = e.props.scroll.offset
557 const chartRows = shown === 'flow' ? FLOW_ROWS : shown === 'timeline' ? heightOf(shape) + 1 : 1
558 const pinned = shown === 'timeline' ? spans.find(s => s.id === pinnedId) : undefined
559 const explainer = selected ? EXPLAIN[selected] : undefined
560 const room = Math.max(3, e.props.scroll.bodyRows - 14 - callRows - (hasChart ? chartRows : 0))
561
562 const rows = [...stats.entries()].sort((a, b) => b[1].lastAt - a[1].lastAt).slice(0, room)
563 const lastStep = steps.at(-1)
564 const lastTurn = turns.at(-1)
565
566 // The odometer: amber while it rolls, a blinking alert after a costly turn.
567 const isFlashing = now < flashUntil
568 const usd = sessionUsd === undefined ? '$—.———' : `$${shownUsd.toFixed(3)}`
569 const usdColor = isFlashing ? 'red' : odometer !== undefined ? 'yellow' : undefined
570
571 // The context gauge, green to amber to red as it fills toward compaction.
572 const gaugeWidth = 14
573 const pct = contextPercent ?? 0
574 const gaugeColor = pct >= 80 ? 'red' : pct >= 60 ? 'yellow' : 'green'
575 const filled = bar(pct, 100, gaugeWidth)
576
577 const links = lastCall?.links.filter(l => l.outcome !== 'skipped').slice(0, 4) ?? []
578 const slowest = Math.max(1, ...links.map(l => l.ms))
579 const linkBar = Math.max(6, width - 22)
580
581 let chartView
582 if (hasChart) {
583 chartColumns = width
584 if (shown !== 'none') {
585 const { Client } = $.ui.resolve(e)
586 const props = shown === 'flow' ? chartProps(width, now) : timelineProps(width, now)
587 chartView = <Client key="chart" module="./chart.tsx" props={props} width={width} height={chartRows} />
588 } else {
589 chartView = <Text dimColor>Widen the pane to see the chart.</Text>
590 }
591 }
592
593 return (
594 <Box flexDirection="column">
595 <Box flexDirection="row" gap={1}>
596 <Text bold color={usdColor} inverse={isFlashing && count % 2 === 0}>{usd}</Text>
597 <Text dimColor>ctx</Text>
598 <Box flexDirection="row">
599 <Text color={gaugeColor}>{filled}</Text>
600 <Text dimColor>{'░'.repeat(Math.max(0, gaugeWidth - filled.length))}</Text>
601 </Box>
602 <Text color={gaugeColor}>{contextPercent === undefined ? '—' : `${pct}%`}</Text>
603 {hasKeys
604 ? <Text color="black" backgroundColor="green" bold>{' ⌨ keys · esc '}</Text>
605 : <Text dimColor>{'click for keys'}</Text>}
606 </Box>
607 <Box flexDirection="row" gap={2}>
608 <Button key="pause" label={isPaused ? 'resume' : 'pause'} hotkey="p" plain
609 onPress={() => { isPaused = !isPaused; return update($, tick, n => n + 1) }} />
610 <Button key="reset" label="reset" hotkey="r" plain
611 onPress={() => { reset(); return update($, tick, n => n + 1) }} />
612 <Button key="ui" label={isWatchingUi ? 'ui: on' : 'ui: off'} hotkey="u" plain
613 dimColor={!isWatchingUi} onPress={() => toggleUi($, isWatchingUi)} />
614 {hasChart && (
615 <Button key="view" label={`view: ${view}`} hotkey="v" plain
616 onPress={() => {
617 view = view === 'flow' ? 'timeline' : 'flow'
618 return update($, tick, n => n + 1)
619 }} />
620 )}
621 </Box>
622 {chartView}
623 {replay && (() => {
624 const step = replay.steps[Math.max(0, replay.index)]
625 const first = replay.steps[0]?.at ?? 0
626 const title = step ? EXPLAIN[step.node]?.title ?? step.node : ''
627 return (
628 <Text color="yellow">
629 {step
630 ? `▶ ${replay.index + 1}/${replay.steps.length} · ${title}${step.detail ? ` · ${step.detail}` : ''} · +${duration(step.at - first)}`
631 : `▶ replaying ${replay.steps.length} steps…`}
632 </Text>
633 )
634 })()}
635 {hasChart && shown === 'flow' && !explainer && (
636 <Box flexDirection="row" gap={1}>
637 <Button key="explain" label="? explain" hotkey="e" variant="primary" onPress={() => select($, latestBox())} />
638 <Button key="replay" label={replay ? '■ stop' : '▶ replay'} hotkey="t" plain
639 onPress={() => toggleReplay($)} />
640 </Box>
641 )}
642 {hasChart && shown === 'flow' && (
643 <Text dimColor italic>
644 {!e.props.isFocused
645 ? 'click a box to explain it, or ctrl+x tab then e'
646 : explainer
647 ? '↑↓ step · 1–8 a c s i jump · t replay · x close'
648 : 'click a box or e to explain · t replay the last turn'}
649 </Text>
650 )}
651 {hasChart && shown === 'timeline' && spans.length > 0 && (
652 <Box flexDirection="row" gap={2}>
653 <Button key="zoom:in" label="z zoom in" hotkey="z" plain
654 onPress={() => { zoomTimeline(0.5, chartColumns, Date.now()); return update($, tick, n => n + 1) }} />
655 <Button key="zoom:out" label="o out" hotkey="o" plain dimColor={!zoom}
656 onPress={() => { zoomTimeline(2, chartColumns, Date.now()); return update($, tick, n => n + 1) }} />
657 {zoom && (
658 <Button key="zoom:fit" label="f fit" hotkey="f" plain
659 onPress={() => { zoom = undefined; return update($, tick, n => n + 1) }} />
660 )}
661 </Box>
662 )}
663 {hasChart && shown === 'timeline' && !explainer && (
664 <Text dimColor italic>
665 {!e.props.isFocused
666 ? 'hover a bar to light what ran inside it · click to pin · wheel zooms'
667 : pinned
668 ? '↑↓ or b n step through the turn · e explain · x unpin'
669 : 'hover a bar · click to pin · wheel or z o zoom · ↑↓ step'}
670 </Text>
671 )}
672 {explainer && selected ? (() => {
673 // Explain mode: the boxes as a list on the left, the chosen one's
674 // details on the right, in place of the stats below.
675 const color = hex(NODES.find(n => n.id === selected)?.color ?? 0xffffff)
676 const events = [...stats].filter(([event]) => nodeOf(event) === selected)
677 const recent = history.get(selected) ?? []
678 return (
679 <Box flexDirection="row" gap={1} marginTop={1}>
680 <Box flexDirection="column" width={19} flexShrink={0}>
681 {ORDER.map(id => NODES.find(n => n.id === id)).filter(n => n !== undefined).map(node => {
682 const one = EXPLAIN[node.id]
683 const n = countOf(node.id)
684 return (
685 <Button key={`explain:${node.id}`} plain hotkey={one?.key}
686 dimColor={node.id !== selected}
687 label={`${node.id === selected ? '▸' : ' '}${one?.key ?? ' '} ${pad(node.label, 10)}${(n ? short(n) : '').padStart(5)}`}
688 onPress={() => select($, node.id)} />
689 )
690 })}
691 <Text> </Text>
692 <Button key="replay" label={replay ? '■ stop replay' : '▶ replay turn'} hotkey="t" plain
693 onPress={() => toggleReplay($)} />
694 <Button key="close" label="✕ close" hotkey="x" plain dimColor onPress={() => select($, undefined)} />
695 </Box>
696 <Box flexDirection="column" flexGrow={1} borderStyle="round" borderColor={color} paddingX={1}>
697 <Text bold color={color}>{explainer.title}</Text>
698 <Text>{explainer.what}</Text>
699 <Text> </Text>
700 <Text bold>A hook here can</Text>
701 <Text>{explainer.can}</Text>
702 <Code source={explainer.snippet} language="ts" />
703 <Text bold>Events</Text>
704 {events.length === 0 && <Text dimColor>{`${explainer.events} · not seen yet`}</Text>}
705 {events.map(([event, one]) => (
706 <Text>
707 {`${pad(event, 24)}${short(one.n).padStart(6)} ${duration(one.ms / one.n).padStart(6)} avg`}
708 </Text>
709 ))}
710 <Text bold>Recently</Text>
711 {recent.length === 0 && <Text dimColor>nothing yet this session</Text>}
712 {recent.map(step => (
713 <Text dimColor>
714 {`${duration(now - step.at).padStart(7)} ago ${step.detail || step.event}`}
715 </Text>
716 ))}
717 </Box>
718 </Box>
719 )
720 })() : pinned ? (() => {
721 // A pinned span: what it was, how long it took, what ran through
722 // it and inside it, and what its box means.
723 const color = hex(nodeColor(pinned.node))
724 const about = EXPLAIN[pinned.node]
725 const inside = insideOf(spans, pinned, now)
726 const kinds = new Map<string, number>()
727 for (const one of inside) kinds.set(one.event, (kinds.get(one.event) ?? 0) + 1)
728 const index = spans.indexOf(pinned)
729 return (
730 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} marginTop={1}>
731 <Text bold color={color}>{`${index + 1}/${spans.length} · ${about?.title ?? pinned.node}`}</Text>
732 <Text>{`${pinned.event}${pinned.detail ? ` · ${pinned.detail}` : ''}`}</Text>
733 <Text dimColor>
734 {`started +${seconds(pinned.start - (spans[0]?.start ?? pinned.start))} · took ${pinned.end === undefined ? `${seconds(now - pinned.start)} so far` : seconds(pinned.end - pinned.start)} · ${short(pinned.bytes / 1024)} KB in and out`}
735 </Text>
736 <Text dimColor>
737 {pinned.plugins.length > 0 ? `plugins on it: ${[...new Set(pinned.plugins)].join(', ')}` : 'no other plugin hooked it'}
738 </Text>
739 <Text> </Text>
740 <Text bold>Inside it</Text>
741 {kinds.size === 0 && <Text dimColor>nothing else ran inside this span</Text>}
742 {[...kinds].map(([event, n]) => <Text>{`${pad(event, 26)}×${n}`}</Text>)}
743 <Text> </Text>
744 <Text dimColor>{about?.what ?? ''}</Text>
745 <Box flexDirection="row" gap={2} marginTop={1}>
746 <Button key="span:prev" label="◀ prev" hotkey="b" plain
747 onPress={() => { pinFrom(-1); return update($, tick, n => n + 1) }} />
748 <Button key="span:next" label="next ▶" hotkey="n" plain
749 onPress={() => { pinFrom(1); return update($, tick, n => n + 1) }} />
750 <Button key="span:explain" label="? explain" hotkey="e" plain onPress={() => select($, pinned.node)} />
751 <Button key="span:unpin" label="✕ unpin" hotkey="x" plain dimColor
752 onPress={() => { pinnedId = undefined; return update($, tick, n => n + 1) }} />
753 </Box>
754 </Box>
755 )
756 })() : (
757 <Box flexDirection="column">
758 <Text dimColor>{`${pad('event', nameWidth)} ${pad(`last ${SECONDS}s`, SECONDS)} n ms KB`}</Text>
759 {rows.length === 0 && <Text dimColor>Waiting for events…</Text>}
760 {rows.map(([event, one]) => {
761 const isHot = now - one.lastAt < 1000
762 const avg = one.ms / one.n
763 return (
764 <Text color={isHot ? 'green' : undefined} dimColor={!isHot && now - one.lastAt > SECONDS * 1000}>
765 {`${pad(event, nameWidth)} ${sparkline(recentSeconds(one, now))} ${short(one.n).padStart(5)} ${short(avg).padStart(4)} ${short(one.bytes / 1024).padStart(4)}`}
766 </Text>
767 )
768 })}
769 <Text> </Text>
770 <Text bold>
771 {lastCall ? `Last call: ${lastCall.tool} · ${duration(lastCall.ms)}` : 'Last call: none yet'}
772 </Text>
773 {links.map(link => (
774 <Box flexDirection="row">
775 <Text dimColor>{pad(link.plugin, 12)} </Text>
776 <Text color={link.plugin === 'engine' ? 'cyan' : 'magenta'}>{pad(bar(link.ms, slowest, linkBar), linkBar)}</Text>
777 <Text dimColor>{duration(link.ms).padStart(8)}</Text>
778 </Box>
779 ))}
780 <Text bold>Tokens per model request</Text>
781 <Text color="cyan">{sparkline(steps.slice(-(width - 2)).map(s => s.input + s.cached + s.output)) || ' '}</Text>
782 <Text dimColor>
783 {lastStep
784 ? `last: ${short(lastStep.input)} in · ${short(lastStep.cached)} cached · ${short(lastStep.output)} out`
785 : 'no requests yet'}
786 </Text>
787 <Text bold>Dollars per turn</Text>
788 <Text color="yellow">{sparkline(turns.slice(-(width - 2)).map(t => t.usd)) || ' '}</Text>
789 <Text dimColor>
790 {lastTurn ? `last turn: $${lastTurn.usd.toFixed(3)} · ${turns.length} turns` : 'no turns yet'}
791 </Text>
792 </Box>
793 )}
794 </Box>
795 )
796 })
797}
798hooks/explain.ts 115 lines1// What each box of the flow chart stands for, for the explain panel: what
2// happens there, its events, what a hook can do, and the hook that would.
3
4export type Explainer = {
5 key: string
6 title: string
7 what: string
8 events: string
9 can: string
10 snippet: string
11}
12
13export const EXPLAIN: Record<string, Explainer> = {
14 prompt: {
15 key: '1',
16 title: 'Prompt submitted',
17 what: 'You pressed Enter. Your text becomes the next user message.',
18 events: 'prompt.submit',
19 can: 'Rewrite the text before the model sees it, or drop the prompt.',
20 snippet: "on('prompt.submit', ($, e, next) =>\n next({ ...e, text: e.text.trim() }))",
21 },
22 context: {
23 key: '2',
24 title: 'Context assembled',
25 what: 'The system prompt is composed from sections, and context blocks are attached.',
26 events: 'prompt.compose, prompt.section, prompt.context, prompt.attachment',
27 can: 'Add, replace, reorder or drop system prompt sections.',
28 snippet: "on('prompt.compose', async ($, e, next) => {\n const { sections } = await next(e)\n return { sections: [...sections, mine] }\n})",
29 },
30 turn: {
31 key: '3',
32 title: 'Turn started',
33 what: 'A model turn begins: the loop of requests and tool calls that answers you.',
34 events: 'turn.start',
35 can: 'Observe only: start a timer, reset a counter.',
36 snippet: "on('turn.start', async ($, e, next) => {\n startedAt = await $.clock.now()\n return next(e)\n})",
37 },
38 model: {
39 key: '4',
40 title: 'Model request',
41 what: 'One API call. The reply streams back as text and tool calls; its usage is the tokens you pay for.',
42 events: 'turn.step (streams)',
43 can: 'Read or rewrite the streamed chunks, and read the token usage.',
44 snippet: "on('turn.step', async function* ($, e, next) {\n const result = yield* next(e)\n log(result.usage)\n return result\n})",
45 },
46 tool: {
47 key: '5',
48 title: 'Tool call',
49 what: 'The model asked for a tool (Bash, Read, Edit…). It runs here, unless a hook or a permission check stops it.',
50 events: 'tool.call',
51 can: 'Refuse it, rewrite its input, or act on its result.',
52 snippet: "on('tool.call', { tool: 'Bash' }, ($, e, next) =>\n e.command.includes('rm -rf')\n ? { deny: 'not here' }\n : next(e))",
53 },
54 perms: {
55 key: '6',
56 title: 'Permission check',
57 what: 'Rules, your permission mode and settings hooks decide: allow, ask you, or deny.',
58 events: 'classic.PreToolUse, tool.check',
59 can: 'Return your own decision: allow, ask or deny.',
60 snippet: "on('tool.check', { tool: 'Read' }, () =>\n ({ decision: 'allow' }))",
61 },
62 append: {
63 key: '7',
64 title: 'Transcript row',
65 what: 'A message or tool result is written to the conversation. This is what the model reads on its next request.',
66 events: 'session.append',
67 can: 'Rewrite what is stored and sent, e.g. trim a huge tool result.',
68 snippet: "on('session.append', ($, e, next) =>\n next({ ...e, message: trimmed(e.message) }))",
69 },
70 done: {
71 key: '8',
72 title: 'Turn complete',
73 what: 'The model stopped asking for tools and answered. The loop ends until your next prompt.',
74 events: 'turn.complete',
75 can: 'Show a line beneath the answer, or react: a toast, the cost.',
76 snippet: "on('turn.complete', async ($, e, next) => {\n const done = await next(e)\n $.ui.toast('done')\n return done\n})",
77 },
78 agent: {
79 key: 'a',
80 title: 'Subagent spawned',
81 what: 'The Agent tool started a subagent: a whole loop of its own, nested inside this tool call.',
82 events: 'agent.spawn',
83 can: 'Refuse the spawn or pick its model.',
84 snippet: "on('agent.spawn', ($, e, next) =>\n next({ ...e, model: 'haiku' }))",
85 },
86 compact: {
87 key: 'c',
88 title: 'Compaction',
89 what: 'The conversation is summarized to free up the context window.',
90 events: 'session.compact',
91 can: 'Change the summary instructions, or skip compacting.',
92 snippet: "on('session.compact', ($, e, next) =>\n next({ ...e, instructions: 'keep decisions' }))",
93 },
94 command: {
95 key: 's',
96 title: 'Slash command',
97 what: 'A /command ran: built in, or registered by a plugin.',
98 events: 'command.run',
99 can: 'Answer a command yourself, or change what one prints.',
100 snippet: "on('command.run', { command: 'flow' }, () =>\n ({ text: 'hello' }))",
101 },
102 ui: {
103 key: 'i',
104 title: 'UI',
105 what: 'The screen draws: messages, tool rows, the spinner, panes. It runs alongside everything else.',
106 events: 'ui.render, ui.press, ui.scroll…',
107 can: 'Draw your own tree for a site, like this pane.',
108 snippet: "on('ui.render', { component: 'Pane', requestId: id },\n ($, e) => <Text>hi</Text>)",
109 },
110}
111
112export const NODE_BY_KEY: Record<string, string> = Object.fromEntries(
113 Object.entries(EXPLAIN).map(([id, one]) => [one.key, id]),
114)
115hooks/chart.tsx 51 lines1import type { ClientModule } from 'claude-code'
2
3// The flow chart and the timeline, drawn on the surface so a click or a
4// hover can reach the hooks module. It draws the rows of runs it is handed
5// and asks for fresh ones: every 50 ms while something moves, every half
6// second at rest. A hover is posted when it reaches a new cell.
7
8export type ChartProps = { rows: [string, number][][]; isHot: boolean }
9
10let isHot = false
11
12const hex = (n: number) => `#${n.toString(16).padStart(6, '0')}`
13
14const Chart: ClientModule<ChartProps, true> = (props, surface) => {
15 const { Box, Text } = surface.elements
16 isHot = props.isHot
17 if (surface.state === undefined) {
18 surface.setState(true)
19 let ticks = 0
20 surface.every(50, () => {
21 ticks += 1
22 if (isHot || ticks % 10 === 0) surface.post({ kind: 'frame' })
23 })
24 // No key listener: a click would hand this Client keys of its own, a
25 // focus nobody can see. The hooks module asks for the pane's instead.
26 let hoverAt = ''
27 surface.onPointer(event => {
28 if (event.type === 'down' && event.button === 'left') surface.post({ kind: 'click', x: event.x, y: event.y })
29 if (event.type === 'move' && event.button === undefined && `${event.x},${event.y}` !== hoverAt) {
30 hoverAt = `${event.x},${event.y}`
31 surface.post({ kind: 'hover', x: event.x, y: event.y })
32 }
33 if (event.type === 'leave') {
34 hoverAt = ''
35 surface.post({ kind: 'hover', x: -1, y: -1 })
36 }
37 })
38 }
39 return (
40 <Box flexDirection="column">
41 {props.rows.map(runs => (
42 <Box flexDirection="row">
43 {runs.map(([text, color]) => (color < 0 ? <Text>{text}</Text> : <Text color={hex(color)}>{text}</Text>))}
44 </Box>
45 ))}
46 </Box>
47 )
48}
49
50export default Chart
51hooks/flow.ts 201 lines1// The flow chart: the agentic loop as boxes and arrows, each box lit in its
2// color when one of its events fires, each arrow when the loop moves along it.
3// Pure: the hooks module feeds it hits and draws the runs it returns.
4
5export const FLOW_COLUMNS = 45
6export const FLOW_ROWS = 15
7const DEFAULT = 0x01000000
8const IDLE_BORDER = 0x3d434b
9const SEEN_BORDER = 0x5a6270
10const LABEL = 0xb0b8c0
11const FADE_MS = 600
12
13type Node = { id: string; label: string; x: number; y: number; color: number; isSide?: boolean }
14
15// Three columns of 13-cell boxes, 16 apart; rows of 3-cell boxes, 4 apart.
16const col = (i: number) => i * 16
17const row = (i: number) => i * 4
18
19export const NODES: Node[] = [
20 { id: 'prompt', label: 'prompt', x: col(0), y: row(0), color: 0xff6bcb },
21 { id: 'context', label: 'context', x: col(1), y: row(0), color: 0xf368e0 },
22 { id: 'turn', label: 'turn start', x: col(2), y: row(0), color: 0x48dbfb },
23 { id: 'append', label: 'append', x: col(0), y: row(1), color: 0x1dd1a1 },
24 { id: 'tool', label: 'tool call', x: col(1), y: row(1), color: 0xff9f43 },
25 { id: 'model', label: 'model', x: col(2), y: row(1), color: 0x48dbfb },
26 { id: 'agent', label: 'subagent', x: col(0), y: row(2), color: 0xfeca57, isSide: true },
27 { id: 'perms', label: 'permission', x: col(1), y: row(2), color: 0xee5253 },
28 { id: 'done', label: 'turn done', x: col(2), y: row(2), color: 0x0abde3 },
29 { id: 'compact', label: 'compact', x: col(0), y: row(3), color: 0xa29bfe, isSide: true },
30 { id: 'command', label: 'command', x: col(1), y: row(3), color: 0xc8d6e5, isSide: true },
31 { id: 'ui', label: 'ui', x: col(2), y: row(3), color: 0x54a0ff, isSide: true },
32]
33
34// Which box an event lights; undefined for the events the chart leaves out.
35export const nodeOf = (event: string): string | undefined => {
36 if (event.startsWith('ui.')) return 'ui'
37 switch (event) {
38 case 'prompt.submit': return 'prompt'
39 case 'prompt.compose':
40 case 'prompt.section':
41 case 'prompt.context':
42 case 'prompt.attachment': return 'context'
43 case 'turn.start': return 'turn'
44 case 'turn.step': return 'model'
45 case 'tool.call': return 'tool'
46 case 'tool.check':
47 case 'classic.PreToolUse':
48 case 'classic.PermissionRequest': return 'perms'
49 case 'session.append': return 'append'
50 case 'turn.complete': return 'done'
51 case 'agent.spawn': return 'agent'
52 case 'session.compact': return 'compact'
53 case 'command.run': return 'command'
54 default: return undefined
55 }
56}
57
58type Cell = [x: number, y: number, glyph: string]
59type Edge = { from: string; to: string; cells: Cell[] }
60
61const line = (glyph: string, y: number, x0: number, x1: number): Cell[] =>
62 Array.from({ length: x1 - x0 + 1 }, (_, i) => [x0 + i, y, glyph] as Cell)
63
64// Arrowheads sit on the border of the box they point into.
65export const EDGES: Edge[] = [
66 { from: 'prompt', to: 'context', cells: [...line('─', 1, 13, 14), [15, 1, '▶']] },
67 { from: 'context', to: 'turn', cells: [...line('─', 1, 29, 30), [31, 1, '▶']] },
68 { from: 'turn', to: 'model', cells: [[38, 2, '┬'], [38, 3, '│'], [38, 4, '▼']] },
69 { from: 'model', to: 'tool', cells: [[29, 5, '◀'], ...line('─', 5, 30, 31)] },
70 { from: 'tool', to: 'perms', cells: [[22, 6, '┬'], [22, 7, '┼'], [22, 8, '▼']] },
71 { from: 'tool', to: 'append', cells: [[13, 5, '◀'], ...line('─', 5, 14, 15)] },
72 {
73 from: 'append',
74 to: 'model',
75 cells: [[6, 6, '┬'], [6, 7, '└'], ...line('─', 7, 7, 21), ...line('─', 7, 23, 34), [35, 7, '┘'], [35, 6, '▲']],
76 },
77 { from: 'model', to: 'done', cells: [[41, 6, '┬'], [41, 7, '│'], [41, 8, '▼']] },
78]
79
80const blend = (from: number, to: number, by: number): number => {
81 const f = Math.max(0, Math.min(1, by))
82 const mix = (shift: number) =>
83 Math.round(((from >> shift) & 0xff) * (1 - f) + ((to >> shift) & 0xff) * f) << shift
84 return mix(16) | mix(8) | mix(0)
85}
86
87const short = (n: number): string =>
88 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${n}`
89
90export class Flow {
91 hitAt = new Map<string, number>()
92 edgeAt = new Map<string, number>()
93 last: string | undefined
94
95 // Lights the event's box, and the arrow from the box the loop was last in.
96 // A permission check branches off its tool call: the loop stays at the tool.
97 hit(event: string, now: number) {
98 const id = nodeOf(event)
99 if (!id) return
100 this.hitAt.set(id, now)
101 const node = NODES.find(n => n.id === id)
102 if (node?.isSide) return
103 if (this.last && this.last !== id) this.edgeAt.set(`${this.last}>${id}`, now)
104 if (id !== 'perms') this.last = id
105 }
106
107 isHot(now: number): boolean {
108 for (const at of [...this.hitAt.values(), ...this.edgeAt.values()]) if (now - at < FADE_MS) return true
109 return false
110 }
111
112 // The chart as glyphs and colors, `columns` x FLOW_ROWS, centered;
113 // `countOf` gives each box's count, `keyOf` the key that explains it, and
114 // `selected` stays lit.
115 grid(
116 columns: number,
117 now: number,
118 countOf: (id: string) => number,
119 keyOf: (id: string) => string = () => '',
120 selected?: string,
121 ): { glyphs: string[][]; colors: number[][] } {
122 const glyphs = Array.from({ length: FLOW_ROWS }, () => Array.from({ length: columns }, () => ' '))
123 const colors = Array.from({ length: FLOW_ROWS }, () => Array.from({ length: columns }, () => DEFAULT))
124 const left = leftOf(columns)
125 const put = (x: number, y: number, glyph: string, fg: number) => {
126 const at = left + x
127 const row = glyphs[y]
128 const tint = colors[y]
129 if (!row || !tint || at < 0 || at >= columns) return
130 row[at] = glyph
131 tint[at] = fg
132 }
133 const heat = (at: number | undefined) => (at === undefined ? 0 : Math.max(0, 1 - (now - at) / FADE_MS))
134
135 for (const node of NODES) {
136 const count = countOf(node.id)
137 const glow = node.id === selected ? 1 : heat(this.hitAt.get(node.id))
138 const rest = count > 0 ? SEEN_BORDER : IDLE_BORDER
139 const border = blend(rest, node.color, glow)
140 const text = blend(count > 0 ? LABEL : SEEN_BORDER, node.color, glow)
141 const { x, y } = node
142 const [tl, tr, bl, br, h, v] = node.isSide ? ['╭', '╮', '╰', '╯', '┄', '┆'] : ['┌', '┐', '└', '┘', '─', '│']
143 put(x, y, tl, border)
144 put(x + 12, y, tr, border)
145 put(x, y + 2, bl, border)
146 put(x + 12, y + 2, br, border)
147 for (let i = 1; i < 12; i++) {
148 put(x + i, y, h, border)
149 put(x + i, y + 2, h, border)
150 }
151 put(x, y + 1, v, border)
152 put(x + 12, y + 1, v, border)
153 const label = node.label.slice(0, 11)
154 for (let i = 0; i < label.length; i++) put(x + 1 + i, y + 1, label[i] ?? ' ', text)
155 const key = keyOf(node.id)
156 if (key) put(x + 1, y, key, blend(LABEL, node.color, Math.max(glow, 0.5)))
157 // The count rides the top border's right end: `┌1─────── 14 ┐`.
158 if (count > 0) {
159 const number = ` ${short(count)} `
160 const from = x + 12 - number.length
161 for (let i = 0; i < number.length; i++) put(from + i, y, number[i] ?? ' ', blend(LABEL, 0xffffff, glow))
162 }
163 }
164
165 for (const edge of EDGES) {
166 const glow = heat(this.edgeAt.get(`${edge.from}>${edge.to}`))
167 const to = NODES.find(n => n.id === edge.to)
168 const fg = blend(SEEN_BORDER, to?.color ?? LABEL, glow)
169 for (const [x, y, glyph] of edge.cells) put(x, y, glyph, fg)
170 }
171 return { glyphs, colors }
172 }
173
174 // The chart as rows of [text, color] runs, a run per stretch of one color:
175 // what a Client draws as Text, `-1` for the terminal's own color.
176 rows(...args: Parameters<Flow['grid']>): Run[][] {
177 const { glyphs, colors } = this.grid(...args)
178 return glyphs.map((row, y) => {
179 const runs: Run[] = []
180 row.forEach((glyph, x) => {
181 const raw = colors[y]?.[x] ?? DEFAULT
182 const color = raw === DEFAULT || glyph === ' ' ? -1 : raw
183 const last = runs.at(-1)
184 if (last && last[1] === color) last[0] += glyph
185 else runs.push([glyph, color])
186 })
187 return runs
188 })
189 }
190}
191
192export type Run = [text: string, color: number]
193
194const leftOf = (columns: number) => Math.max(0, Math.floor((columns - FLOW_COLUMNS) / 2))
195
196// The box under a cell of the chart, as a click lands on it.
197export const nodeAt = (x: number, y: number, columns: number): string | undefined => {
198 const cx = x - leftOf(columns)
199 return NODES.find(n => cx >= n.x && cx <= n.x + 12 && y >= n.y && y <= n.y + 2)?.id
200}
201hooks/timeline.ts 181 lines1// The span timeline: one turn laid out in time, a row per box of the flow
2// chart, each event a bar from when its hook started to when it returned.
3// Pure: the hooks module records the spans and draws the runs this returns.
4
5import { NODES } from './flow'
6import type { Run } from './flow'
7
8export type Span = {
9 id: number
10 event: string
11 node: string
12 detail: string
13 start: number
14 end?: number
15 bytes: number
16 plugins: string[]
17}
18
19export const LABEL_WIDTH = 9
20const MAX_LANES = 3
21const DIM = 0x5a6270
22const AXIS = 0x7f8c8d
23
24// The loop's boxes, always shown so an empty row says "didn't happen";
25// the side boxes only when the turn used them.
26const MAIN = ['prompt', 'context', 'turn', 'model', 'tool', 'perms', 'append', 'done']
27const SIDE = ['agent', 'compact', 'command']
28const LABELS: Record<string, string> = {
29 prompt: 'prompt', context: 'context', turn: 'turn', model: 'model', tool: 'tool', perms: 'perms',
30 append: 'append', done: 'done', agent: 'subagent', compact: 'compact', command: 'command',
31}
32
33type Row = { node: string; lane: number }
34type Placed = { span: Span; row: number; x0: number; x1: number }
35export type Window = { t0: number; t1: number }
36export type Layout = { rows: Row[]; placed: Placed[]; t0: number; t1: number; field: number; whole: Window }
37
38// The whole turn, first start to last end; an open span runs to `now`.
39export const wholeOf = (spans: readonly Span[], now: number): Window => {
40 const t0 = spans[0]?.start ?? now
41 return { t0, t1: Math.max(t0 + 1, ...spans.map(s => s.end ?? now)) }
42}
43
44// Rows, and each span's cells: a lane per span that would touch another in
45// its row, up to three, the rest drawn over the last. Zoomed to a window,
46// only the spans in it are laid out, those crossing its edges cut there.
47export const layout = (spans: readonly Span[], columns: number, now: number, zoom?: Window): Layout => {
48 const field = Math.max(1, columns - LABEL_WIDTH)
49 const whole = wholeOf(spans, now)
50 const { t0, t1 } = zoom ?? whole
51 const at = (t: number) => Math.max(0, Math.min(field - 1, Math.floor(((t - t0) / (t1 - t0)) * field)))
52 const shown = spans.filter(s => s.start <= t1 && (s.end ?? now) >= t0)
53 const nodes = [...MAIN, ...SIDE.filter(id => spans.some(s => s.node === id))]
54 const rows: Row[] = []
55 const placed: Placed[] = []
56 for (const node of nodes) {
57 const ends: number[] = []
58 const first = rows.length
59 for (const span of shown.filter(s => s.node === node)) {
60 const x0 = at(span.start)
61 const x1 = Math.max(x0, at(span.end ?? now))
62 let lane = ends.findIndex(end => x0 > end + 1)
63 if (lane < 0) lane = ends.length < MAX_LANES ? ends.length : MAX_LANES - 1
64 ends[lane] = Math.max(ends[lane] ?? -2, x1)
65 placed.push({ span, row: first + lane, x0, x1 })
66 }
67 const lanes = Math.max(1, ends.length)
68 for (let lane = 0; lane < lanes; lane++) rows.push({ node, lane })
69 }
70 return { rows, placed, t0, t1, field, whole }
71}
72
73// Zooms by `factor` (below 1 in, above 1 out) keeping the time at `anchor`,
74// a fraction of the window, where it is; out to the whole turn, no window.
75export const zoomBy = (zoom: Window | undefined, whole: Window, factor: number, anchor = 0.5): Window | undefined => {
76 const { t0, t1 } = zoom ?? whole
77 const width = Math.max(5, (t1 - t0) * factor)
78 if (width >= whole.t1 - whole.t0) return undefined
79 const at = t0 + anchor * (t1 - t0)
80 const from = Math.max(whole.t0, Math.min(whole.t1 - width, at - anchor * width))
81 return { t0: from, t1: from + width }
82}
83
84// The window moved just enough to show a span, its width kept.
85export const follow = (zoom: Window | undefined, span: Span, now: number): Window | undefined => {
86 if (!zoom) return undefined
87 const end = span.end ?? now
88 if (span.start >= zoom.t0 && end <= zoom.t1) return zoom
89 const width = zoom.t1 - zoom.t0
90 const t0 = span.start - width / 2 + Math.min(width, end - span.start) / 2
91 return { t0, t1: t0 + width }
92}
93
94// The rows a timeline takes: its lanes plus the time axis.
95export const heightOf = (shape: Layout) => shape.rows.length + 1
96
97// Every span under a cell, latest first; a click or a hover lands here.
98export const spansAt = (shape: Layout, x: number, y: number): Span[] => {
99 const cx = x - LABEL_WIDTH
100 return shape.placed
101 .filter(p => p.row === y && cx >= p.x0 && cx <= p.x1)
102 .map(p => p.span)
103 .reverse()
104}
105
106// The spans that ran inside one: its own work and what it waited on.
107export const insideOf = (spans: readonly Span[], outer: Span, now: number): Span[] =>
108 spans.filter(s => s !== outer && s.start >= outer.start && (s.end ?? now) <= (outer.end ?? now))
109
110const blend = (from: number, to: number, by: number): number => {
111 const mix = (shift: number) =>
112 Math.round(((from >> shift) & 0xff) * (1 - by) + ((to >> shift) & 0xff) * by) << shift
113 return mix(16) | mix(8) | mix(0)
114}
115
116export const seconds = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`)
117
118// The timeline as rows of [text, color] runs. With a span in focus (hovered
119// or pinned) it and everything inside it light up; the rest dims.
120export const timelineRows = (
121 spans: readonly Span[],
122 shape: Layout,
123 columns: number,
124 now: number,
125 focus?: Span,
126): Run[][] => {
127 const color = (node: string) => NODES.find(n => n.id === node)?.color ?? 0xdfe6e9
128 const lit = new Set(focus ? [focus, ...insideOf(spans, focus, now)] : [])
129 const glyphs = shape.rows.map(() => Array.from({ length: columns }, () => ' '))
130 const colors = shape.rows.map(() => Array.from({ length: columns }, () => -1))
131 shape.rows.forEach((row, y) => {
132 if (row.lane > 0) return
133 const label = (LABELS[row.node] ?? row.node).padEnd(LABEL_WIDTH - 1).slice(0, LABEL_WIDTH - 1)
134 const isLit = focus !== undefined && [...lit].some(s => s.node === row.node)
135 for (let i = 0; i < label.length; i++) {
136 glyphs[y]![i] = label[i]!
137 colors[y]![i] = isLit ? color(row.node) : spans.some(s => s.node === row.node) ? AXIS : DIM
138 }
139 glyphs[y]![LABEL_WIDTH - 1] = '│'
140 colors[y]![LABEL_WIDTH - 1] = DIM
141 })
142 shape.rows.forEach((row, y) => {
143 if (row.lane === 0) return
144 glyphs[y]![LABEL_WIDTH - 1] = '│'
145 colors[y]![LABEL_WIDTH - 1] = DIM
146 })
147 // Lit spans draw last, so a stack shows the one in focus.
148 const order = [...shape.placed].sort((a, b) => Number(lit.has(a.span)) - Number(lit.has(b.span)))
149 for (const { span, row, x0, x1 } of order) {
150 const base = color(span.node)
151 const fg = span === focus ? blend(base, 0xffffff, 0.55) : lit.has(span) ? base : focus ? blend(base, 0x000000, 0.6) : blend(base, 0x000000, 0.15)
152 const isOpen = span.end === undefined
153 for (let x = x0; x <= x1; x++) {
154 glyphs[row]![LABEL_WIDTH + x] = isOpen && x === x1 ? '▶' : x1 === x0 ? '▌' : '█'
155 colors[row]![LABEL_WIDTH + x] = fg
156 }
157 }
158 // The axis reads in time since the turn began: `0` to its length, or the
159 // window's edges and how far it is zoomed.
160 const from = shape.t0 - shape.whole.t0
161 const isZoomed = shape.t1 - shape.t0 < shape.whole.t1 - shape.whole.t0
162 const left = isZoomed ? `+${seconds(from)}` : '0'
163 const ratio = (shape.whole.t1 - shape.whole.t0) / (shape.t1 - shape.t0)
164 const zoomed = isZoomed ? ` ${ratio < 10 ? ratio.toFixed(1).replace(/\.0$/, '') : Math.round(ratio)}× ` : ''
165 const right = isZoomed ? `+${seconds(shape.t1 - shape.whole.t0)}` : seconds(shape.t1 - shape.t0)
166 const rule = Math.max(0, shape.field - left.length - right.length - zoomed.length)
167 const axis = `${' '.repeat(LABEL_WIDTH - 1)}└${left}${'─'.repeat(Math.floor(rule / 2))}${zoomed}${'─'.repeat(Math.ceil(rule / 2))}${right}`
168 const rows: Run[][] = glyphs.map((line, y) => {
169 const runs: Run[] = []
170 line.forEach((glyph, x) => {
171 const c = glyph === ' ' ? -1 : colors[y]![x]!
172 const last = runs.at(-1)
173 if (last && last[1] === c) last[0] += glyph
174 else runs.push([glyph, c])
175 })
176 return runs
177 })
178 rows.push([[axis.slice(0, columns), AXIS]])
179 return rows
180}
181types/index.d.ts 26 lines1export type SavedEvent = {
2 event: string
3 n: number
4 ms: number
5 bytes: number
6 lastAt: number
7 perSecond: [number, number][]
8 plugins: string[]
9}
10
11export type Snapshot = {
12 events: SavedEvent[]
13 steps: { input: number; output: number; cached: number }[]
14 turns: { usd: number; tokens: number }[]
15 isPaused: boolean
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'hook-flow': {
21 tick: number
22 snapshot: Snapshot | null
23 }
24 }
25}
26