SLOPSHOPPER

hook-flow

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…

newpanecommandtoasttimer
v0.1.0MITupdated 2026-10-09legibleco/hook-flow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hook-flow
│ ┃ Hook flow ✕ › fix the failing auth test and add an audit log call │ ┃ $0.420 ctx ██████▉░░░░░░░ 49% click for keys │ ┃ p: pause r: reset u: ui: off v: view: flo ⏺ Read(src/auth.ts) │ ┃ ▣ client module ./chart.tsx ⎿ Read 6 lines │ ┃ [ ? explain ] t: ▶ replay ⏺ Update(src/auth.ts) │ ┃ click a box to explain it, or ctrl+x tab ⎿ Added 2 lines, removed 1 line │ ┃ then e ⏺ Bash(bun test) │ ┃ event last 12s ⎿ 3 pass, 1 fail │ ┃ n ms KB │ ┃ Waiting for events… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Last call: none yet ✻ Worked for 42s · done 4:20 PM │ ┃ Tokens per model request │ ┃ › /flow │ ┃ no requests yet ⎿ hook-flow: Hook flow pane opened. │ ┃ Dollars per turn │ ┃ │ ┃ last turn: $0.000 · 1 turns │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Hook flow
$0.420 ctx ██████▉░░░░░░░ 49% click for keys p: pause r: reset u: ui: off v: view: flow ▣ client module ./chart.tsx [ ? explain ] t: ▶ replay click a box to explain it, or ctrl+x tab then e event last 12s n ms KB Waiting for events… Last call: none yet Tokens per model request no requests yet Dollars per turn last turn: $0.000 · 1 turns
README

hook-flow

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

The hook-flow timeline: one turn laid out in time, with model requests, tool calls, permission checks and transcript writes as bars in their own rows

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:

  • each hook event as it fires
  • a flow chart of the loop, lighting up box by box
  • a timeline of every span in the turn
  • the tokens and dollars the turn cost

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.

Install

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.

What you'll see

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.

Keys

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.

WhereKeyDoes
Anywherep / r / u / vpause, reset counts, count UI events, switch flow ⇄ timeline
Flow charte, or click a boxopen the explainer
treplay the last turn
Explainer↑ ↓step through the boxes
1–8, a c s ijump to a box
xclose
Timelinehover, clicklight a span, pin it
wheel, z / o / fzoom in, out, fit the whole turn
↑ ↓, b / nstep through the turn's spans
e / xexplain the pinned span's step, unpin

Settings

Set at install, or later in /config:

SettingDefault
Count UI eventsoffAlso 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.5Flash the odometer and show a toast when one turn costs at least this much.

How it works

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.tsxThe hooks: the observer above, token and cost tracking, the /flow command, and the pane
hooks/flow.tsThe flow chart: boxes, arrows, which event lights which box
hooks/timeline.tsThe timeline: span layout in lanes, zoom, hit-testing
hooks/explain.tsThe explainer text for each box
hooks/chart.tsxThe surface module that draws the chart and timeline and reports the pointer
hooks/register.test.tsTests, 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.

Changing it

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.

License

MIT

Source 6 files
hooks/register.tsx 798 lines
1import { 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}
798
hooks/explain.ts 115 lines
1// 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)
115
hooks/chart.tsx 51 lines
1import 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
51
hooks/flow.ts 201 lines
1// 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}
201
hooks/timeline.ts 181 lines
1// 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}
181
types/index.d.ts 26 lines
1export 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