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

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:
| Row | What counts |
|---|---|
| Model | Claude writing its answer or deciding the next step |
| Shell | Bash and background shell tools |
| Read | Read, Grep, Glob |
| Edit | Edit, Write, notebook edits |
| Agents | sub-agents and workflows |
| Web | web search and fetch, browser tools |
| Other | everything 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.
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.
hooks/register.tsx 426 lines1import { 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}
426hooks/model.ts 585 lines1import 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}
585types/index.d.ts 47 lines1export 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