See inside a turn: a live Gantt timeline of model requests, tool calls and subagents, with tokens and cost per lane.

Shows turns, tool activity and helper activity over time so you can see when work happened and what ran alongside it.
See the complete guide and creation prompt.
Test from the kit root:
claude plugin validate plugins/flight-recorder
claude plugin test plugins/flight-recorderhooks/flight-recorder.mjs 1180 lines1// Flight Recorder: see inside a turn. A live Gantt timeline in a pane, one lane
2// for the main agent and one per subagent. Every model request (turn.step) and
3// every tool call (tool.call) is a bar from its start to its end, colored by
4// model family or tool kind, with requests, tools, tokens and estimated cost
5// per lane on the right.
6//
7// Data lives in $.state ("rec"), written by the event hooks with update() so
8// parallel subagents never lose a write. The pane draws one Raster (a cell
9// grid); while a turn runs, a 10 fps timer repaints it in place with $.ui.blit
10// so the "now" edge and running bars advance without a render pass. The timer
11// stops when nothing is running or the pane is closed.
12
13import { update } from "claude-code";
14
15const PANE = "flight-recorder";
16const TITLE = "Flight Recorder";
17const RASTER_KEY = "timeline";
18const FRAME_MS = 100;
19const KEEP_TURNS = 10;
20const MAX_SEGS = 1500;
21const PACK_TOLERANCE_MS = 400;
22const DEFAULT_VIEW = { turns: 1 };
23const EMPTY_REC = { count: 0, turns: [] };
24
25const rec = { plugin: "flight-recorder", key: "rec" };
26const view = { plugin: "flight-recorder", key: "view" };
27
28// List prices in USD per million tokens (model-router's table). First match wins.
29const PRICES = [
30 { match: /opus-5-5/, input: 4, output: 20, cacheWrite: 5, cacheRead: 0.2 },
31 { match: /opus/, input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
32 { match: /sonnet-5/, input: 2, output: 10, cacheWrite: 2.5, cacheRead: 0.2 },
33 { match: /sonnet/, input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
34 { match: /haiku/, input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 },
35 { match: /fable-5-1|mythos-5-1/, input: 10, output: 50, cacheWrite: 12.5, cacheRead: 0.25 },
36 { match: /fable|mythos/, input: 10, output: 50, cacheWrite: 12.5, cacheRead: 1 },
37];
38
39// ---------------------------------------------------------------- palette
40
41const C = {
42 frame: 0x0b0d11,
43 head: 0x10131a,
44 laneA: 0x151922,
45 laneB: 0x1a1f2a,
46 grid: 0x2a3140,
47 text: 0xd6dbe4,
48 dim: 0x7a8496,
49 faint: 0x4a5263,
50 ink: 0x0b0d11,
51 white: 0xffffff,
52 coral: 0xe8603c,
53 rec: 0xff4d4d,
54 now: 0xffd166,
55 done: 0x3ddc84,
56};
57
58const FAMILY = {
59 fable: 0xf5c542,
60 opus: 0xa070ff,
61 sonnet: 0x4c8dff,
62 haiku: 0x3ddc84,
63 other: 0x9aa3b2,
64};
65
66const TOOL = {
67 read: 0x22d3ee,
68 edit: 0xe8603c,
69 bash: 0xffb020,
70 agent: 0xf2f4f8,
71 mcp: 0xff5fa2,
72 other: 0x8b9bb4,
73 failed: 0xff3030,
74};
75
76const LEFT_EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
77
78// ---------------------------------------------------------------- register
79
80export function register(on) {
81 let ticker = null;
82 let ticking = false;
83 let paneOpen = false;
84 let mounted = null; // { columns, rows } of the Raster on screen
85 let misses = 0;
86 let deps = null; // closures over a live `$`, for the frame timer
87 let nextIsNotice = false; // the coming turn.start answers a background task, not a person
88
89 const stopTicker = () => {
90 if (ticker && typeof ticker.cancel === "function") ticker.cancel();
91 ticker = null;
92 };
93
94 const tick = async () => {
95 if (ticking || !deps) return;
96 ticking = true;
97 try {
98 const { value: r = EMPTY_REC } = await deps.readRec();
99 const { value: v = DEFAULT_VIEW } = await deps.readView();
100 const now = await deps.now();
101 if (paneOpen && mounted) {
102 const frame = buildFrame(r, v, { columns: mounted.columns, now });
103 if (frame.rows === mounted.rows) {
104 const res = await deps.blit({
105 requestId: PANE,
106 key: RASTER_KEY,
107 cells: encodeCells(frame.words),
108 columns: frame.columns,
109 rows: frame.rows,
110 });
111 misses = res && res.deny ? misses + 1 : 0;
112 }
113 }
114 if (!paneOpen || misses > 30 || !isLive(r, v, now)) stopTicker();
115 } catch {
116 stopTicker();
117 } finally {
118 ticking = false;
119 }
120 };
121
122 const kick = () => {
123 if (!ticker && paneOpen && deps) ticker = deps.every(FRAME_MS, tick);
124 };
125
126 on("session.start", async ($, e, next) => {
127 const result = await next(e);
128 deps = {
129 now: () => $.clock.now(),
130 readRec: () => $.state.get(rec),
131 readView: () => $.state.get(view),
132 blit: (args) => $.ui.blit(args),
133 every: (ms, fn) => $.clock.every(ms, fn),
134 };
135 const stored = await $.store.get("turns");
136 const turns = clampTurns(stored);
137 await $.state.set(view, { turns });
138 await $.command.register({
139 name: "timeline",
140 description: "Flight Recorder: live timeline of requests, tools and subagents",
141 argumentHint: "[clear|turns <n>|close]",
142 immediate: true,
143 });
144 return result;
145 });
146
147 on("command.run", { command: "timeline" }, async ($, e) => {
148 if (!deps) {
149 deps = {
150 now: () => $.clock.now(),
151 readRec: () => $.state.get(rec),
152 readView: () => $.state.get(view),
153 blit: (args) => $.ui.blit(args),
154 every: (ms, fn) => $.clock.every(ms, fn),
155 };
156 }
157 const words = (e.args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
158 const verb = words[0] ?? "";
159
160 if (verb === "clear") {
161 await update($, rec, (r = EMPTY_REC) => ({ count: r.count ?? 0, turns: [] }));
162 return { text: "Flight Recorder cleared. The next turn starts a fresh timeline." };
163 }
164
165 if (verb === "turns") {
166 const n = Number(words[1]);
167 if (!Number.isInteger(n) || n < 1 || n > KEEP_TURNS) {
168 return { text: `Usage /timeline turns <1-${KEEP_TURNS}>` };
169 }
170 await $.state.set(view, { turns: n });
171 await $.store.set("turns", n);
172 return { text: `Flight Recorder shows the last ${n} turn${n === 1 ? "" : "s"}.` };
173 }
174
175 const panes = await $.ui.panes();
176 const isOpen = panes.some((p) => p.id === PANE);
177
178 if (verb === "close" || verb === "off" || (verb === "" && isOpen)) {
179 await $.ui.close({ id: PANE });
180 paneOpen = false;
181 stopTicker();
182 return { text: "Flight Recorder closed." };
183 }
184
185 if (verb !== "" && verb !== "open") {
186 return { text: "Usage /timeline [clear|turns <n>|close]" };
187 }
188
189 const { value: r = EMPTY_REC } = await $.state.get(rec);
190 const { value: v = DEFAULT_VIEW } = await $.state.get(view);
191 const width = e.presentation?.columns ?? 120;
192 const wanted = Math.max(70, Math.min(140, Math.round(width * 0.58)));
193 const probe = buildFrame(r, v, { columns: wanted, now: await $.clock.now() });
194 const opened = await $.ui.open({
195 id: PANE,
196 title: TITLE,
197 rows: Math.max(12, probe.rows),
198 columns: wanted,
199 });
200 paneOpen = true;
201 kick();
202 if (opened && opened.isPlaced === false) {
203 return { text: `Flight Recorder is open but waits for room (${opened.reason}).` };
204 }
205 return { text: "Flight Recorder open. Send a prompt and watch the turn unfold." };
206 });
207
208 on("ui.close", async ($, e, next) => {
209 if (e.id === PANE) {
210 paneOpen = false;
211 mounted = null;
212 stopTicker();
213 }
214 return next(e);
215 });
216
217 on("prompt.submit", async ($, e, next) => {
218 const kind = e.origin && e.origin.kind;
219 if (e.turnId === undefined) nextIsNotice = kind === "task-notification";
220 return next(e);
221 });
222
223 on("turn.start", async ($, e, next) => {
224 const at = await $.clock.now();
225 const isNotice = nextIsNotice || isNoticeText(e.text);
226 nextIsNotice = false;
227 await update($, rec, (r = EMPTY_REC) =>
228 isNotice && r.turns && r.turns.length > 0
229 ? applyTurnResume(r, { id: e.turnId, at })
230 : applyTurnStart(r, { id: e.turnId, prompt: e.text ?? "", at }),
231 );
232 kick();
233 return next(e);
234 });
235
236 on("turn.step", async function* ($, e, next) {
237 const at = await $.clock.now();
238 const segId = `s:${e.turnId}:${e.agentId ?? "main"}:${e.index}:${at}`;
239 await update($, rec, (r = EMPTY_REC) =>
240 applyStepStart(r, { turnId: e.turnId, agentId: e.agentId, model: e.model, segId, at }),
241 );
242 kick();
243 let result = null;
244 try {
245 result = yield* next(e);
246 return result;
247 } finally {
248 try {
249 const end = await $.clock.now();
250 const usage = result && result.usage ? result.usage : null;
251 await update($, rec, (r = EMPTY_REC) =>
252 applyStepEnd(r, { agentId: e.agentId, segId, at: end, usage, model: e.model }),
253 );
254 } catch {
255 // the dispatch was abandoned; the turn's end closes what is left open
256 }
257 }
258 });
259
260 on("tool.call", async ($, e, next) => {
261 const at = await $.clock.now();
262 const segId = e.tool_use_id ? `t:${e.tool_use_id}` : `t:${e.tool}:${at}`;
263 await update($, rec, (r = EMPTY_REC) =>
264 applyToolStart(r, { agentId: e.agentId, name: String(e.tool), segId, at }),
265 );
266 kick();
267 let failed = true;
268 try {
269 const result = await next(e);
270 failed = Boolean(result && (result.deny || result.isError));
271 return result;
272 } finally {
273 try {
274 const end = await $.clock.now();
275 await update($, rec, (r = EMPTY_REC) => applyToolEnd(r, { agentId: e.agentId, segId, at: end, failed }));
276 } catch {
277 // abandoned; closed at the turn's end
278 }
279 }
280 });
281
282 on("agent.spawn", async ($, e, next) => {
283 const result = await next(e);
284 if (result && result.agentId) {
285 const at = await $.clock.now();
286 await update($, rec, (r = EMPTY_REC) =>
287 applySpawn(r, {
288 id: result.agentId,
289 type: e.subagentType || "agent",
290 model: result.model || "",
291 parent: e.parentAgentId || "",
292 toolUseId: e.tool_use_id || "",
293 at,
294 }),
295 );
296 kick();
297 }
298 return result;
299 });
300
301 on("turn.complete", async ($, e, next) => {
302 const result = await next(e);
303 const at = await $.clock.now();
304 await update($, rec, (r = EMPTY_REC) => applyComplete(r, { agentId: e.agentId, turnId: e.turnId, at }));
305 return result;
306 });
307
308 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
309 const { value: r = EMPTY_REC } = await $.state.get(rec);
310 const { value: v = DEFAULT_VIEW } = await $.state.get(view);
311 const now = await $.clock.now();
312 paneOpen = true;
313 if (e.surface !== "terminal") {
314 const { Box, Text } = $.ui.resolve(e);
315 return fallbackTree(Box, Text, r, v, now);
316 }
317 const { Raster } = $.ui.resolve(e);
318 const columns = Math.max(24, Math.min(512, e.props.bodyColumns || 100));
319 const frame = buildFrame(r, v, { columns, now });
320 mounted = { columns: frame.columns, rows: frame.rows };
321 return Raster({ key: RASTER_KEY, columns: frame.columns, rows: frame.rows, cells: encodeCells(frame.words) });
322 });
323}
324
325// ---------------------------------------------------------------- bookkeeping (pure)
326
327function clampTurns(n) {
328 const v = Number(n);
329 return Number.isInteger(v) && v >= 1 && v <= KEEP_TURNS ? v : DEFAULT_VIEW.turns;
330}
331
332function clone(r) {
333 return structuredClone({ count: r.count ?? 0, turns: Array.isArray(r.turns) ? r.turns : [] });
334}
335
336function newLane(id, at, fields = {}) {
337 return {
338 id,
339 label: id === "main" ? "main" : `agent ${shortId(id)}`,
340 type: id === "main" ? "main" : "agent",
341 model: "",
342 parent: "",
343 toolUseId: "",
344 t0: at,
345 done: null,
346 segs: [],
347 ...fields,
348 };
349}
350
351function newTurn(r, id, prompt, at) {
352 r.count = (r.count ?? 0) + 1;
353 const turn = { n: r.count, id, prompt: oneLine(prompt).slice(0, 160), t0: at, t1: null, lanes: [newLane("main", at)] };
354 r.turns.push(turn);
355 if (r.turns.length > KEEP_TURNS) r.turns.splice(0, r.turns.length - KEEP_TURNS);
356 return turn;
357}
358
359function latestTurn(r, at) {
360 return r.turns[r.turns.length - 1] ?? newTurn(r, `t${at}`, "", at);
361}
362
363// The lane a main-loop or subagent event belongs to, made when missing.
364function laneFor(r, agentId, at, turnId) {
365 if (agentId === undefined || agentId === null || agentId === "") {
366 let turn = turnId ? r.turns.find((t) => t.id === turnId) : undefined;
367 if (!turn) turn = latestTurn(r, at);
368 let lane = turn.lanes.find((l) => l.id === "main");
369 if (!lane) {
370 lane = newLane("main", at);
371 turn.lanes.unshift(lane);
372 }
373 return { turn, lane };
374 }
375 for (let i = r.turns.length - 1; i >= 0; i--) {
376 const lane = r.turns[i].lanes.find((l) => l.id === agentId);
377 if (lane) return { turn: r.turns[i], lane };
378 }
379 const turn = latestTurn(r, at);
380 const lane = newLane(agentId, at);
381 turn.lanes.push(lane);
382 return { turn, lane };
383}
384
385function findSeg(r, agentId, segId) {
386 for (let i = r.turns.length - 1; i >= 0; i--) {
387 for (const lane of r.turns[i].lanes) {
388 if (agentId && lane.id !== agentId) continue;
389 if (!agentId && lane.id !== "main") continue;
390 const seg = lane.segs.find((s) => s.id === segId);
391 if (seg) return { turn: r.turns[i], lane, seg };
392 }
393 }
394 return null;
395}
396
397function countSegs(turn) {
398 let n = 0;
399 for (const l of turn.lanes) n += l.segs.length;
400 return n;
401}
402
403export function applyTurnStart(r0, { id, prompt, at }) {
404 const r = clone(r0);
405 // Close anything a previous turn left open (an interrupt mid-call).
406 for (const t of r.turns) {
407 if (t.t1 === null) t.t1 = at;
408 for (const l of t.lanes) for (const s of l.segs) if (s.t1 === null && l.id === "main") s.t1 = at;
409 }
410 newTurn(r, id, prompt, at);
411 return r;
412}
413
414// A turn the engine starts to hand the model a background task's result (a
415// subagent finished) belongs to the person's turn it came from: reopen it.
416export function applyTurnResume(r0, { id, at }) {
417 const r = clone(r0);
418 const turn = r.turns[r.turns.length - 1];
419 if (!turn) return applyTurnStart(r0, { id, prompt: "", at });
420 turn.id = id;
421 turn.t1 = null;
422 const main = turn.lanes.find((l) => l.id === "main");
423 if (main) main.done = null;
424 return r;
425}
426
427export function isNoticeText(text) {
428 return /^\s*<(task-notification|agent-message|background-task|system-reminder)\b/.test(String(text ?? ""));
429}
430
431export function applyStepStart(r0, { turnId, agentId, model, segId, at }) {
432 const r = clone(r0);
433 const { turn, lane } = laneFor(r, agentId, at, agentId ? undefined : turnId);
434 lane.model = model || lane.model;
435 if (countSegs(turn) < MAX_SEGS) {
436 lane.segs.push({ id: segId, kind: "req", name: model || "", t0: at, t1: null, out: 0, tok: 0, cost: 0, err: false });
437 }
438 return r;
439}
440
441export function applyStepEnd(r0, { agentId, segId, at, usage, model }) {
442 const r = clone(r0);
443 const hit = findSeg(r, agentId, segId);
444 if (!hit) return r;
445 const { seg, lane } = hit;
446 seg.t1 = at;
447 if (usage) {
448 seg.name = usage.model || model || seg.name;
449 seg.out = usage.output_tokens ?? 0;
450 seg.tok =
451 (usage.input_tokens ?? 0) +
452 (usage.cache_creation_input_tokens ?? 0) +
453 (usage.cache_read_input_tokens ?? 0) +
454 (usage.output_tokens ?? 0);
455 seg.cost = costOf(usage.model || model, usage);
456 lane.model = usage.model || lane.model;
457 } else {
458 seg.err = true;
459 }
460 return r;
461}
462
463export function applyToolStart(r0, { agentId, name, segId, at }) {
464 const r = clone(r0);
465 const { turn, lane } = laneFor(r, agentId, at);
466 if (countSegs(turn) < MAX_SEGS && !lane.segs.some((s) => s.id === segId)) {
467 lane.segs.push({ id: segId, kind: "tool", name, t0: at, t1: null, out: 0, tok: 0, cost: 0, err: false });
468 }
469 return r;
470}
471
472export function applyToolEnd(r0, { agentId, segId, at, failed }) {
473 const r = clone(r0);
474 const hit = findSeg(r, agentId, segId);
475 if (!hit) return r;
476 hit.seg.t1 = at;
477 hit.seg.err = Boolean(failed);
478 return r;
479}
480
481export function applySpawn(r0, { id, type, model, parent, toolUseId, at }) {
482 const r = clone(r0);
483 const label = `${type} ${shortId(id)}`;
484 for (const t of r.turns) {
485 const lane = t.lanes.find((l) => l.id === id);
486 if (lane) {
487 Object.assign(lane, { label, type, parent, toolUseId, model: lane.model || model });
488 return r;
489 }
490 }
491 const turn = latestTurn(r, at);
492 const lane = newLane(id, at, { label, type, model, parent, toolUseId });
493 // Nested subagents sit right under their parent.
494 const pi = parent ? turn.lanes.findIndex((l) => l.id === parent) : -1;
495 if (pi >= 0) {
496 let at2 = pi + 1;
497 while (at2 < turn.lanes.length && turn.lanes[at2].parent === parent) at2++;
498 turn.lanes.splice(at2, 0, lane);
499 } else {
500 turn.lanes.push(lane);
501 }
502 return r;
503}
504
505export function applyComplete(r0, { agentId, turnId, at }) {
506 const r = clone(r0);
507 if (agentId) {
508 for (const t of r.turns) {
509 const lane = t.lanes.find((l) => l.id === agentId);
510 if (lane) {
511 lane.done = at;
512 for (const s of lane.segs) if (s.t1 === null) s.t1 = at;
513 }
514 }
515 return r;
516 }
517 const turn = r.turns.find((t) => t.id === turnId) ?? r.turns[r.turns.length - 1];
518 if (!turn) return r;
519 turn.t1 = at;
520 const main = turn.lanes.find((l) => l.id === "main");
521 if (main) {
522 main.done = at;
523 for (const s of main.segs) if (s.t1 === null) s.t1 = at;
524 }
525 return r;
526}
527
528export function isLive(r, v, now) {
529 const shown = (r.turns ?? []).slice(-clampTurns(v?.turns));
530 for (const t of shown) {
531 if (t.t1 === null && now - t.t0 < 3_600_000) return true;
532 if (t.lanes.some((l) => l.id !== "main" && l.done === null && now - lastActivity(l) < 60_000)) return true;
533 for (const l of t.lanes) for (const s of l.segs) if (s.t1 === null && now - s.t0 < 3_600_000) return true;
534 }
535 return false;
536}
537
538function lastActivity(lane) {
539 let t = lane.t0;
540 for (const s of lane.segs) t = Math.max(t, s.t1 ?? s.t0);
541 return t;
542}
543
544// ---------------------------------------------------------------- money and names
545
546function priceOf(model) {
547 return PRICES.find((p) => p.match.test(String(model ?? "")));
548}
549
550export function costOf(model, u) {
551 const p = priceOf(model);
552 if (!p || !u) return 0;
553 return (
554 ((u.input_tokens ?? 0) * p.input +
555 (u.output_tokens ?? 0) * p.output +
556 (u.cache_creation_input_tokens ?? 0) * p.cacheWrite +
557 (u.cache_read_input_tokens ?? 0) * p.cacheRead) /
558 1_000_000
559 );
560}
561
562export function familyOf(model) {
563 const m = String(model ?? "").toLowerCase();
564 if (/fable|mythos/.test(m)) return "fable";
565 if (/opus/.test(m)) return "opus";
566 if (/sonnet/.test(m)) return "sonnet";
567 if (/haiku/.test(m)) return "haiku";
568 return "other";
569}
570
571export function toolKind(name) {
572 const n = String(name ?? "");
573 if (n.startsWith("mcp__")) return "mcp";
574 if (/^(Read|Grep|Glob|LS|NotebookRead)$/.test(n)) return "read";
575 if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(n)) return "edit";
576 if (/^(Bash|BashOutput|KillShell|KillBash|PowerShell|Monitor)$/.test(n)) return "bash";
577 if (/^(Agent|Task)$/.test(n)) return "agent";
578 return "other";
579}
580
581function toolGlyph(name) {
582 const n = String(name ?? "");
583 const kind = toolKind(n);
584 if (kind === "mcp") return "M";
585 if (n === "Bash") return "$";
586 if (n === "Glob") return "*";
587 return (n[0] ?? "?").toUpperCase();
588}
589
590function toolLabel(name) {
591 const n = String(name ?? "");
592 if (n.startsWith("mcp__")) return n.split("__").slice(-1)[0] || "mcp";
593 return n;
594}
595
596function shortId(id) {
597 const s = String(id ?? "").replace(/^(agent|subagent|task)[-_]?/i, "");
598 return s.slice(0, 4) || "????";
599}
600
601function oneLine(s) {
602 return String(s ?? "").replace(/\s+/g, " ").trim();
603}
604
605export function fmtTok(n) {
606 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
607 if (n >= 1000) return `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k`;
608 return String(Math.round(n));
609}
610
611export function fmtUsd(n) {
612 if (n <= 0) return "$0";
613 if (n < 1) return `$${n.toFixed(3)}`;
614 return n < 100 ? `$${n.toFixed(2)}` : `$${n.toFixed(0)}`;
615}
616
617export function fmtSecs(ms) {
618 const s = ms / 1000;
619 if (s < 60) return `${s.toFixed(1)}s`;
620 const m = Math.floor(s / 60);
621 const r = Math.floor(s % 60);
622 return `${m}m ${String(r).padStart(2, "0")}s`;
623}
624
625function fmtTick(sec) {
626 if (sec < 120 || sec % 60 !== 0) {
627 if (sec >= 120) return `${Math.floor(sec / 60)}m${String(sec % 60).padStart(2, "0")}`;
628 return `${sec}s`;
629 }
630 return `${sec / 60}m`;
631}
632
633// ---------------------------------------------------------------- time scale (pure)
634
635const NICE_SPANS = [10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 300, 450, 600, 900, 1200, 1800, 2700, 3600, 5400, 7200];
636const NICE_STEPS = [1, 2, 5, 10, 15, 20, 30, 60, 120, 300, 600, 900, 1800, 3600];
637
638// The visible span in ms: a live turn uses the next nice span above its
639// elapsed time (so the "now" edge walks right, then the scale steps), a
640// finished one fits its elapsed time exactly.
641export function spanFor(elapsedMs, isLiveTurn) {
642 const e = Math.max(1, elapsedMs);
643 if (!isLiveTurn) return Math.max(1000, e);
644 const want = (e * 1.06) / 1000;
645 for (const s of NICE_SPANS) if (s >= want) return s * 1000;
646 return Math.ceil(want / 3600) * 3600 * 1000;
647}
648
649// Tick step in seconds so labels are at least `minCols` apart.
650export function tickStep(spanMs, cols, minCols = 9) {
651 const span = spanMs / 1000;
652 for (const s of NICE_STEPS) if ((s / span) * cols >= minCols) return s;
653 return NICE_STEPS[NICE_STEPS.length - 1];
654}
655
656// Greedy interval packing: each segment gets the first sub-row free at its
657// start (with a small tolerance, since a tool may start while its request is
658// still closing). Open segments run to infinity. Returns rows per seg index.
659export function packLane(segs0) {
660 // A request's row is free from the moment its first tool starts: tools run
661 // while the response is still streaming, and should not push a new row.
662 const segs = segs0.map((s) => {
663 if (s.kind !== "req") return s;
664 let end = s.t1 === null ? Infinity : s.t1;
665 for (const o of segs0) if (o.kind === "tool" && o.t0 >= s.t0 && o.t0 < end) end = o.t0;
666 return { ...s, t1: end === Infinity ? null : end };
667 });
668 const order = segs.map((s, i) => i).sort((a, b) => segs[a].t0 - segs[b].t0 || a - b);
669 const ends = [];
670 const rows = new Array(segs.length).fill(0);
671 for (const i of order) {
672 const s = segs[i];
673 let row = ends.findIndex((end) => end <= s.t0 + PACK_TOLERANCE_MS);
674 if (row < 0) {
675 row = ends.length;
676 ends.push(0);
677 }
678 ends[row] = s.t1 === null ? Infinity : Math.max(s.t1, s.t0);
679 rows[i] = row;
680 }
681 return { rows, depth: Math.max(1, ends.length) };
682}
683
684// ---------------------------------------------------------------- canvas
685
686class Canvas {
687 constructor(columns) {
688 this.columns = columns;
689 this.lines = [];
690 }
691 row(bg) {
692 const line = new Uint32Array(this.columns * 3);
693 for (let x = 0; x < this.columns; x++) {
694 line[x * 3] = 0x20;
695 line[x * 3 + 1] = C.text;
696 line[x * 3 + 2] = bg;
697 }
698 this.lines.push(line);
699 return this.lines.length - 1;
700 }
701 put(x, y, ch, fg, bg) {
702 if (x < 0 || x >= this.columns || y < 0 || y >= this.lines.length) return;
703 const line = this.lines[y];
704 line[x * 3] = typeof ch === "number" ? ch : ch.codePointAt(0) ?? 0x20;
705 if (fg !== undefined) line[x * 3 + 1] = fg;
706 if (bg !== undefined) line[x * 3 + 2] = bg;
707 }
708 bgAt(x, y) {
709 return this.lines[y][x * 3 + 2];
710 }
711 chAt(x, y) {
712 return this.lines[y][x * 3];
713 }
714 text(x, y, str, fg, bg, max = Infinity) {
715 let i = 0;
716 for (const ch of String(str)) {
717 if (i >= max) break;
718 this.put(x + i, y, ch, fg, bg);
719 i++;
720 }
721 return x + i;
722 }
723 words() {
724 const out = new Uint32Array(this.lines.length * this.columns * 3);
725 this.lines.forEach((line, i) => out.set(line, i * this.columns * 3));
726 return out;
727 }
728}
729
730function mix(a, b, t) {
731 const r = Math.round(((a >> 16) & 255) * (1 - t) + ((b >> 16) & 255) * t);
732 const g = Math.round(((a >> 8) & 255) * (1 - t) + ((b >> 8) & 255) * t);
733 const bl = Math.round((a & 255) * (1 - t) + (b & 255) * t);
734 return (r << 16) | (g << 8) | bl;
735}
736
737function luma(c) {
738 return 0.299 * ((c >> 16) & 255) + 0.587 * ((c >> 8) & 255) + 0.114 * (c & 255);
739}
740
741// A bar from p0 to p1 in eighths of a cell on row y, starting at column x0,
742// with 1/8-cell edges: a right edge is a left-aligned partial block in the bar
743// color, a left edge the same glyph inverted (background color over the bar).
744function bar(cv, y, x0, p0, p1, color) {
745 const a = Math.max(0, Math.round(p0));
746 const z = Math.max(a, Math.round(p1));
747 if (z <= a) return { first: -1, last: -1 };
748 const c0 = Math.floor(a / 8);
749 const c1 = Math.floor((z - 1) / 8);
750 let first = -1;
751 let last = -1;
752 for (let c = c0; c <= c1; c++) {
753 const lo = Math.max(a, c * 8) - c * 8;
754 const hi = Math.min(z, c * 8 + 8) - c * 8;
755 const x = x0 + c;
756 const under = cv.bgAt(x, y);
757 if (lo === 0 && hi === 8) {
758 cv.put(x, y, 0x20, C.ink, color);
759 if (first < 0) first = x;
760 last = x;
761 } else if (lo === 0) {
762 cv.put(x, y, LEFT_EIGHTHS[hi], color, under);
763 } else if (hi === 8) {
764 cv.put(x, y, LEFT_EIGHTHS[lo], under, color);
765 } else {
766 cv.put(x, y, LEFT_EIGHTHS[Math.max(1, hi)], color, under);
767 }
768 }
769 return { first, last };
770}
771
772// A label inside a bar, on the longest run of its cells nothing drew over
773// since; the full text with a cell of padding, else the short glyph.
774function labelIn(cv, y, span, text, glyph, color) {
775 if (span.first < 0) return;
776 let best = { start: -1, len: 0 };
777 let start = -1;
778 for (let x = span.first; x <= span.last + 1; x++) {
779 const ok = x <= span.last && cv.bgAt(x, y) === color && cv.chAt(x, y) === 0x20;
780 if (ok && start < 0) start = x;
781 if (!ok && start >= 0) {
782 if (x - start > best.len) best = { start, len: x - start };
783 start = -1;
784 }
785 }
786 if (best.len < 1) return;
787 const fg = luma(color) > 140 ? C.ink : C.white;
788 const s = String(text);
789 if (best.len >= s.length + 2) cv.text(best.start + 1, y, s, fg, color);
790 else if (best.len >= s.length && s.length > 0) cv.text(best.start, y, s, fg, color);
791 else if (glyph && best.len >= glyph.length) cv.text(best.start + (best.len >= glyph.length + 2 ? 1 : 0), y, glyph, fg, color);
792}
793
794const BRIGHTNESS = [0.55, 0.7, 0.85, 1];
795
796// Output tokens to one of four brightness steps (under 30, 300, 1500, more).
797export function brightnessStep(out) {
798 if (out < 30) return BRIGHTNESS[0];
799 if (out < 300) return BRIGHTNESS[1];
800 if (out < 1500) return BRIGHTNESS[2];
801 return BRIGHTNESS[3];
802}
803
804// ---------------------------------------------------------------- frame (pure)
805
806function laneTotals(lane) {
807 let req = 0;
808 let tools = 0;
809 let tok = 0;
810 let cost = 0;
811 for (const s of lane.segs) {
812 if (s.kind === "req") {
813 req++;
814 tok += s.tok;
815 cost += s.cost;
816 } else {
817 tools++;
818 }
819 }
820 return { req, tools, tok, cost };
821}
822
823function geometry(columns) {
824 const labelW = columns >= 110 ? 18 : columns >= 80 ? 15 : 12;
825 const totalsW = columns >= 100 ? 27 : columns >= 76 ? 18 : 0;
826 const timeW = Math.max(8, columns - labelW - totalsW - 2);
827 return { labelW, totalsW, timeW, x0: labelW + 1 };
828}
829
830export function buildFrame(r0, v, { columns, now }) {
831 const r = r0 && Array.isArray(r0.turns) ? r0 : EMPTY_REC;
832 const W = Math.max(24, Math.min(512, Math.floor(columns)));
833 const cv = new Canvas(W);
834 const g = geometry(W);
835 const shown = r.turns.slice(-clampTurns(v?.turns)).reverse();
836 const live = isLive(r, v, now);
837
838 // Title bar
839 const y0 = cv.row(C.head);
840 let x = cv.text(1, y0, "◉ ", C.coral, C.head);
841 x = cv.text(x, y0, "FLIGHT RECORDER", C.white, C.head);
842 if (W >= 60) cv.text(x + 2, y0, "inside the turn", C.faint, C.head);
843 const blinkOn = Math.floor(now / 500) % 2 === 0;
844 if (live) {
845 cv.text(W - 8, y0, "●", blinkOn ? C.rec : mix(C.rec, C.head, 0.6), C.head);
846 cv.text(W - 6, y0, "REC", C.text, C.head);
847 } else {
848 cv.text(W - 9, y0, "■", C.faint, C.head);
849 cv.text(W - 7, y0, "IDLE", C.dim, C.head);
850 }
851
852 if (shown.length === 0) {
853 cv.row(C.frame);
854 const y = cv.row(C.frame);
855 cv.text(2, y, "Waiting for the next turn. Send a prompt and watch it fly.", C.dim, C.frame, W - 3);
856 cv.row(C.frame);
857 }
858
859 for (const turn of shown) drawTurn(cv, turn, g, now, W);
860
861 drawLegend(cv, W);
862 return { columns: W, rows: cv.lines.length, words: cv.words() };
863}
864
865function drawTurn(cv, turn, g, now, W) {
866 const isLiveTurn =
867 turn.t1 === null ||
868 turn.lanes.some(
869 (l) => l.segs.some((s) => s.t1 === null) || (l.id !== "main" && l.done === null && now - lastActivity(l) < 60_000),
870 );
871 let end = turn.t1 ?? now;
872 for (const l of turn.lanes) {
873 if (l.done) end = Math.max(end, l.done);
874 for (const s of l.segs) end = Math.max(end, s.t1 ?? now);
875 }
876 if (isLiveTurn) end = Math.max(end, now);
877 const elapsed = Math.max(0, end - turn.t0);
878 const span = spanFor(elapsed, isLiveTurn);
879 const pxPerMs = (g.timeW * 8) / span;
880 const toP = (t) => (t - turn.t0) * pxPerMs;
881
882 // Turn totals
883 let req = 0;
884 let tools = 0;
885 let tok = 0;
886 let cost = 0;
887 for (const l of turn.lanes) {
888 const t = laneTotals(l);
889 req += t.req;
890 tools += t.tools;
891 tok += t.tok;
892 cost += t.cost;
893 }
894 const agents = turn.lanes.filter((l) => l.id !== "main").length;
895
896 // Summary row
897 cv.row(C.frame);
898 const ys = cv.row(C.frame);
899 let x = cv.text(1, ys, ` TURN ${turn.n} `, C.ink, isLiveTurn ? C.coral : C.dim);
900 x = cv.text(x + 2, ys, fmtSecs(elapsed), C.white, C.frame);
901 x = cv.text(x + 3, ys, fmtUsd(cost), C.now, C.frame);
902 const stats = `${req} req ${tools} tools ${fmtTok(tok)} tok${agents ? ` ${agents} agent${agents === 1 ? "" : "s"}` : ""}`;
903 x = cv.text(x + 3, ys, stats, C.dim, C.frame);
904 if (turn.prompt && W - x > 12) {
905 const room = W - x - 4;
906 const p = turn.prompt.length > room - 2 ? `${turn.prompt.slice(0, Math.max(0, room - 3))}...` : turn.prompt;
907 cv.text(x + 3, ys, `"${p}"`, C.faint, C.frame, room);
908 }
909
910 // Axis row
911 const ya = cv.row(C.frame);
912 cv.text(1, ya, "LANE", C.faint, C.frame);
913 if (g.totalsW >= 27) cv.text(W - g.totalsW, ya, "REQ TOOLS TOKENS COST", C.faint, C.frame);
914 else if (g.totalsW > 0) cv.text(W - g.totalsW, ya, "REQ TOOL COST", C.faint, C.frame);
915 const step = tickStep(span, g.timeW);
916 const ticks = [];
917 for (let s = 0; s * 1000 <= span + 1; s += step) ticks.push(s);
918 for (const s of ticks) {
919 const cx = g.x0 + Math.floor((s * 1000 * pxPerMs) / 8);
920 if (cx >= g.x0 + g.timeW) break;
921 const label = fmtTick(s);
922 if (cx + label.length + 1 > g.x0 + g.timeW + 1) continue;
923 cv.put(cx, ya, "▏", C.faint, C.frame);
924 cv.text(cx + 1, ya, label, C.dim, C.frame);
925 }
926
927 // Lanes
928 const nowX = isLiveTurn ? g.x0 + Math.min(g.timeW - 1, Math.floor(toP(now) / 8)) : -1;
929 turn.lanes.forEach((lane, li) => {
930 const bg = li % 2 === 0 ? C.laneA : C.laneB;
931 const { rows, depth } = packLane(lane.segs);
932 const ys0 = cv.lines.length;
933 for (let d = 0; d < depth; d++) cv.row(bg);
934
935 // grid lines in the empty timeline
936 for (const s of ticks) {
937 if (s === 0) continue;
938 const cx = g.x0 + Math.floor((s * 1000 * pxPerMs) / 8);
939 if (cx >= g.x0 + g.timeW) break;
940 for (let d = 0; d < depth; d++) cv.put(cx, ys0 + d, "▏", C.grid, bg);
941 }
942
943 // bars: requests first, tools on top; labels after every bar is down
944 const labels = [];
945 const order = lane.segs.map((s, i) => i);
946 order.sort((a, b) => (lane.segs[a].kind === lane.segs[b].kind ? 0 : lane.segs[a].kind === "req" ? -1 : 1));
947 for (const i of order) {
948 const s = lane.segs[i];
949 const y = ys0 + rows[i];
950 const t1 = s.t1 ?? now;
951 const running = s.t1 === null;
952 let color;
953 let label;
954 let minE;
955 if (s.kind === "req") {
956 const base = FAMILY[familyOf(s.name || lane.model)];
957 // Four fixed brightness steps: the terminal keeps one table of 1024
958 // color pairs per session, so every color here comes from a short list.
959 const f = running ? BRIGHTNESS[1] : brightnessStep(s.out);
960 color = s.err && !running ? mix(base, C.frame, 0.55) : mix(mix(base, C.frame, 0.3), base, f);
961 const fam = familyOf(s.name || lane.model);
962 label = running ? fam : s.out > 0 ? `${fam} ${fmtTok(s.out)}` : fam;
963 minE = 2;
964 } else {
965 const kind = toolKind(s.name);
966 color = s.err && !running ? TOOL.failed : TOOL[kind];
967 const linked = kind === "agent" ? agentLaneLabel(lane, s, t1, turn) : "";
968 label = linked || toolLabel(s.name);
969 minE = 4;
970 }
971 let p0 = toP(s.t0);
972 let p1 = toP(t1);
973 if (p1 - p0 < minE) p1 = p0 + minE;
974 const limit = g.timeW * 8;
975 if (p0 >= limit) continue;
976 p1 = Math.min(p1, limit);
977 // a 1/8 cell gap at the end keeps back-to-back bars apart
978 const spanCells = bar(cv, y, g.x0, p0, p1 - p0 >= 6 && !running ? p1 - 1 : p1, color);
979 if (s.kind === "tool") {
980 const room = spanCells.first < 0 ? 0 : spanCells.last - spanCells.first + 1;
981 if (room >= 1) labels.push({ y, spanCells, text: label, glyph: toolGlyph(s.name), color });
982 else {
983 // a sliver: put the glyph in the cell after it when that cell is empty
984 const gx = g.x0 + Math.floor(p1 / 8);
985 if (gx < g.x0 + g.timeW && cv.bgAt(gx, y) === bg && cv.chAt(gx, y) !== 0x2588) {
986 const ch = cv.chAt(gx, y);
987 if (ch === 0x20 || ch === 0x258f) cv.put(gx, y, toolGlyph(s.name), color, bg);
988 }
989 }
990 } else {
991 labels.push({ y, spanCells, text: label, glyph: familyOf(s.name || lane.model), color });
992 }
993 if (running) {
994 // a bright leading edge on what is still running
995 const ex = g.x0 + Math.min(g.timeW - 1, Math.floor(Math.max(p0, p1 - 1) / 8));
996 const pulse = Math.floor(now / 250) % 2 === 0;
997 cv.put(ex, y, "▐", pulse ? C.white : mix(color, C.white, 0.5), cv.bgAt(ex, y));
998 }
999 }
1000
1001 for (const l of labels) labelIn(cv, l.y, l.spanCells, l.text, l.glyph, l.color);
1002
1003 // done cap
1004 if (lane.done && lane.id !== "main") {
1005 const cx = g.x0 + Math.floor(toP(lane.done) / 8) + 1;
1006 if (cx < g.x0 + g.timeW && cv.bgAt(cx, ys0) === bg) cv.put(cx, ys0, "✓", C.done, bg);
1007 }
1008
1009 // now edge
1010 if (nowX >= g.x0) {
1011 for (let d = 0; d < depth; d++) {
1012 if (cv.bgAt(nowX, ys0 + d) === bg && (cv.chAt(nowX, ys0 + d) === 0x20 || cv.chAt(nowX, ys0 + d) === 0x258f)) {
1013 cv.put(nowX, ys0 + d, "▏", C.now, bg);
1014 }
1015 }
1016 }
1017
1018 // label column
1019 const nested = lane.parent && turn.lanes.some((l) => l.id === lane.parent);
1020 const fam = familyOf(lane.model);
1021 const dot = FAMILY[fam];
1022 let lx = 1;
1023 if (nested) lx = cv.text(lx, ys0, "└", C.faint, bg);
1024 lx = cv.text(lx, ys0, "●", dot, bg);
1025 const status = laneStatus(lane, turn, now, isLiveTurn);
1026 const room = g.labelW - (lx - 1) - 3;
1027 const name = lane.id === "main" ? "main" : fitLabel(lane, room);
1028 cv.text(lx + 1, ys0, name, lane.id === "main" ? C.white : C.text, bg, room);
1029 cv.text(g.labelW - 1, ys0, status.ch, status.color, bg);
1030 if (depth >= 2 && lane.id !== "main" && lane.type) {
1031 // second row: the model under the name
1032 cv.text(lx + 1, ys0 + 1, fam, C.dim, bg, room);
1033 } else if (depth >= 2 && lane.id === "main") {
1034 cv.text(lx + 1, ys0 + 1, fam, C.dim, bg, room);
1035 }
1036
1037 // totals
1038 if (g.totalsW > 0) {
1039 const t = laneTotals(lane);
1040 const tx = W - g.totalsW;
1041 if (g.totalsW >= 27) {
1042 cv.text(tx, ys0, String(t.req).padStart(3), C.text, bg);
1043 cv.text(tx + 4, ys0, String(t.tools).padStart(5), C.text, bg);
1044 cv.text(tx + 11, ys0, fmtTok(t.tok).padStart(6), C.dim, bg);
1045 cv.text(tx + 18, ys0, fmtUsd(t.cost).padStart(8), C.now, bg);
1046 } else {
1047 cv.text(tx, ys0, String(t.req).padStart(3), C.text, bg);
1048 cv.text(tx + 4, ys0, String(t.tools).padStart(4), C.text, bg);
1049 cv.text(tx + 9, ys0, fmtUsd(t.cost).padStart(7), C.now, bg);
1050 }
1051 }
1052 });
1053}
1054
1055// "general-purpose 3f9c" in a narrow column keeps the id and trims the type.
1056function fitLabel(lane, room) {
1057 if (lane.label.length <= room) return lane.label;
1058 const id = shortId(lane.id);
1059 const type = lane.label.slice(0, Math.max(0, lane.label.length - id.length - 1));
1060 return `${type.slice(0, Math.max(1, room - id.length - 1))} ${id}`;
1061}
1062
1063function agentLaneLabel(lane, seg, t1, turn) {
1064 const id = seg.id.startsWith("t:") ? seg.id.slice(2) : "";
1065 if (!id) return "";
1066 const target = turn.lanes.find((l) => l.toolUseId === id);
1067 return target ? `> ${target.label}` : "";
1068}
1069
1070function laneStatus(lane, turn, now, isLiveTurn) {
1071 const pulse = { ch: "●", color: Math.floor(now / 400) % 2 === 0 ? C.now : mix(C.now, C.frame, 0.55) };
1072 if (lane.id === "main") {
1073 if (turn.t1 === null || lane.segs.some((s) => s.t1 === null)) return pulse;
1074 if (isLiveTurn) return { ch: "○", color: C.dim }; // waiting on subagents
1075 return { ch: "✓", color: C.done };
1076 }
1077 if (lane.done) return { ch: "✓", color: C.done };
1078 if (isLiveTurn) return pulse;
1079 return { ch: "·", color: C.faint };
1080}
1081
1082function drawLegend(cv, W) {
1083 cv.row(C.frame);
1084 const y = cv.row(C.head);
1085 const items = [
1086 ["MODEL", null],
1087 ["fable", FAMILY.fable],
1088 ["opus", FAMILY.opus],
1089 ["sonnet", FAMILY.sonnet],
1090 ["haiku", FAMILY.haiku],
1091 ["TOOL", null],
1092 ["read", TOOL.read],
1093 ["edit", TOOL.edit],
1094 ["bash", TOOL.bash],
1095 ["agent", TOOL.agent],
1096 ["mcp", TOOL.mcp],
1097 ["other", TOOL.other],
1098 ["failed", TOOL.failed],
1099 ];
1100 let x = 1;
1101 for (const [name, color] of items) {
1102 if (color === null) {
1103 if (x > 1) x += 1;
1104 if (x + name.length >= W) break;
1105 x = cv.text(x, y, name, C.faint, C.head) + 1;
1106 continue;
1107 }
1108 if (x + name.length + 3 >= W) break;
1109 cv.put(x, y, "█", color, C.head);
1110 x = cv.text(x + 2, y, name, C.dim, C.head) + 2;
1111 }
1112 const y2 = cv.row(C.head);
1113 const hint = "brighter request = more output ▏now ✓ done /timeline turns <n> /timeline clear";
1114 cv.text(1, y2, hint, C.faint, C.head, W - 2);
1115 // color the now and done glyphs in the hint
1116 const ni = hint.indexOf("▏now");
1117 if (ni >= 0 && ni + 1 < W - 1) cv.put(1 + ni, y2, "▏", C.now, C.head);
1118 const di = hint.indexOf("✓ done");
1119 if (di >= 0 && di + 1 < W - 1) cv.put(1 + di, y2, "✓", C.done, C.head);
1120}
1121
1122// ---------------------------------------------------------------- non-terminal fallback
1123
1124function fallbackTree(Box, Text, r, v, now) {
1125 const shown = (r.turns ?? []).slice(-clampTurns(v?.turns)).reverse();
1126 const rows = [Text({ bold: true, children: "Flight Recorder" })];
1127 if (shown.length === 0) rows.push(Text({ dimColor: true, children: "Waiting for the next turn." }));
1128 for (const turn of shown) {
1129 const end = turn.t1 ?? now;
1130 let cost = 0;
1131 for (const l of turn.lanes) cost += laneTotals(l).cost;
1132 rows.push(
1133 Text({
1134 bold: true,
1135 children: `Turn ${turn.n} ${fmtSecs(end - turn.t0)} ${fmtUsd(cost)}${turn.t1 === null ? " running" : ""}`,
1136 }),
1137 );
1138 for (const lane of turn.lanes) {
1139 const t = laneTotals(lane);
1140 const name = lane.id === "main" ? "main" : lane.label;
1141 const state = lane.done || (lane.id === "main" && turn.t1 !== null) ? "done" : "running";
1142 rows.push(
1143 Text({
1144 wrap: "truncate",
1145 children: ` ${name} ${familyOf(lane.model)} ${t.req} req ${t.tools} tools ${fmtTok(t.tok)} tok ${fmtUsd(t.cost)} ${state}`,
1146 }),
1147 );
1148 }
1149 }
1150 return Box({ flexDirection: "column", children: rows });
1151}
1152
1153// ---------------------------------------------------------------- base64
1154
1155const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1156
1157export function encodeCells(words) {
1158 const bytes = new Uint8Array(words.buffer, words.byteOffset, words.byteLength);
1159 if (typeof bytes.toBase64 === "function") return bytes.toBase64();
1160 const parts = [];
1161 const n = bytes.length;
1162 let chunk = "";
1163 let i = 0;
1164 for (; i + 2 < n; i += 3) {
1165 const v = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
1166 chunk += B64[(v >> 18) & 63] + B64[(v >> 12) & 63] + B64[(v >> 6) & 63] + B64[v & 63];
1167 if (chunk.length > 8192) {
1168 parts.push(chunk);
1169 chunk = "";
1170 }
1171 }
1172 if (i < n) {
1173 const b1 = i + 1 < n ? bytes[i + 1] : 0;
1174 const v = (bytes[i] << 16) | (b1 << 8);
1175 chunk += B64[(v >> 18) & 63] + B64[(v >> 12) & 63] + (i + 1 < n ? B64[(v >> 6) & 63] : "=") + "=";
1176 }
1177 parts.push(chunk);
1178 return parts.join("");
1179}
1180types/index.d.ts 45 lines1export type FlightRecorderSeg = {
2 id: string;
3 kind: "req" | "tool";
4 name: string;
5 t0: number;
6 t1: number | null;
7 out: number;
8 tok: number;
9 cost: number;
10 err: boolean;
11};
12
13export type FlightRecorderLane = {
14 id: string;
15 label: string;
16 type: string;
17 model: string;
18 parent: string;
19 toolUseId: string;
20 t0: number;
21 done: number | null;
22 segs: FlightRecorderSeg[];
23};
24
25export type FlightRecorderTurn = {
26 n: number;
27 id: string;
28 prompt: string;
29 t0: number;
30 t1: number | null;
31 lanes: FlightRecorderLane[];
32};
33
34export type FlightRecorderRec = { count: number; turns: FlightRecorderTurn[] };
35export type FlightRecorderView = { turns: number };
36
37declare module "claude-code" {
38 interface PluginState {
39 "flight-recorder": {
40 rec: FlightRecorderRec;
41 view: FlightRecorderView;
42 };
43 }
44}
45