SLOPSHOPPER

flight-recorder

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

newpaneguardcommandprompttimer
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 │ ┃ ⏺ Read(src/auth.ts) │ ┃ TURN 1 0.4s $0 0 req 9 tools 0 tok ⎿ Read 6 lines │ ┃ LANE ▏0s ⏺ Update(src/auth.ts) │ ┃ ● main ✓ ▉▌▌▎▉▉▋▌▎▎▉▋▋ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /timeline │ ⎿ flight-recorder: Flight Recorder open. Send a prompt and watch t │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Flight Recorder
◉ FLIGHT RECORDER ■ IDLE TURN 1 0.4s $0 0 req 9 tools 0 tok LANE ▏0s ● main ✓ ▉▌▌▎▉▉▋▌▎▎▉▋▋
README

Flight Recorder

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-recorder
Source 2 files
hooks/flight-recorder.mjs 1180 lines
1// 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}
1180
types/index.d.ts 45 lines
1export 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