SLOPSHOPPER

flight-recorder

A live flight recorder for each turn: see where Claude's time goes, tool by tool, as it happens.

newpaneguardcommandtoastprompt
v0.1.0MITupdated 2026-10-09robertiuoras/claude-mods/flight-recorder
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flight-recorder
│ ┃ Flight Recorder ✕ › fix the failing auth test and add an audit log call │ ┃ Flight Recorder ■ 42s 9 calls 0 steps │ ┃ ⏺ Read(src/auth.ts) │ ┃ Model ··································· ⎿ Read 6 lines │ ┃ Shell ··································█ ⏺ Update(src/auth.ts) │ ┃ Read ··································█ ⎿ Added 2 lines, removed 1 line │ ┃ Edit ··································█ ⏺ Bash(bun test) │ ┃ Agents ··································· ⎿ 3 pass, 1 fail │ ┃ Web ··································· │ ┃ Other ··································· ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 0s 21s 42s │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Most time: Shell 1% · slowest: auth.ts <1s │ › /flight │ ⎿ flight-recorder: Flight Recorder opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Flight Recorder
Flight Recorder ■ 42s 9 calls 0 steps Model ··································· 0% 0s Shell ··································█ 1% <1s Read ··································█ 1% <1s Edit ··································█ 1% <1s Agents ··································· 0% 0s Web ··································· 0% 0s Other ··································· 0% 0s 0s 21s 42s Most time: Shell 1% · slowest: auth.ts <1s
README

Flight Recorder

A Claude Code mod that shows, live, where each turn's time goes.

Type /flight to open the pane (type it again to close it). While Claude works, one row per kind of work fills in as it happens:

RowWhat counts
ModelClaude writing its answer or deciding the next step
ShellBash and background shell tools
ReadRead, Grep, Glob
EditEdit, Write, notebook edits
Agentssub-agents and workflows
Webweb search and fetch, browser tools
Othereverything else (MCP tools, skills)

The right-hand side shows each row's share of the turn and its total time. When the turn ends, one line names where most of the time went and the slowest single call. After three turns, a second line adds up your last 20 turns. A turn over a minute also ends with a short pop-up summary.

Only the main conversation is drawn. Calls made inside sub-agents are counted in the header but kept off the rows, so the rows add up to the turn you watched.

Install

Needs Claude Code 2.1.287 or later.

/plugin marketplace add robertiuoras/claude-mods
/plugin install flight-recorder@robertiuoras

Or try it from a folder: claude --plugin-dir /path/to/flight-recorder.

Good to know

  • A tool's time runs from the moment Claude asks for it to the moment its result comes back. That includes any permission step before it runs: your hooks, a permission prompt you answer, or auto mode's safety check. Claude Code does not report when the tool itself starts, so these cannot be split out yet.
  • The last 50 turns are kept on your machine (the mod's own store) for the "last 20 turns" line. Nothing is sent anywhere.
  • The pane draws in the terminal and the desktop app's Code tab.
Source 3 files
hooks/register.tsx 426 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, Timer } from 'claude-code'
3
4import type { FlightSegment, FlightSummary, FlightTurn } from '../types'
5import {
6  DONE_COLOR,
7  LABEL_WIDTH,
8  LANES,
9  LANE_COLORS,
10  REC_COLOR,
11  STATS_WIDTH,
12  abandonTurn,
13  addSegment,
14  analyze,
15  axisLine,
16  buildCells,
17  cleanHistory,
18  closeTurn,
19  encodeRaster,
20  endSegment,
21  formatClock,
22  formatDuration,
23  freshTurn,
24  groupRuns,
25  historyTop,
26  labelOf,
27  laneOf,
28  plural,
29  pushHistory,
30  statsText,
31  summarize,
32  toastText,
33  topLanes,
34} from './model'
35import type { Analysis } from './model'
36
37const PANE = 'flight'
38const TICK_MS = 200
39// A started turn that no model step, call or turn.start confirms within this long is dropped.
40const UNENGAGED_MS = 10_000
41
42const turnAtom = atom({ plugin: 'flight-recorder', key: 'turn' } as const, null)
43const tickAtom = atom({ plugin: 'flight-recorder', key: 'tick' } as const, 0)
44
45type Kit = Pick<Elements['terminal'], 'Box' | 'Text'>
46
47/** The recorder watches; whatever it hits must never fail the call or step it watches. */
48async function quietly<T>(work: () => Promise<T>): Promise<T | undefined> {
49  try {
50    return await work()
51  } catch {
52    return undefined
53  }
54}
55
56let timer: Timer | undefined
57let history: FlightSummary[] | undefined
58
59function stopTicker() {
60  timer?.cancel()
61  timer = undefined
62}
63
64async function getHistory($: EngineInterface): Promise<FlightSummary[]> {
65  if (history === undefined) {
66    try {
67      history = cleanHistory(await $.store.get('history'))
68    } catch {
69      return []
70    }
71  }
72
73  return history
74}
75
76async function abandonIfUnengaged($: EngineInterface) {
77  const turn = await read($, turnAtom)
78  if (turn?.isRunning && !turn.isEngaged) {
79    await update($, turnAtom, t => (t && t.id === turn.id && t.isRunning ? abandonTurn(t) : t))
80    stopTicker()
81  }
82}
83
84async function onTick($: EngineInterface) {
85  const turn = await read($, turnAtom)
86  if (!turn?.isRunning) {
87    stopTicker()
88
89    return
90  }
91  if (!turn.isEngaged && (await $.clock.now()) - turn.startedAt > UNENGAGED_MS) {
92    await abandonIfUnengaged($)
93
94    return
95  }
96  await update($, tickAtom, n => n + 1)
97}
98
99function startTicker($: EngineInterface) {
100  stopTicker()
101  timer = $.clock.every(TICK_MS, () => void quietly(() => onTick($)))
102}
103
104/** prompt.submit and turn.start both come here: the first one to fire starts the turn. */
105async function begin($: EngineInterface, turnId?: string) {
106  const now = await $.clock.now()
107  const before = await read($, turnAtom)
108  // A prompt never restarts a running turn; a turn.start with another turn's id does.
109  const isSame =
110    before?.isRunning === true &&
111    (turnId === undefined || before.turnId === undefined || before.turnId === turnId)
112
113  if (isSame) {
114    await update($, turnAtom, t =>
115      t && t.id === before.id ? { ...t, ...(turnId === undefined ? {} : { turnId, isEngaged: true }) } : t,
116    )
117    if (timer === undefined) startTicker($)
118
119    return
120  }
121
122  await update($, turnAtom, t => freshTurn(now, t, turnId, turnId !== undefined))
123  await update($, tickAtom, () => 0)
124  startTicker($)
125}
126
127async function finish($: EngineInterface, durationMs: number) {
128  const now = await $.clock.now()
129  const before = await read($, turnAtom)
130  if (!before?.isRunning) return
131
132  const closed = await update($, turnAtom, t =>
133    t && t.id === before.id && t.isRunning ? closeTurn(t, now, durationMs) : t,
134  )
135  stopTicker()
136  if (closed === null || closed.isRunning || closed.id !== before.id) return
137
138  const summary = summarize(closed)
139  // Read the store again rather than the cache: other sessions append to the same history.
140  history = pushHistory(cleanHistory(await $.store.get('history')), summary)
141  await $.store.set('history', history)
142  // The pane drew the closed turn already; this draw picks up the new history line.
143  await update($, tickAtom, n => n + 1)
144
145  if (summary.durationMs > 60_000) $.ui.toast(toastText(summary))
146}
147
148async function track($: EngineInterface, segment: FlightSegment): Promise<number | undefined> {
149  const after = await update($, turnAtom, t => (t && t.isRunning ? addSegment(t, segment) : t))
150
151  return after?.isRunning ? after.id : undefined
152}
153
154async function untrack($: EngineInterface, owner: number, id: string) {
155  const at = await $.clock.now()
156  await update($, turnAtom, t => (t && t.id === owner ? endSegment(t, id, at) : t))
157}
158
159export const register: Register = on => {
160  on('session.start', async ($, e, next) => {
161    await $.command.register({
162      name: 'flight',
163      description: "Show or hide the Flight Recorder (where each turn's time goes)",
164      immediate: true,
165    })
166    // A reload drops the timer; a turn that was running keeps ticking.
167    const turn = await read($, turnAtom)
168    if (turn?.isRunning) startTicker($)
169
170    return next(e)
171  })
172
173  on('command.run', { command: 'flight' }, async $ => {
174    await quietly(() => abandonIfUnengaged($))
175    const panes = await $.ui.panes()
176    if (panes.some(pane => pane.id === PANE && pane.isPlaced)) {
177      await $.ui.close({ id: PANE })
178
179      return { text: 'Flight Recorder closed.' }
180    }
181
182    const opened = await $.ui.open({ id: PANE, title: 'Flight Recorder', rows: 14, columns: 96 })
183
184    return {
185      text: opened.isPlaced
186        ? 'Flight Recorder opened.'
187        : `Flight Recorder is open but not drawn yet: ${opened.reason}`,
188    }
189  })
190
191  on('prompt.submit', async ($, e, next) => {
192    // A prompt typed over a running turn is queued for it, not a new turn.
193    if (e.turnId === undefined) await quietly(() => begin($))
194
195    return next(e)
196  })
197
198  on('turn.start', async ($, e, next) => {
199    await quietly(() => begin($, e.turnId))
200
201    return next(e)
202  })
203
204  on('turn.step', async function* ($, e, next) {
205    if (e.agentId !== undefined) return yield* next(e)
206
207    const start = await $.clock.now()
208    const id = `step-${e.index}-${start}`
209    const owner = await quietly(() =>
210      track($, { id, lane: 'Model', label: `step ${e.index + 1}`, start }),
211    )
212
213    try {
214      return yield* next(e)
215    } finally {
216      if (owner !== undefined) await quietly(() => untrack($, owner, id))
217    }
218  })
219
220  on('tool.call', async ($, e, next) => {
221    if (e.agentId !== undefined) {
222      await quietly(() =>
223        update($, turnAtom, t => (t && t.isRunning ? { ...t, subCalls: t.subCalls + 1 } : t)),
224      )
225
226      return next(e)
227    }
228
229    const owner = await quietly(async () => {
230      const segment: FlightSegment = {
231        id: e.tool_use_id,
232        lane: laneOf(e.tool),
233        label: labelOf(e.tool, e),
234        start: await $.clock.now(),
235      }
236
237      return track($, segment)
238    })
239
240    try {
241      return await next(e)
242    } finally {
243      if (owner !== undefined) await quietly(() => untrack($, owner, e.tool_use_id))
244    }
245  })
246
247  on('turn.complete', async ($, e, next) => {
248    if (e.agentId === undefined) await quietly(() => finish($, e.durationMs))
249
250    return next(e)
251  })
252
253  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
254    const turn = await read($, turnAtom)
255    const tick = await read($, tickAtom)
256    const now = await $.clock.now()
257    const recent = await getHistory($)
258    const { Box, Text } = $.ui.resolve(e)
259    const kit: Kit = { Box, Text }
260
261    const bodyColumns = e.props.bodyColumns > 0 ? e.props.bodyColumns : (e.viewport?.columns ?? 80)
262    const columns = Math.min(512, Math.max(4, bodyColumns - LABEL_WIDTH - 1 - STATS_WIDTH))
263    const historyLine = historyRow(kit, recent)
264
265    if (turn === null) {
266      return (
267        <Box flexDirection="column">
268          <Text dimColor>Waiting for the next turn… (ask Claude anything)</Text>
269          {historyLine}
270        </Box>
271      )
272    }
273
274    const a = analyze(turn, now)
275    const cells = buildCells({
276      segments: turn.segments,
277      start: a.start,
278      end: a.end,
279      now,
280      columns,
281      isShimmer: tick % 2 === 0,
282    })
283
284    let grid: JSX.Element
285    if (e.surface === 'terminal') {
286      const { Raster } = $.ui.resolve(e)
287      grid = <Raster key="grid" rows={LANES.length} columns={columns} cells={encodeRaster(cells, columns)} />
288    } else {
289      // No Raster here: the same cell model as one Text per run of a colour.
290      grid = (
291        <Box flexDirection="column">
292          {cells.map(row => (
293            <Box>
294              {groupRuns(row).map(run => (
295                <Text color={run.color}>{run.text}</Text>
296              ))}
297            </Box>
298          ))}
299        </Box>
300      )
301    }
302
303    return (
304      <Box flexDirection="column">
305        {headerRow(kit, turn, now, tick)}
306        <Box marginTop={1}>
307          <Box flexDirection="column" width={LABEL_WIDTH} flexShrink={0}>
308            {LANES.map(lane => (
309              <Text color={LANE_COLORS[lane]} bold>
310                {lane}
311              </Text>
312            ))}
313          </Box>
314          <Box marginLeft={1} width={columns} flexShrink={0}>
315            {grid}
316          </Box>
317          <Box flexDirection="column" width={STATS_WIDTH} flexShrink={0}>
318            {LANES.map(lane => {
319              const stats = statsText(a.totals[lane], a.wallMs)
320
321              return (
322                <Box>
323                  <Text color={LANE_COLORS[lane]} bold={!stats.isZero} dimColor={stats.isZero}>
324                    {stats.pct}
325                  </Text>
326                  <Text dimColor={stats.isZero}>{stats.duration}</Text>
327                </Box>
328              )
329            })}
330          </Box>
331        </Box>
332        <Text dimColor>{`${' '.repeat(LABEL_WIDTH + 1)}${axisLine(columns, a.end - a.start)}`}</Text>
333        {verdictRow(kit, turn, a)}
334        {historyLine}
335      </Box>
336    )
337  })
338}
339
340function headerRow(kit: Kit, turn: FlightTurn, now: number, tick: number) {
341  const { Box, Text } = kit
342  const subs = turn.subCalls > 0 ? `   ${plural(turn.subCalls, 'sub-agent call')}` : ''
343  const counts = `   ${plural(turn.calls, 'call')}   ${plural(turn.steps, 'step')}${subs}`
344
345  if (turn.isRunning) {
346    return (
347      <Box>
348        <Text bold>{'Flight Recorder   '}</Text>
349        <Text color={REC_COLOR} dimColor={tick % 2 === 1}>
350          ●
351        </Text>
352        <Text>{' REC  '}</Text>
353        <Text bold>{formatClock(now - turn.startedAt)}</Text>
354        <Text>{counts}</Text>
355      </Box>
356    )
357  }
358
359  return (
360    <Box>
361      <Text bold>{'Flight Recorder   '}</Text>
362      <Text color={DONE_COLOR}>■</Text>
363      <Text>{' '}</Text>
364      <Text bold>{formatDuration(turn.durationMs ?? 0)}</Text>
365      <Text>{counts}</Text>
366    </Box>
367  )
368}
369
370function verdictRow(kit: Kit, turn: FlightTurn, a: Analysis) {
371  const { Box, Text } = kit
372
373  if (turn.isRunning) {
374    const now = a.running
375    if (!now) {
376      return (
377        <Box marginTop={1}>
378          <Text dimColor>Now: between steps</Text>
379        </Box>
380      )
381    }
382
383    return (
384      <Box marginTop={1}>
385        <Text dimColor>{'Now: '}</Text>
386        <Text color={LANE_COLORS[now.lane]} bold>
387          {now.lane}
388        </Text>
389        <Text>{` · ${now.label} (${formatDuration(now.ms)})`}</Text>
390      </Box>
391    )
392  }
393
394  const top = topLanes(a.totals, a.wallMs, 1)[0]
395  if (!top) {
396    return (
397      <Box marginTop={1}>
398        <Text dimColor>Nothing was recorded for this turn.</Text>
399      </Box>
400    )
401  }
402  const slowest = a.slowest ? ` · slowest: ${a.slowest.label} ${formatDuration(a.slowest.ms)}` : ''
403
404  return (
405    <Box marginTop={1}>
406      <Text>{'Most time: '}</Text>
407      <Text color={LANE_COLORS[top.lane]} bold>
408        {top.lane}
409      </Text>
410      <Text>{` ${top.pct}%${slowest}`}</Text>
411    </Box>
412  )
413}
414
415function historyRow(kit: Kit, history: readonly FlightSummary[]) {
416  const { Text } = kit
417  if (history.length < 3) return null
418
419  const { turns, shares } = historyTop(history)
420  if (shares.length === 0) return null
421
422  return (
423    <Text dimColor>{`Last ${turns} turns: ${shares.map(s => `${s.lane} ${s.pct}%`).join(' · ')}`}</Text>
424  )
425}
426
hooks/model.ts 585 lines
1import type {
2  FlightLane,
3  FlightSegment,
4  FlightSlowest,
5  FlightSummary,
6  FlightTurn,
7} from '../types'
8
9export const LANES: readonly FlightLane[] = [
10  'Model',
11  'Shell',
12  'Read',
13  'Edit',
14  'Agents',
15  'Web',
16  'Other',
17]
18
19export const LANE_COLORS: Record<FlightLane, string> = {
20  Model: '#D97757',
21  Shell: '#E5C07B',
22  Read: '#61AFEF',
23  Edit: '#98C379',
24  Agents: '#C678DD',
25  Web: '#56B6C2',
26  Other: '#8B949E',
27}
28
29export const EMPTY_COLOR = '#30363D'
30export const DONE_COLOR = '#6E7681'
31export const REC_COLOR = '#FF5F57'
32
33export const MAX_SEGMENTS = 400
34export const HISTORY_LIMIT = 50
35export const LABEL_WIDTH = 7
36export const STATS_WIDTH = 13
37
38// Raster cell colours: 0x01000000 (bit 24 alone) is the terminal's default.
39export const DEFAULT_BG = 0x01000000
40
41// ---------------------------------------------------------------- lanes
42
43const SHELL = new Set(['Bash', 'BashOutput', 'KillShell', 'Monitor'])
44const READ = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead'])
45const EDIT = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
46const AGENTS = new Set(['Agent', 'Task', 'Workflow', 'SendMessage'])
47const WEB = new Set(['WebFetch', 'WebSearch'])
48const WEB_HINTS = ['exa', 'chrome', 'browser', 'fetch']
49
50export function laneOf(tool: string): FlightLane {
51  if (SHELL.has(tool)) return 'Shell'
52  if (READ.has(tool)) return 'Read'
53  if (EDIT.has(tool)) return 'Edit'
54  if (AGENTS.has(tool)) return 'Agents'
55  if (WEB.has(tool)) return 'Web'
56  const lower = tool.toLowerCase()
57  if (WEB_HINTS.some(hint => lower.includes(hint))) return 'Web'
58  return 'Other'
59}
60
61// ---------------------------------------------------------------- labels
62
63export function baseName(path: string): string {
64  const trimmed = path.replace(/[\\/]+$/, '')
65  const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
66  return cut >= 0 ? trimmed.slice(cut + 1) : trimmed
67}
68
69export function hostOf(url: string): string {
70  const match = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/?#]*@)?([^/:?#]+)/i.exec(url)
71  return match?.[1] ?? url
72}
73
74export function labelOf(tool: string, input: object): string {
75  const fields = input as Record<string, unknown>
76  const text = (key: string): string => {
77    const value = fields[key]
78    return typeof value === 'string' ? value : ''
79  }
80
81  let label = ''
82  switch (tool) {
83    case 'Bash': {
84      const command = text('command').replace(/\s+/g, ' ').trim()
85      label = command.length > 40 ? `${command.slice(0, 40)}…` : command
86      break
87    }
88    case 'Read':
89    case 'Edit':
90    case 'Write':
91    case 'MultiEdit':
92      label = baseName(text('file_path'))
93      break
94    case 'NotebookEdit':
95    case 'NotebookRead':
96      label = baseName(text('notebook_path'))
97      break
98    case 'WebFetch':
99      label = hostOf(text('url'))
100      break
101    case 'Agent':
102    case 'Task':
103      label = text('description').trim()
104      break
105  }
106
107  return label === '' ? tool : label
108}
109
110// ---------------------------------------------------------------- time
111
112export type Interval = readonly [number, number]
113
114/** Total length covered by the intervals; overlaps count once. */
115export function unionMs(intervals: readonly Interval[]): number {
116  const sorted = intervals.filter(([from, to]) => to > from).sort((a, b) => a[0] - b[0])
117  let total = 0
118  let from = 0
119  let to = 0
120  let isOpen = false
121
122  for (const [start, end] of sorted) {
123    if (isOpen && start <= to) {
124      to = Math.max(to, end)
125      continue
126    }
127    if (isOpen) total += to - from
128    from = start
129    to = end
130    isOpen = true
131  }
132
133  return isOpen ? total + (to - from) : total
134}
135
136export function emptyTotals(): Record<FlightLane, number> {
137  return { Model: 0, Shell: 0, Read: 0, Edit: 0, Agents: 0, Web: 0, Other: 0 }
138}
139
140/** Per-lane active time inside [start, end]; a running segment lasts until `now`. */
141export function laneTotals(
142  segments: readonly FlightSegment[],
143  start: number,
144  end: number,
145  now: number,
146): Record<FlightLane, number> {
147  const byLane: Record<FlightLane, Interval[]> = {
148    Model: [],
149    Shell: [],
150    Read: [],
151    Edit: [],
152    Agents: [],
153    Web: [],
154    Other: [],
155  }
156
157  for (const segment of segments) {
158    const from = Math.max(segment.start, start)
159    const to = Math.min(segment.end ?? now, end)
160    if (to > from) byLane[segment.lane].push([from, to])
161  }
162
163  const totals = emptyTotals()
164  for (const lane of LANES) totals[lane] = unionMs(byLane[lane])
165
166  return totals
167}
168
169export function percent(ms: number, wallMs: number): number {
170  if (!(ms > 0)) return 0
171
172  return Math.min(100, Math.max(1, Math.round((ms * 100) / Math.max(1, wallMs))))
173}
174
175/** `48s`, `4m 12s`, `1h 02m`; under half a second `<1s`. */
176export function formatDuration(ms: number): string {
177  if (!(ms > 0)) return '0s'
178
179  const total = Math.round(ms / 1000)
180  if (total === 0) return '<1s'
181  if (total < 60) return `${total}s`
182
183  const hours = Math.floor(total / 3600)
184  const minutes = Math.floor((total % 3600) / 60)
185  if (hours > 0) return `${hours}h ${String(minutes).padStart(2, '0')}m`
186
187  return `${minutes}m ${total % 60}s`
188}
189
190/** The running clock: `01:42`, or `1:02:03` past an hour. */
191export function formatClock(ms: number): string {
192  const total = Math.max(0, Math.floor(ms / 1000))
193  const hours = Math.floor(total / 3600)
194  const minutes = Math.floor((total % 3600) / 60)
195  const seconds = total % 60
196  const pair = (n: number) => String(n).padStart(2, '0')
197
198  return hours > 0 ? `${hours}:${pair(minutes)}:${pair(seconds)}` : `${pair(minutes)}:${pair(seconds)}`
199}
200
201/** Axis ticks: one decimal under ten seconds, otherwise `formatDuration`. */
202export function formatAxis(ms: number): string {
203  if (!(ms > 0)) return '0s'
204  if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`
205
206  return formatDuration(ms)
207}
208
209export function plural(count: number, noun: string): string {
210  return `${count} ${noun}${count === 1 ? '' : 's'}`
211}
212
213// ---------------------------------------------------------------- the turn
214
215export function freshTurn(
216  now: number,
217  previous: FlightTurn | null,
218  turnId?: string,
219  isEngaged = false,
220): FlightTurn {
221  const turn: FlightTurn = {
222    id: now,
223    startedAt: now,
224    isRunning: true,
225    isEngaged,
226    steps: 0,
227    calls: 0,
228    subCalls: 0,
229    segments: [],
230  }
231  if (turnId !== undefined) turn.turnId = turnId
232  if (previous !== null && !previous.isRunning) {
233    const { previous: _older, ...flat } = previous
234    turn.previous = flat
235  }
236
237  return turn
238}
239
240/** The turn to show when a started turn never engaged: the one it replaced. */
241export function abandonTurn(turn: FlightTurn): FlightTurn | null {
242  return turn.previous ?? null
243}
244
245/** Adds a segment; past MAX_SEGMENTS the oldest finished ones go, the counters stay exact. */
246export function addSegment(turn: FlightTurn, segment: FlightSegment): FlightTurn {
247  let drop = turn.segments.length + 1 - MAX_SEGMENTS
248  const kept = turn.segments.filter(old => {
249    if (drop > 0 && old.end !== undefined) {
250      drop -= 1
251
252      return false
253    }
254
255    return true
256  })
257  const isModel = segment.lane === 'Model'
258
259  return {
260    ...turn,
261    isEngaged: true,
262    steps: turn.steps + (isModel ? 1 : 0),
263    calls: turn.calls + (isModel ? 0 : 1),
264    segments: [...kept, segment],
265  }
266}
267
268export function endSegment(turn: FlightTurn, id: string, at: number): FlightTurn {
269  return {
270    ...turn,
271    segments: turn.segments.map(segment =>
272      segment.id === id && segment.end === undefined
273        ? { ...segment, end: Math.max(at, segment.start) }
274        : segment,
275    ),
276  }
277}
278
279/**
280 * Ends the turn at `now`. When the engine reported the turn's length the window is
281 * re-anchored to it, so the duration, the percentages and the axis all agree with it.
282 */
283export function closeTurn(turn: FlightTurn, now: number, durationMs?: number): FlightTurn {
284  const hasLength = durationMs !== undefined && Number.isFinite(durationMs) && durationMs >= 0
285  const length = hasLength ? durationMs : Math.max(0, now - turn.startedAt)
286
287  return {
288    ...turn,
289    isRunning: false,
290    isEngaged: true,
291    endedAt: now,
292    startedAt: now - length,
293    durationMs: length,
294    segments: turn.segments.map(segment =>
295      segment.end === undefined ? { ...segment, end: Math.max(now, segment.start) } : segment,
296    ),
297  }
298}
299
300export type NowCall = { lane: FlightLane; label: string; ms: number }
301
302export type Analysis = {
303  start: number
304  end: number
305  wallMs: number
306  totals: Record<FlightLane, number>
307  slowest?: FlightSlowest
308  running?: NowCall
309}
310
311/** The slowest tool call (model steps are not calls); a running one counts to `now`. */
312export function slowestCall(
313  segments: readonly FlightSegment[],
314  now: number,
315): FlightSlowest | undefined {
316  let best: FlightSlowest | undefined
317  for (const segment of segments) {
318    if (segment.lane === 'Model') continue
319    const ms = (segment.end ?? now) - segment.start
320    if (ms > 0 && (best === undefined || ms > best.ms)) {
321      best = { label: segment.label, lane: segment.lane, ms }
322    }
323  }
324
325  return best
326}
327
328/** The newest segment that has not ended. */
329export function runningCall(segments: readonly FlightSegment[], now: number): NowCall | undefined {
330  let newest: FlightSegment | undefined
331  for (const segment of segments) {
332    if (segment.end === undefined && (newest === undefined || segment.start >= newest.start)) {
333      newest = segment
334    }
335  }
336
337  return newest && { lane: newest.lane, label: newest.label, ms: Math.max(0, now - newest.start) }
338}
339
340export function analyze(turn: FlightTurn, now: number): Analysis {
341  const start = turn.startedAt
342  // A running turn shows at least a second so the first bar is not the whole axis.
343  const end = turn.isRunning
344    ? Math.max(now, start + 1000)
345    : Math.max(turn.endedAt ?? now, start + 1)
346  const analysis: Analysis = {
347    start,
348    end,
349    wallMs: end - start,
350    totals: laneTotals(turn.segments, start, end, now),
351  }
352  const slowest = slowestCall(turn.segments, now)
353  if (slowest) analysis.slowest = slowest
354  const running = turn.isRunning ? runningCall(turn.segments, now) : undefined
355  if (running) analysis.running = running
356
357  return analysis
358}
359
360export type LaneShare = { lane: FlightLane; ms: number; pct: number }
361
362/** Lanes with any time, most first; equal time keeps the lane order. */
363export function topLanes(
364  totals: Record<FlightLane, number>,
365  wallMs: number,
366  count = LANES.length,
367): LaneShare[] {
368  return LANES.map(lane => ({ lane, ms: totals[lane], pct: percent(totals[lane], wallMs) }))
369    .filter(share => share.ms > 0)
370    .sort((a, b) => b.ms - a.ms)
371    .slice(0, count)
372}
373
374export function summarize(turn: FlightTurn): FlightSummary {
375  const durationMs = turn.durationMs ?? Math.max(0, (turn.endedAt ?? turn.startedAt) - turn.startedAt)
376  const end = turn.startedAt + durationMs
377  const summary: FlightSummary = {
378    at: turn.endedAt ?? end,
379    durationMs,
380    calls: turn.calls,
381    lanes: laneTotals(turn.segments, turn.startedAt, end, end),
382  }
383  const slowest = slowestCall(turn.segments, end)
384  if (slowest) summary.slowest = slowest
385
386  return summary
387}
388
389/** `Flight Recorder: 4m 12s · Shell 61% · slowest: npm test 48s` */
390export function toastText(summary: FlightSummary): string {
391  const parts = [formatDuration(summary.durationMs)]
392  const top = topLanes(summary.lanes, summary.durationMs, 1)[0]
393  if (top) parts.push(`${top.lane} ${top.pct}%`)
394  if (summary.slowest) parts.push(`slowest: ${summary.slowest.label} ${formatDuration(summary.slowest.ms)}`)
395
396  return `Flight Recorder: ${parts.join(' · ')}`
397}
398
399/** What `$.store` held under `history`, minus anything that is not a summary. */
400export function cleanHistory(raw: unknown): FlightSummary[] {
401  if (!Array.isArray(raw)) return []
402
403  return raw.filter((item): item is FlightSummary => {
404    const entry = item as Partial<FlightSummary> | null
405
406    return (
407      typeof entry === 'object' &&
408      entry !== null &&
409      typeof entry.durationMs === 'number' &&
410      typeof entry.lanes === 'object' &&
411      entry.lanes !== null
412    )
413  })
414}
415
416export function pushHistory(
417  history: readonly FlightSummary[],
418  summary: FlightSummary,
419  limit = HISTORY_LIMIT,
420): FlightSummary[] {
421  return [...history, summary].slice(-limit)
422}
423
424/** Top lanes by summed active time over summed duration of the last `window` turns. */
425export function historyTop(
426  history: readonly FlightSummary[],
427  window = 20,
428  count = 3,
429): { turns: number; shares: LaneShare[] } {
430  const recent = history.slice(-window)
431  const totals = emptyTotals()
432  let wallMs = 0
433  for (const entry of recent) {
434    wallMs += entry.durationMs
435    for (const lane of LANES) totals[lane] += entry.lanes[lane] ?? 0
436  }
437
438  return { turns: recent.length, shares: topLanes(totals, wallMs, count) }
439}
440
441// ---------------------------------------------------------------- the grid
442
443export type Cell = { glyph: string; color: string }
444
445export type GridInput = {
446  segments: readonly FlightSegment[]
447  start: number
448  end: number
449  now: number
450  columns: number
451  /** Running segments draw in a brighter tint while this is true. */
452  isShimmer: boolean
453}
454
455const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value))
456
457/** `#RRGGBB` mixed toward white. */
458export function lighten(hex: string, amount = 0.4): string {
459  const value = parseInt(hex.slice(1), 16)
460  const mix = (channel: number) => Math.round(channel + (255 - channel) * amount)
461  const out = [(value >> 16) & 255, (value >> 8) & 255, value & 255].map(channel =>
462    mix(channel).toString(16).padStart(2, '0'),
463  )
464
465  return `#${out.join('').toUpperCase()}`
466}
467
468/**
469 * Lanes x columns. The x axis maps [start, end] onto the columns; a cell is filled when
470 * a segment of its lane overlaps its slice, and every segment gets at least one cell.
471 */
472export function buildCells(input: GridInput): Cell[][] {
473  const { segments, start, end, now, columns, isShimmer } = input
474  const span = Math.max(1, end - start)
475  const marks = LANES.map(() => new Array<number>(columns).fill(0))
476
477  for (const segment of segments) {
478    const row = marks[LANES.indexOf(segment.lane)]
479    const from = segment.start
480    const to = segment.end ?? now
481    if (!row || to < start || from > end) continue
482
483    const first = clamp(Math.floor(((from - start) * columns) / span), 0, columns - 1)
484    const last = clamp(Math.ceil(((to - start) * columns) / span) - 1, first, columns - 1)
485    const mark = segment.end === undefined ? 2 : 1
486    for (let column = first; column <= last; column++) {
487      row[column] = Math.max(row[column] ?? 0, mark)
488    }
489  }
490
491  return LANES.map((lane, index) => {
492    const color = LANE_COLORS[lane]
493
494    return (marks[index] ?? []).map((mark): Cell => {
495      if (mark === 0) return { glyph: '·', color: EMPTY_COLOR }
496
497      return { glyph: '█', color: mark === 2 && isShimmer ? lighten(color) : color }
498    })
499  })
500}
501
502/** Consecutive cells of one colour become one run (one `Text`). */
503export function groupRuns(row: readonly Cell[]): { text: string; color: string }[] {
504  const runs: { text: string; color: string }[] = []
505  for (const cell of row) {
506    const last = runs[runs.length - 1]
507    if (last && last.color === cell.color) last.text += cell.glyph
508    else runs.push({ text: cell.glyph, color: cell.color })
509  }
510
511  return runs
512}
513
514const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
515
516/** Standard padded base64 (the module environment declares `btoa` but no byte encoder). */
517export function toBase64(bytes: Uint8Array): string {
518  let out = ''
519  for (let i = 0; i < bytes.length; i += 3) {
520    const left = bytes.length - i
521    const word = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
522    out += BASE64.charAt((word >> 18) & 63) + BASE64.charAt((word >> 12) & 63)
523    out += left > 1 ? BASE64.charAt((word >> 6) & 63) : '='
524    out += left > 2 ? BASE64.charAt(word & 63) : '='
525  }
526
527  return out
528}
529
530/** `RasterProps.cells`: base64 of little-endian u32 triplets [codePoint, 0x00RRGGBB, bg]. */
531export function encodeRaster(cells: readonly (readonly Cell[])[], columns: number): string {
532  const bytes = new Uint8Array(cells.length * columns * 12)
533  let at = 0
534  const put = (word: number) => {
535    bytes[at++] = word & 255
536    bytes[at++] = (word >>> 8) & 255
537    bytes[at++] = (word >>> 16) & 255
538    bytes[at++] = (word >>> 24) & 255
539  }
540
541  for (const row of cells) {
542    for (let column = 0; column < columns; column++) {
543      const cell = row[column] ?? { glyph: ' ', color: EMPTY_COLOR }
544      put(cell.glyph.codePointAt(0) ?? 32)
545      put(parseInt(cell.color.slice(1), 16))
546      put(DEFAULT_BG)
547    }
548  }
549
550  return toBase64(bytes)
551}
552
553/** `0s`, the midpoint and the end time, placed under the grid's columns. */
554export function axisLine(columns: number, spanMs: number): string {
555  const line = new Array<string>(columns).fill(' ')
556  const put = (at: number, text: string) => {
557    for (let i = 0; i < text.length; i++) {
558      if (at + i >= 0 && at + i < columns) line[at + i] = text.charAt(i)
559    }
560  }
561  const left = '0s'
562  const right = formatAxis(spanMs)
563  const middle = formatAxis(spanMs / 2)
564  const middleAt = Math.round((columns - middle.length) / 2)
565
566  put(0, left)
567  put(columns - right.length, right)
568  if (middleAt > left.length && middleAt + middle.length < columns - right.length) {
569    put(middleAt, middle)
570  }
571
572  return line.join('')
573}
574
575/** One lane's stats cell pair: ` 61%` and `  2m 34s`, 12 characters in all. */
576export function statsText(ms: number, wallMs: number): { pct: string; duration: string; isZero: boolean } {
577  const share = percent(ms, wallMs)
578
579  return {
580    pct: `${share}%`.padStart(4),
581    duration: ` ${formatDuration(ms)}`.padStart(8),
582    isZero: share === 0,
583  }
584}
585
types/index.d.ts 47 lines
1export type FlightLane = 'Model' | 'Shell' | 'Read' | 'Edit' | 'Agents' | 'Web' | 'Other'
2
3/** One call (or model step) on the timeline; `end` is absent while it runs. */
4export type FlightSegment = {
5  id: string
6  lane: FlightLane
7  label: string
8  start: number
9  end?: number
10}
11
12/** The main-loop turn being recorded, or the last one that finished. */
13export type FlightTurn = {
14  /** Identity of the turn; unchanged when the finished turn is re-anchored. */
15  id: number
16  turnId?: string
17  startedAt: number
18  endedAt?: number
19  durationMs?: number
20  isRunning: boolean
21  /** True once the engine confirmed a model turn (`turn.start`, a step or a call). */
22  isEngaged: boolean
23  steps: number
24  calls: number
25  subCalls: number
26  segments: FlightSegment[]
27  /** The finished turn this one replaced; comes back if this one never engages. */
28  previous?: FlightTurn
29}
30
31export type FlightSlowest = { label: string; lane: FlightLane; ms: number }
32
33/** The compact record of a finished turn kept in `$.store` key `history`. */
34export type FlightSummary = {
35  at: number
36  durationMs: number
37  calls: number
38  lanes: Record<FlightLane, number>
39  slowest?: FlightSlowest
40}
41
42declare module 'claude-code' {
43  interface PluginState {
44    'flight-recorder': { turn: FlightTurn | null; tick: number }
45  }
46}
47