SLOPSHOPPER

trace-waterfall

A live waterfall of each turn in a side pane: every model call, tool call and subagent as a bar on one time axis, with duration and estimated cost

newpaneguardcommandprompttool
v0.1.0MITupdated 2026-10-26vdvoorder/claude-code-trace-waterfall
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · trace-waterfall
│ ┃ Trace ✕ › fix the failing auth test and add an audit log call │ ┃ Request 1 of 1 running[ ◀ Earlier ] [ Later │ ┃ fix the failing auth test and add an audit ⏺ Read(src/auth.ts) │ ┃ log call ⎿ Read 6 lines │ ┃ Whole request Duration 280ms Cost no ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 0s ⏺ Bash(bun test) │ ┃ time cost ⎿ 3 pass, 1 fail │ ┃ request:turn-1 │ ┃ turn-1 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ toolu_01 │ ┃ toolu_02 ✻ Worked for 42s · done 4:20 PM │ ┃ toolu_03 │ ┃ toolu_04 › /trace-waterfall │ ┃ toolu_05 ⎿ trace-waterfall: Trace pane opened. │ ┃ toolu_06 │ ┃ toolu_07 │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Trace
Request 1 of 1 running [ ◀ Earlier ] [ Later ▶ ] fix the failing auth test and add an audit log call Whole request Duration 280ms Cost none Tokens in 0s time cost request:turn-1 turn-1 toolu_01 toolu_02 toolu_03 toolu_04 toolu_05 toolu_06 toolu_07
README

<h1 align="center">Claude Code Trace Waterfall</h1>

A live waterfall of each Claude Code request: every model call, tool call and subagent on one time axis, with duration and estimated cost

<img src="https://img.shields.io/badge/Claude_Code-%E2%89%A5_2.1.306-D97757?logo=claude&logoColor=fff" alt="Claude Code 2.1.306 or newer"> <img src="https://img.shields.io/badge/version-0.1.0-blue" alt="Version"> <img src="https://img.shields.io/badge/type-mod-6f42c1" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">

<img src="media/trace-waterfall.png" alt="The Trace pane beside the chat: a request with its model calls, tool calls and a subagent as bars on one time axis" width="900">

[!NOTE] trace-waterfall is a Claude Code mod and needs Claude Code 2.1.306+. The Trace pane docks beside the chat in the fullscreen layout (/tui fullscreen) from 110 columns, and opens above the prompt otherwise. It opens by itself when a session starts in a terminal at least 144 columns wide; /trace-waterfall opens it at any width. Tested in the terminal on Windows and Linux.


Installation

At the prompt of a Claude Code session in a terminal:

/plugin install trace-waterfall --marketplace vdvoorder/claude-code-trace-waterfall

Answer y to add the marketplace, then pick a scope: user installs the mod for every project.

From a shell:

claude plugin install trace-waterfall --marketplace vdvoorder/claude-code-trace-waterfall

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with vdvoorder/claude-code-trace-waterfall, then Add plugin.

To update, run claude plugin update trace-waterfall@claude-code-trace-waterfall, then /reload-plugins in a running session.

Features

  • One row per span: the request, each turn, each model call, each tool call and each subagent, nested under what started it and drawn as a bar on one time axis
  • Live: a running call grows every second, and a line marks the present moment
  • Subagents nest under the Agent call that started them, with their own model and tool calls, also when a subagent starts another
  • The header sums the request: duration, estimated cost, tokens in and out, model calls, tool calls and errors
  • Click a row to sum only that span and what it started; click it again for the whole request
  • The slowest call's duration is in bold, and failed calls are red
  • ◀ Earlier and Later ▶ page through the last 20 requests; a turn that answers a subagent's message joins the request that started the subagent
  • Ask Claude to show the trace, and it reopens the pane with the mod's own open_pane tool

Settings

Change the setting with /plugin configure trace-waterfall@claude-code-trace-waterfall.

Open at start:  on | off

Cost

The cost is an estimate at Anthropic's API list prices per million tokens, from the tokens each model call reports:

  • Cache writes count at the 5-minute cache rate. A request that writes to the 1-hour cache costs more than the estimate.
  • Claude Haiku 5.5 counts at its higher prices when a model call's prompt is over 100,000 tokens.
  • A model the price table does not know shows no cost.
  • On a Claude subscription the estimate says what the same tokens would cost on the API, not what the subscription charges.

Considerations

  • /clear empties the pane
  • The pane keeps the last 20 requests of the session
  • The Desktop app and macOS are covered by the tests and CI, but not yet tried by hand

Contributing

Turn on the pre-commit hook once per clone; it runs claude plugin validate and the tests before each commit that touches the mod:

git config core.hooksPath .githooks

The same checks run on Linux, macOS and Windows in CI on every push that touches the mod. Agent rules for this repository are in AGENTS.md.

License

MIT. The layout follows a trace waterfall component that flohoeller posted on X.

Source 4 files
hooks/register.tsx 581 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnStepResult } from 'claude-code'
3
4import type { AgentLink, Theme, Trace } from '../types'
5import { chartOf, INK_COLOR, NOW_COLOR, PALETTES } from './draw'
6import type { Palette, Run } from './draw'
7import {
8  AGENT_PREFIX,
9  addSpan,
10  changeSpan,
11  closeLoop,
12  closeSpan,
13  completeMain,
14  continueRequest,
15  costOf,
16  describeTool,
17  figuresOf,
18  findSpan,
19  formatCost,
20  formatDuration,
21  formatTokens,
22  isLive,
23  latestTrace,
24  modelLabel,
25  placeOf,
26  REQUEST_PREFIX,
27  startRequest,
28  subtreeOf,
29} from './trace'
30import type { Figures, NewSpan } from './trace'
31
32const PLUGIN = 'trace-waterfall'
33const PANE = 'trace'
34const COMMAND = 'trace-waterfall'
35const TITLE = 'Trace'
36const OPEN_TOOL = 'open_pane'
37const AGENT_TOOLS = /^(Agent|Task|Workflow)$/
38const OTHER_TOOLS = /^(?!(Agent|Task|Workflow)$)/
39
40const traces = atom({ plugin: 'trace-waterfall', key: 'traces' } as const, [])
41const agents = atom({ plugin: 'trace-waterfall', key: 'agents' } as const, {})
42const main = atom({ plugin: 'trace-waterfall', key: 'main' } as const, null)
43const view = atom({ plugin: 'trace-waterfall', key: 'view' } as const, null)
44const selected = atom({ plugin: 'trace-waterfall', key: 'selected' } as const, null)
45const now = atom({ plugin: 'trace-waterfall', key: 'now' } as const, 0)
46const theme = atom({ plugin: 'trace-waterfall', key: 'theme' } as const, 'dark')
47
48const REQUEST_ORIGINS = new Set(['composer', 'bridge', 'sdk', 'scheduled-trigger'])
49
50type Arrival = { isRequest: boolean; text: string; detail: string; agentId: string | null }
51
52let queue: Promise<unknown> = Promise.resolve()
53let upcoming: Arrival | null = null
54const reported = new Set<string>()
55
56function arrivalOf(kind: string | null, text: string): Arrival {
57  const agentId = /<agent-message from="([^"]+)"/.exec(text)?.[1] ?? null
58
59  if (kind === null ? !text.trimStart().startsWith('<') : REQUEST_ORIGINS.has(kind)) {
60    return { isRequest: true, text, detail: '', agentId: null }
61  }
62
63  if (agentId !== null || kind === 'peer' || kind === 'peer-send-message' || kind === 'coordinator') {
64    return { isRequest: false, text, detail: 'after an agent message', agentId }
65  }
66
67  if (kind === 'task-notification') {
68    return { isRequest: false, text, detail: 'after a background task', agentId }
69  }
70
71  return { isRequest: false, text, detail: 'without a typed prompt', agentId }
72}
73
74function reasonOf(error: unknown): string {
75  return error instanceof Error ? error.message : String(error)
76}
77
78function inOrder($: EngineInterface, label: string, job: () => Promise<unknown>): Promise<void> {
79  const run = queue.then(job).then(
80    () => undefined,
81    error => {
82      if (!reported.has(label)) {
83        reported.add(label)
84        $.ui.log(`${PLUGIN}: ${label} failed: ${reasonOf(error).replace(/\s+/g, ' ').slice(0, 300)}`)
85      }
86    },
87  )
88  queue = run
89
90  return run
91}
92
93function stamp($: EngineInterface): Promise<number> {
94  return $.clock.now().catch(() => Date.now())
95}
96
97function toTheme(value: unknown): Theme {
98  return String(value).startsWith('light') ? 'light' : 'dark'
99}
100
101async function refreshTheme($: EngineInterface): Promise<void> {
102  const row = (await $.config.list()).find(one => one.key === 'theme')
103  const wanted = toTheme(row?.value)
104
105  if ((await read($, theme)) !== wanted) {
106    await update($, theme, () => wanted)
107  }
108}
109
110async function startMainTurn($: EngineInterface, turnId: string, text: string, arrival: Arrival, at: number): Promise<void> {
111  const held = await read($, traces)
112  const links = await read($, agents)
113  const latest = latestTrace(held)
114
115  if (arrival.isRequest || latest === undefined) {
116    await update($, traces, current => startRequest(current, turnId, arrival.text || text, at))
117    await update($, main, () => ({ trace: REQUEST_PREFIX + turnId, span: turnId }))
118
119    return
120  }
121
122  const owner = arrival.agentId === null ? undefined : links[arrival.agentId]?.trace
123  const traceId = owner !== undefined && held.some(trace => trace.id === owner) ? owner : latest.id
124
125  await update($, traces, current => continueRequest(current, traceId, turnId, arrival.detail, at))
126  await update($, main, () => ({ trace: traceId, span: turnId }))
127}
128
129async function openSpan($: EngineInterface, loop: string | null, fields: NewSpan): Promise<void> {
130  const links = await read($, agents)
131  const current = await read($, main)
132
133  await update($, traces, held => {
134    const place = placeOf(held, links, current, loop)
135
136    return place === undefined ? held : addSpan(held, place, fields, loop)
137  })
138}
139
140async function endSpan($: EngineInterface, id: string, at: number, isError: boolean): Promise<void> {
141  await update($, traces, held => changeSpan(held, id, span => closeSpan(span, at, isError)))
142}
143
144async function endStep($: EngineInterface, id: string, at: number, result: TurnStepResult | undefined): Promise<void> {
145  await update($, traces, held =>
146    changeSpan(held, id, span => {
147      const closed = closeSpan(span, at, false)
148      const usage = result?.usage
149
150      if (usage === null || usage === undefined) {
151        return closed
152      }
153
154      const counts = {
155        input: usage.input_tokens,
156        output: usage.output_tokens,
157        cacheRead: usage.cache_read_input_tokens,
158        cacheWrite: usage.cache_creation_input_tokens,
159      }
160
161      return {
162        ...closed,
163        model: usage.model,
164        details: [modelLabel(usage.model)],
165        usage: counts,
166        cost: costOf(usage.model, counts),
167      }
168    }),
169  )
170}
171
172function linkedSpans(links: Record<string, AgentLink>): Set<string> {
173  return new Set(Object.values(links).map(link => link.span))
174}
175
176async function startAgentTurn($: EngineInterface, agentId: string, prompt: string, at: number): Promise<void> {
177  const links = await read($, agents)
178  let link = links[agentId]
179
180  if (link === undefined) {
181    const held = await read($, traces)
182    const taken = linkedSpans(links)
183
184    for (let index = held.length - 1; index >= 0 && link === undefined; index -= 1) {
185      const trace = held[index]
186      const match = trace?.spans.find(
187        span => span.kind === 'agent' && span.prompt !== null && span.prompt === prompt && !taken.has(span.id),
188      )
189
190      if (trace !== undefined && match !== undefined) {
191        link = { trace: trace.id, span: match.id }
192      }
193    }
194  }
195
196  if (link === undefined) {
197    const place = placeOf(await read($, traces), links, await read($, main), null)
198
199    if (place === undefined) {
200      return
201    }
202
203    const id = AGENT_PREFIX + agentId
204    link = { trace: place.trace, span: id }
205    await update($, traces, current =>
206      addSpan(current, place, { id, kind: 'agent', name: 'Agent', details: [], start: at, agentId, pending: 0 }, null),
207    )
208  }
209
210  const found = link
211
212  await update($, agents, current => ({ ...current, [agentId]: found }))
213  await update($, traces, held =>
214    changeSpan(held, found.span, span => ({ ...span, agentId, pending: span.pending + 1, end: Math.max(span.end, at) })),
215  )
216}
217
218async function linkAgent(
219  $: EngineInterface,
220  agentId: string,
221  toolUseId: string,
222  description: string,
223  subagentType: string,
224  model: string,
225  at: number,
226): Promise<void> {
227  if ((await read($, agents))[agentId] !== undefined) {
228    return
229  }
230
231  const held = await read($, traces)
232  const found = findSpan(held, toolUseId)
233
234  if (found === undefined) {
235    return
236  }
237
238  if (found.span.kind === 'agent' && found.span.agentId === null) {
239    await update($, agents, current => ({ ...current, [agentId]: { trace: found.trace.id, span: found.span.id } }))
240    await update($, traces, current => changeSpan(current, found.span.id, span => ({ ...span, agentId, model })))
241
242    return
243  }
244
245  const id = AGENT_PREFIX + agentId
246  const details = [description, subagentType].filter(text => text !== '')
247
248  await update($, traces, current =>
249    addSpan(current, { trace: found.trace.id, span: found.span.id }, { id, kind: 'agent', name: 'Agent', details, start: at, model, agentId, pending: 0 }, null),
250  )
251  await update($, agents, current => ({ ...current, [agentId]: { trace: found.trace.id, span: id } }))
252}
253
254async function completeAgent($: EngineInterface, agentId: string, at: number): Promise<void> {
255  const link = (await read($, agents))[agentId]
256
257  if (link === undefined) {
258    return
259  }
260
261  await update($, traces, held =>
262    changeSpan(
263      held.map(trace => (trace.id === link.trace ? closeLoop(trace, agentId, at) : trace)),
264      link.span,
265      span => closeSpan(span, at, false),
266    ),
267  )
268}
269
270async function tick($: EngineInterface): Promise<void> {
271  if (!(await read($, traces)).some(isLive)) {
272    return
273  }
274
275  const at = await stamp($)
276  await update($, now, () => at)
277}
278
279function followedIndex(list: Trace[], current: AgentLink | null): number {
280  const found = current === null ? -1 : list.findIndex(trace => trace.id === current.trace)
281
282  return found < 0 ? list.length - 1 : found
283}
284
285async function moveView($: EngineInterface, step: number): Promise<void> {
286  const list = await read($, traces)
287  const followed = followedIndex(list, await read($, main))
288
289  await update($, view, current => {
290    const found = current === null ? followed : list.findIndex(trace => trace.id === current)
291    const target = Math.min(list.length - 1, Math.max(0, (found < 0 ? followed : found) + step))
292
293    return target === followed ? null : (list[target]?.id ?? null)
294  })
295  await update($, selected, () => null)
296}
297
298function promptLine(trace: Trace): string {
299  const line = trace.prompt
300    .split(/\r?\n/)
301    .map(one => one.trim())
302    .find(one => one !== '')
303
304  return line === undefined || line.startsWith('<') ? 'A request without a typed prompt' : line
305}
306
307type Item = { label: string; value: string; width: number; swatch?: string }
308
309function itemsOf(figures: Figures, palette: Palette): Item[] {
310  return [
311    { label: 'Duration', value: formatDuration(figures.duration), width: 7 },
312    { label: 'Cost', value: formatCost(figures.cost) || 'none', width: 6 },
313    { label: 'Tokens in', value: formatTokens(figures.tokensIn), width: 6 },
314    { label: 'out', value: formatTokens(figures.tokensOut), width: 6 },
315    { label: 'Model calls', value: String(figures.modelCalls), width: 4, swatch: palette.model },
316    { label: 'Tool calls', value: String(figures.toolCalls), width: 4, swatch: palette.tool },
317    { label: 'Errors', value: String(figures.errors), width: 3, swatch: palette.error },
318  ]
319}
320
321export const register: Register = (on, options) => {
322  const opensAtStart = options?.start !== 'off'
323
324  on('session.start', async ($, e, next) => {
325    await $.command.register({
326      name: COMMAND,
327      description: 'Show where the time of each request goes, as a waterfall in a side pane',
328    })
329    await $.tool.register({
330      name: OPEN_TOOL,
331      description:
332        'Opens the Trace pane beside the chat: a live waterfall of where the time of each request goes. Call it when the person asks to see, show or reopen the trace, the waterfall or the Gantt pane.',
333    })
334    await refreshTheme($).catch(() => undefined)
335    $.clock.every(1000, () => {
336      void tick($).catch(() => undefined)
337    })
338    if (opensAtStart) void $.ui.open({ id: PANE, title: TITLE })
339
340    return next(e)
341  })
342
343  on('command.run', { command: COMMAND }, async $ => {
344    const opened = await $.ui.open({ id: PANE, title: TITLE })
345
346    return { text: opened.isPlaced ? 'Trace pane opened.' : `The trace pane waits: ${opened.reason}` }
347  })
348
349  on('config.set', { key: 'theme' }, async ($, e, next) => {
350    const changed = await next(e)
351
352    if (changed.deny === undefined) {
353      void inOrder($, 'storing the theme', () => update($, theme, () => toTheme(changed.value)))
354    }
355
356    return changed
357  })
358
359  on('session.end', async ($, e, next) => {
360    if (e.reason === 'clear') {
361      await inOrder($, 'clearing the traces', async () => {
362        await update($, traces, () => [])
363        await update($, agents, () => ({}))
364        await update($, main, () => null)
365        await update($, view, () => null)
366        await update($, selected, () => null)
367      })
368    }
369
370    return next(e)
371  })
372
373  on('prompt.submit', ($, e, next) => {
374    if (e.turnId === undefined) {
375      const typed = e.command === undefined ? e.text : `/${e.command.name} ${e.command.args}`.trim()
376      upcoming = arrivalOf(e.origin.kind, typed)
377    }
378
379    return next(e)
380  })
381
382  on('turn.start', async ($, e, next) => {
383    const at = await stamp($)
384    const agentId = e.agentId
385
386    if (agentId === undefined) {
387      const arrival = upcoming ?? arrivalOf(null, e.text)
388      upcoming = null
389      void inOrder($, 'starting a turn', () => startMainTurn($, e.turnId, e.text, arrival, at))
390      void inOrder($, 'reading the theme', () => refreshTheme($))
391    } else {
392      void inOrder($, 'starting an agent turn', () => startAgentTurn($, agentId, e.text, at))
393    }
394
395    return next(e)
396  })
397
398  on('turn.step', async function* ($, e, next) {
399    const at = await stamp($)
400    const id = `step:${e.turnId}:${e.index}`
401    const loop = e.agentId ?? null
402    let result: TurnStepResult | undefined
403
404    void inOrder($, 'starting a model call', () =>
405      openSpan($, loop, { id, kind: 'model', name: 'Model call', details: [modelLabel(e.model)], start: at, model: e.model }),
406    )
407
408    try {
409      result = yield* next(e)
410
411      return result
412    } finally {
413      const end = await stamp($)
414      void inOrder($, 'ending a model call', () => endStep($, id, end, result))
415    }
416  })
417
418  on('turn.complete', async ($, e, next) => {
419    const at = await stamp($)
420    const agentId = e.agentId
421
422    if (agentId === undefined) {
423      const isError = e.reason === 'error' || e.reason === 'refusal'
424      void inOrder($, 'ending a turn', () => update($, traces, held => completeMain(held, e.turnId, at, isError)))
425    } else {
426      void inOrder($, 'ending an agent turn', () => completeAgent($, agentId, at))
427    }
428
429    return next(e)
430  })
431
432  on('tool.call', { tool: /^mcp__trace-waterfall__open_pane$/ }, async $ => {
433    const opened = await $.ui.open({ id: PANE, title: TITLE })
434
435    return { result: opened.isPlaced ? 'The Trace pane is open.' : `The Trace pane waits: ${opened.reason}` }
436  })
437
438  on('tool.call', { tool: OTHER_TOOLS }, async ($, e, next) => {
439    const at = await stamp($)
440    const id = e.tool_use_id ?? `tool:${String(e.tool)}:${at}`
441    const { name, details } = describeTool(String(e.tool), e as unknown as Record<string, unknown>)
442    let isError = true
443
444    void inOrder($, 'starting a tool call', () => openSpan($, e.agentId ?? null, { id, kind: 'tool', name, details, start: at }))
445
446    try {
447      const ran = await next(e)
448      isError = ran.deny !== undefined || ran.isError === true
449
450      return ran
451    } finally {
452      const end = await stamp($)
453      void inOrder($, 'ending a tool call', () => endSpan($, id, end, isError))
454    }
455  })
456
457  on('tool.call', { tool: AGENT_TOOLS }, async ($, e, next) => {
458    const at = await stamp($)
459    const input = e as unknown as Record<string, unknown>
460    const id = e.tool_use_id ?? `tool:${String(e.tool)}:${at}`
461    const { name, details } = describeTool(String(e.tool), input)
462    const prompt = typeof input.prompt === 'string' ? input.prompt : null
463    const kind = e.tool === 'Workflow' ? 'tool' : 'agent'
464    let isError = true
465
466    void inOrder($, 'starting an agent call', () => openSpan($, e.agentId ?? null, { id, kind, name, details, start: at, prompt }))
467
468    try {
469      const ran = await next(e)
470      isError = ran.deny !== undefined || ran.isError === true
471
472      return ran
473    } finally {
474      const end = await stamp($)
475      void inOrder($, 'ending an agent call', () => endSpan($, id, end, isError))
476    }
477  })
478
479  on('agent.spawn', async ($, e, next) => {
480    const spawned = await next(e)
481
482    if (spawned.deny === undefined && spawned.agentId !== undefined) {
483      const agentId = spawned.agentId
484      const at = await stamp($)
485
486      void inOrder($, 'linking an agent', () =>
487        linkAgent($, agentId, e.tool_use_id, e.description, e.subagentType, spawned.model, at),
488      )
489    }
490
491    return spawned
492  })
493
494  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
495    const { Box, Button, Text } = $.ui.resolve(e)
496
497    const textOf = (run: Run) => (
498      <Text color={run.color} inverse={run.inverse} dimColor={run.dim} bold={run.bold}>
499        {run.text}
500      </Text>
501    )
502
503    try {
504      const held = await read($, traces)
505
506      if (held.length === 0) {
507        return <Text dimColor>No request has run yet in this session.</Text>
508      }
509
510      const viewed = await read($, view)
511      const found = viewed === null ? -1 : held.findIndex(trace => trace.id === viewed)
512      const index = found < 0 ? followedIndex(held, await read($, main)) : found
513      const trace = held[index]
514
515      if (trace === undefined) {
516        return <Text dimColor>No request has run yet in this session.</Text>
517      }
518
519      const live = isLive(trace)
520      const at = live ? await read($, now) : 0
521      const palette = PALETTES[await read($, theme)]
522      const chosen = await read($, selected)
523      const selectedId = chosen !== null && trace.spans.some(span => span.id === chosen) ? chosen : null
524      const scope = selectedId === null ? trace.spans : subtreeOf(trace, selectedId)
525      const figures = figuresOf(scope, at)
526      const chart = chartOf(trace, at, Math.max(24, e.props.bodyColumns), palette, selectedId)
527      const isFirst = index === 0
528      const isLast = index === held.length - 1
529
530      return (
531        <Box flexDirection="column">
532          <Box flexDirection="row" justifyContent="space-between">
533            <Text bold>
534              {`Request ${index + 1} of ${held.length}`}
535              {live ? <Text color={NOW_COLOR}> running</Text> : ''}
536            </Text>
537            <Box flexDirection="row" columnGap={1}>
538              <Button
539                key="earlier"
540                label="◀ Earlier"
541                dimColor={isFirst || undefined}
542                onPress={() => (isFirst ? undefined : moveView($, -1))}
543              />
544              <Button
545                key="later"
546                label="Later ▶"
547                dimColor={isLast || undefined}
548                onPress={() => (isLast ? undefined : moveView($, 1))}
549              />
550            </Box>
551          </Box>
552          <Text dimColor>{promptLine(trace)}</Text>
553          <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
554            <Text bold>{(selectedId === null ? 'Whole request' : 'Selected').padEnd(13)}</Text>
555            {itemsOf(figures, palette).map(item => (
556              <Text>
557                {item.swatch === undefined ? '' : <Text color={item.swatch}>■ </Text>}
558                <Text dimColor>{`${item.label} `}</Text>
559                {item.value.padStart(item.width)}
560              </Text>
561            ))}
562          </Box>
563          <Text> </Text>
564          <Text>{chart.axis.map(textOf)}</Text>
565          {chart.rows.map(row => (
566            <Button
567              key={row.id}
568              plain
569              onPress={() => update($, selected, current => (current === row.id ? null : row.id))}
570            >
571              {row.runs.map(textOf)}
572            </Button>
573          ))}
574        </Box>
575      )
576    } catch (error) {
577      return <Text color={INK_COLOR}>{`The trace could not be drawn: ${reasonOf(error).replace(/\s+/g, ' ').slice(0, 300)}`}</Text>
578    }
579  })
580}
581
hooks/draw.ts 227 lines
1import type { Span, Theme, Trace } from '../types'
2import {
3  barCells,
4  displayEnd,
5  domainOf,
6  formatCost,
7  formatDuration,
8  formatTick,
9  isLive,
10  rowsOf,
11  ticksOf,
12} from './trace'
13
14export type Run = { text: string; color?: string; inverse?: boolean; dim?: boolean; bold?: boolean }
15
16export type ChartRow = { id: string; runs: Run[] }
17
18export type Chart = { axis: Run[]; rows: ChartRow[] }
19
20export type Palette = { model: string; tool: string; error: string; neutral: string }
21
22export const PALETTES: Record<Theme, Palette> = {
23  dark: { model: '#8B7BFF', tool: '#1FA2B8', error: '#E5504A', neutral: '#7A7A78' },
24  light: { model: '#6650E0', tool: '#0086A3', error: '#C8352F', neutral: '#858583' },
25}
26
27export const GRID_COLOR = 'subtle'
28export const NOW_COLOR = 'suggestion'
29export const INK_COLOR = 'text'
30
31const GUTTER = 2
32const DURATION_WIDTH = 7
33const COST_WIDTH = 6
34const MIN_TIMELINE = 8
35const MIN_LABEL = 12
36const MAX_LABEL = 34
37const INDENT = 2
38const MAX_INDENT_DEPTH = 4
39const COST_FROM_WIDTH = 56
40
41export type Layout = { label: number; timeline: number; hasCost: boolean }
42
43export function indentOf(depth: number): number {
44  return Math.min(depth, MAX_INDENT_DEPTH) * INDENT
45}
46
47export function layoutOf(width: number, nameWidth: number): Layout {
48  const hasCost = width >= COST_FROM_WIDTH
49  const numbers = DURATION_WIDTH + (hasCost ? 2 + COST_WIDTH : 0)
50  const fixed = GUTTER + 1 + 1 + numbers
51  const wanted = Math.max(MIN_LABEL, nameWidth, Math.min(Math.round(width * 0.36), MAX_LABEL))
52  const label = Math.min(wanted, width - fixed - MIN_TIMELINE)
53
54  if (label < nameWidth) {
55    return { label: Math.max(nameWidth, width - fixed), timeline: 0, hasCost }
56  }
57
58  return { label, timeline: width - fixed - label, hasCost }
59}
60
61export function colorOf(span: Span, palette: Palette): string {
62  if (span.isError) {
63    return palette.error
64  }
65
66  if (span.kind === 'model') {
67    return palette.model
68  }
69
70  return span.kind === 'tool' ? palette.tool : palette.neutral
71}
72
73function sameStyle(a: Run, b: Run): boolean {
74  return a.color === b.color && a.inverse === b.inverse && a.dim === b.dim && a.bold === b.bold
75}
76
77export function merged(runs: Run[]): Run[] {
78  const out: Run[] = []
79
80  for (const run of runs) {
81    const last = out[out.length - 1]
82
83    if (run.text === '') {
84      continue
85    }
86
87    if (last !== undefined && sameStyle(last, run)) {
88      out[out.length - 1] = { ...last, text: last.text + run.text }
89    } else {
90      out.push({ ...run })
91    }
92  }
93
94  return out
95}
96
97function labelRuns(span: Span, depth: number, width: number, isSelected: boolean): Run[] {
98  const indent = ' '.repeat(indentOf(depth))
99  const used = indent.length + span.name.length
100  const detail = span.details.find(text => used + 1 + text.length <= width)
101  const pad = width - used - (detail === undefined ? 0 : detail.length + 1)
102
103  return [
104    { text: indent },
105    { text: span.name, bold: isSelected || undefined },
106    { text: detail === undefined ? '' : ` ${detail}`, dim: true },
107    { text: ' '.repeat(Math.max(0, pad)) },
108  ]
109}
110
111function slowestLeaf(spans: Span[], now: number): string | null {
112  let best: Span | null = null
113
114  for (const span of spans) {
115    if (span.kind !== 'model' && span.kind !== 'tool') {
116      continue
117    }
118
119    if (best === null || displayEnd(span, now) - span.start > displayEnd(best, now) - best.start) {
120      best = span
121    }
122  }
123
124  return best === null ? null : best.id
125}
126
127export function rowCosts(trace: Trace): Map<string, number> {
128  const parents = new Map(trace.spans.map(span => [span.id, span.id === trace.id ? null : span.parent]))
129  const sums = new Map<string, number>()
130
131  for (const span of trace.spans) {
132    if (span.cost === null) {
133      continue
134    }
135
136    let id: string | null | undefined = span.id
137    const seen = new Set<string>()
138
139    while (id !== null && id !== undefined && !seen.has(id)) {
140      seen.add(id)
141      sums.set(id, (sums.get(id) ?? 0) + span.cost)
142      id = parents.get(id)
143    }
144  }
145
146  return sums
147}
148
149export function nameWidthOf(trace: Trace): number {
150  return rowsOf(trace).reduce((widest, row) => Math.max(widest, indentOf(row.depth) + row.span.name.length), 0)
151}
152
153export function chartOf(trace: Trace, now: number, width: number, palette: Palette, selected: string | null): Chart {
154  const rows = rowsOf(trace)
155  const layout = layoutOf(width, nameWidthOf(trace))
156  const live = isLive(trace)
157  const last = Math.max(trace.start, ...trace.spans.map(span => displayEnd(span, now)))
158  const extent = Math.max(1000, last - trace.start)
159  const domain = domainOf(extent)
160  const cells = layout.timeline
161  const ticks = ticksOf(domain, cells)
162  const column = (at: number): number => Math.floor(((at - trace.start) / domain) * cells)
163  const eighth = (at: number): number => Math.round(((at - trace.start) / domain) * cells * 8)
164  const gridColumns = new Set(ticks.filter(tick => tick > 0).map(tick => Math.floor(((tick * 1000) / domain) * cells)))
165  const nowColumn = live ? column(now) : -1
166  const slowest = slowestLeaf(trace.spans, now)
167  const costs = rowCosts(trace)
168
169  const axisLine = Array.from({ length: cells }, () => ' ')
170  let free = 0
171
172  for (const tick of ticks) {
173    const at = Math.floor(((tick * 1000) / domain) * cells)
174    const text = formatTick(tick)
175
176    if (at >= free && at + text.length <= cells) {
177      text.split('').forEach((char, index) => {
178        axisLine[at + index] = char
179      })
180      free = at + text.length + 1
181    }
182  }
183
184  const heads = 'time'.padStart(DURATION_WIDTH) + (layout.hasCost ? '  ' + 'cost'.padStart(COST_WIDTH) : '')
185  const axis = merged([
186    { text: ' '.repeat(GUTTER + layout.label + 1) },
187    { text: cells > 0 ? axisLine.join('') + ' ' : '', dim: true },
188    { text: heads, dim: true },
189  ])
190
191  const chartRows = rows.map(({ span, depth }) => {
192    const isSelected = span.id === selected
193    const color = colorOf(span, palette)
194    const end = span.id === trace.id ? last : displayEnd(span, now)
195    const runs: Run[] = isSelected ? [{ text: '▌', color: INK_COLOR }, { text: ' ' }] : [{ text: '  ' }]
196
197    runs.push(...labelRuns(span, depth, layout.label, isSelected), { text: ' ' })
198
199    if (cells > 0) {
200      barCells(eighth(span.start), eighth(end), cells).forEach((cell, index) => {
201        if (cell.mode === 'solid') {
202          runs.push({ text: cell.glyph, color })
203        } else if (cell.mode === 'inverse') {
204          runs.push({ text: cell.glyph, color, inverse: true })
205        } else if (index === nowColumn) {
206          runs.push({ text: '│', color: NOW_COLOR })
207        } else if (gridColumns.has(index)) {
208          runs.push({ text: '┊', color: GRID_COLOR })
209        } else {
210          runs.push({ text: ' ' })
211        }
212      })
213      runs.push({ text: ' ' })
214    }
215
216    runs.push({ text: formatDuration(end - span.start).padStart(DURATION_WIDTH), bold: span.id === slowest || undefined })
217
218    if (layout.hasCost) {
219      runs.push({ text: '  ' + formatCost(costs.get(span.id) ?? null).padStart(COST_WIDTH) })
220    }
221
222    return { id: span.id, runs: merged(runs) }
223  })
224
225  return { axis, rows: chartRows }
226}
227
hooks/trace.ts 542 lines
1import type { AgentLink, Span, SpanKind, SpanUsage, Trace } from '../types'
2
3export const MAX_TRACES = 20
4export const MAX_SPANS = 3000
5export const AGENT_PREFIX = 'agent:'
6export const REQUEST_PREFIX = 'request:'
7
8type Price = { input: number; output: number; cacheWrite: number; cacheRead: number }
9
10const PRICES: Record<string, Price> = {
11  'fable 5.1': { input: 10, output: 50, cacheWrite: 12.5, cacheRead: 0.25 },
12  'mythos 5.1': { input: 10, output: 50, cacheWrite: 12.5, cacheRead: 0.25 },
13  'fable 5': { input: 10, output: 50, cacheWrite: 12.5, cacheRead: 1 },
14  'mythos 5': { input: 10, output: 50, cacheWrite: 12.5, cacheRead: 1 },
15  'opus 5.5': { input: 4, output: 20, cacheWrite: 5, cacheRead: 0.2 },
16  'opus 5': { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
17  'opus 4.8': { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
18  'opus 4.7': { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
19  'opus 4.6': { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
20  'opus 4.5': { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 },
21  'sonnet 5.5': { input: 2, output: 10, cacheWrite: 2.5, cacheRead: 0.1 },
22  'sonnet 5': { input: 2, output: 10, cacheWrite: 2.5, cacheRead: 0.2 },
23  'sonnet 4.6': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
24  'sonnet 4.5': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 },
25  'haiku 5.5': { input: 0.1, output: 0.5, cacheWrite: 0.125, cacheRead: 0.01 },
26  'haiku 4.5': { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 },
27}
28
29const LONG_PROMPT_PRICES: Record<string, { over: number; price: Price }> = {
30  'haiku 5.5': { over: 100_000, price: { input: 0.5, output: 2.5, cacheWrite: 0.625, cacheRead: 0.05 } },
31}
32
33type ModelName = { family: string; version: string }
34
35export function parseModel(id: string): ModelName | null {
36  const found = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/.exec(id)
37
38  if (found === null || found[1] === undefined || found[2] === undefined) {
39    return null
40  }
41
42  return { family: found[1], version: found[3] === undefined ? found[2] : `${found[2]}.${found[3]}` }
43}
44
45export function modelLabel(id: string): string {
46  const parsed = parseModel(id)
47
48  if (parsed === null) {
49    return id
50  }
51
52  return `${parsed.family.charAt(0).toUpperCase()}${parsed.family.slice(1)} ${parsed.version}`
53}
54
55export function costOf(model: string, usage: SpanUsage): number | null {
56  const parsed = parseModel(model)
57  const name = parsed === null ? '' : `${parsed.family} ${parsed.version}`
58  const long = LONG_PROMPT_PRICES[name]
59  const prompt = usage.input + usage.cacheWrite + usage.cacheRead
60  const price = long !== undefined && prompt > long.over ? long.price : PRICES[name]
61
62  if (price === undefined) {
63    return null
64  }
65
66  return (
67    (usage.input * price.input +
68      usage.output * price.output +
69      usage.cacheWrite * price.cacheWrite +
70      usage.cacheRead * price.cacheRead) /
71    1e6
72  )
73}
74
75function oneLine(value: unknown): string {
76  if (typeof value !== 'string') {
77    return ''
78  }
79
80  const trimmed = value.trim()
81
82  return /[\r\n]/.test(trimmed) ? '' : trimmed
83}
84
85function pathForms(path: string): string[] {
86  const parts = path.replace(/\\/g, '/').split('/').filter(part => part !== '')
87  const last = parts[parts.length - 1]
88  const parent = parts[parts.length - 2]
89
90  if (last === undefined) {
91    return []
92  }
93
94  return parent === undefined ? [last] : [`${parent}/${last}`, last]
95}
96
97function hostOf(url: string): string {
98  return /^[a-z]+:\/\/([^/?#]+)/i.exec(url)?.[1] ?? ''
99}
100
101function firstWord(command: string): string {
102  return command.split(/\s+/)[0] ?? ''
103}
104
105function unique(texts: string[]): string[] {
106  return texts.filter((text, index) => text !== '' && texts.indexOf(text) === index)
107}
108
109export function describeTool(tool: string, input: Record<string, unknown>): { name: string; details: string[] } {
110  if (tool.startsWith('mcp__')) {
111    const parts = tool.split('__')
112
113    return { name: parts.slice(2).join('__') || tool, details: unique([parts[1] ?? '']) }
114  }
115
116  const text = (key: string): string => oneLine(input[key])
117
118  switch (tool) {
119    case 'Read':
120    case 'Edit':
121    case 'Write':
122    case 'NotebookEdit':
123      return { name: tool, details: unique(pathForms(text('file_path') || text('notebook_path'))) }
124    case 'Bash':
125    case 'PowerShell': {
126      const command = text('command')
127
128      return { name: tool, details: unique([text('description'), command, firstWord(command)]) }
129    }
130    case 'Grep':
131    case 'Glob':
132      return { name: tool, details: unique([text('pattern')]) }
133    case 'WebFetch':
134      return { name: tool, details: unique([text('url'), hostOf(text('url'))]) }
135    case 'WebSearch':
136    case 'ToolSearch':
137      return { name: tool, details: unique([text('query')]) }
138    case 'Skill':
139      return { name: tool, details: unique([text('skill')]) }
140    case 'Agent':
141    case 'Task':
142      return { name: 'Agent', details: unique([text('description'), text('subagent_type')]) }
143    case 'Workflow':
144      return { name: tool, details: unique([text('name'), text('description')]) }
145    default:
146      return { name: tool, details: [] }
147  }
148}
149
150export type NewSpan = {
151  id: string
152  kind: SpanKind
153  name: string
154  details: string[]
155  start: number
156  model?: string | null
157  prompt?: string | null
158  agentId?: string | null
159  pending?: number
160}
161
162function makeSpan(fields: NewSpan, parent: string | null, loop: string | null): Span {
163  return {
164    id: fields.id,
165    parent,
166    kind: fields.kind,
167    name: fields.name,
168    details: fields.details,
169    loop,
170    start: fields.start,
171    end: fields.start,
172    pending: fields.pending ?? 1,
173    isError: false,
174    model: fields.model ?? null,
175    usage: null,
176    cost: null,
177    prompt: fields.prompt ?? null,
178    agentId: fields.agentId ?? null,
179  }
180}
181
182export function startRequest(traces: Trace[], turnId: string, prompt: string, at: number): Trace[] {
183  const id = REQUEST_PREFIX + turnId
184  const root = makeSpan({ id, kind: 'turn', name: 'Request', details: [], start: at, pending: 0 }, null, null)
185  const turn = makeSpan({ id: turnId, kind: 'turn', name: 'Turn', details: [], start: at }, id, null)
186  const kept = traces.filter(trace => trace.id !== id)
187
188  return [...kept, { id, prompt, start: at, spans: [root, turn] }].slice(-MAX_TRACES)
189}
190
191export function continueRequest(traces: Trace[], traceId: string, turnId: string, detail: string, at: number): Trace[] {
192  const details = detail === '' ? [] : [detail]
193
194  return addSpan(traces, { trace: traceId, span: traceId }, { id: turnId, kind: 'turn', name: 'Turn', details, start: at }, null)
195}
196
197export function latestTrace(traces: Trace[]): Trace | undefined {
198  return traces[traces.length - 1]
199}
200
201export function findSpan(traces: Trace[], id: string): { trace: Trace; span: Span } | undefined {
202  for (let index = traces.length - 1; index >= 0; index -= 1) {
203    const trace = traces[index]
204    const span = trace?.spans.find(one => one.id === id)
205
206    if (trace !== undefined && span !== undefined) {
207      return { trace, span }
208    }
209  }
210
211  return undefined
212}
213
214export function placeOf(
215  traces: Trace[],
216  links: Record<string, AgentLink>,
217  main: AgentLink | null,
218  loop: string | null,
219): AgentLink | undefined {
220  const link = loop === null ? (main ?? undefined) : links[loop]
221
222  if (link !== undefined && traces.some(trace => trace.id === link.trace && trace.spans.some(span => span.id === link.span))) {
223    return link
224  }
225
226  const latest = latestTrace(traces)
227
228  return latest === undefined ? undefined : { trace: latest.id, span: latest.id }
229}
230
231export function addSpan(traces: Trace[], place: AgentLink, fields: NewSpan, loop: string | null): Trace[] {
232  return traces.map(trace => {
233    if (trace.id !== place.trace || trace.spans.length >= MAX_SPANS || trace.spans.some(one => one.id === fields.id)) {
234      return trace
235    }
236
237    return { ...trace, spans: [...trace.spans, makeSpan(fields, place.span, loop)] }
238  })
239}
240
241export function changeSpan(traces: Trace[], id: string, change: (span: Span) => Span): Trace[] {
242  const found = findSpan(traces, id)
243
244  if (found === undefined) {
245    return traces
246  }
247
248  return traces.map(trace =>
249    trace.id !== found.trace.id ? trace : { ...trace, spans: trace.spans.map(span => (span.id === id ? change(span) : span)) },
250  )
251}
252
253export function closeSpan(span: Span, at: number, isError: boolean): Span {
254  return {
255    ...span,
256    pending: Math.max(0, span.pending - 1),
257    end: Math.max(span.end, at),
258    isError: span.isError || isError,
259  }
260}
261
262export function closeLoop(trace: Trace, loop: string | null, at: number): Trace {
263  return {
264    ...trace,
265    spans: trace.spans.map(span =>
266      span.loop === loop && span.pending > 0 && (span.kind === 'model' || span.kind === 'tool')
267        ? { ...span, pending: 0, end: Math.max(span.end, at) }
268        : span,
269    ),
270  }
271}
272
273export function completeMain(traces: Trace[], id: string, at: number, isError: boolean): Trace[] {
274  return traces.map(trace => {
275    if (!trace.spans.some(span => span.id === id)) {
276      return trace
277    }
278
279    const closed = closeLoop(trace, null, at)
280
281    return {
282      ...closed,
283      spans: closed.spans.map(span => {
284        if (span.id === id) {
285          return { ...span, pending: 0, end: Math.max(span.end, at), isError: span.isError || isError }
286        }
287
288        if (span.kind === 'agent' && span.agentId === null && span.pending > 0) {
289          return { ...span, pending: 0, end: Math.max(span.end, at) }
290        }
291
292        return span
293      }),
294    }
295  })
296}
297
298export function displayEnd(span: Span, now: number): number {
299  return span.pending > 0 ? Math.max(now, span.end, span.start) : span.end
300}
301
302export function isLive(trace: Trace): boolean {
303  return trace.spans.some(span => span.pending > 0)
304}
305
306export type Row = { span: Span; depth: number }
307
308export function rowsOf(trace: Trace): Row[] {
309  const children = new Map<string | null, Span[]>()
310
311  for (const span of trace.spans) {
312    const parent = span.id === trace.id ? null : span.parent
313    const list = children.get(parent) ?? []
314    list.push(span)
315    children.set(parent, list)
316  }
317
318  const rows: Row[] = []
319  const seen = new Set<string>()
320
321  const walk = (parent: string | null, depth: number): void => {
322    const list = [...(children.get(parent) ?? [])].sort((a, b) => a.start - b.start)
323
324    for (const span of list) {
325      if (seen.has(span.id)) {
326        continue
327      }
328
329      seen.add(span.id)
330      rows.push({ span, depth })
331      walk(span.id, depth + 1)
332    }
333  }
334
335  walk(null, 0)
336
337  for (const span of trace.spans) {
338    if (!seen.has(span.id)) {
339      seen.add(span.id)
340      rows.push({ span, depth: 1 })
341    }
342  }
343
344  return rows
345}
346
347export function subtreeOf(trace: Trace, id: string): Span[] {
348  const rows = rowsOf(trace)
349  const at = rows.findIndex(row => row.span.id === id)
350  const head = rows[at]
351
352  if (head === undefined) {
353    return []
354  }
355
356  const out = [head.span]
357
358  for (const row of rows.slice(at + 1)) {
359    if (row.depth <= head.depth) {
360      break
361    }
362
363    out.push(row.span)
364  }
365
366  return out
367}
368
369export type Figures = {
370  duration: number
371  cost: number | null
372  tokensIn: number
373  tokensOut: number
374  modelCalls: number
375  toolCalls: number
376  errors: number
377}
378
379export function isToolCall(span: Span): boolean {
380  return span.kind === 'tool' || (span.kind === 'agent' && !span.id.startsWith(AGENT_PREFIX))
381}
382
383export function figuresOf(spans: Span[], now: number): Figures {
384  const head = spans[0]
385  const start = head?.start ?? 0
386  let end = start
387  let cost: number | null = null
388  let tokensIn = 0
389  let tokensOut = 0
390  let modelCalls = 0
391  let toolCalls = 0
392  let errors = 0
393
394  for (const span of spans) {
395    end = Math.max(end, displayEnd(span, now))
396
397    if (span.cost !== null) {
398      cost = (cost ?? 0) + span.cost
399    }
400
401    if (span.usage !== null) {
402      tokensIn += span.usage.input + span.usage.cacheRead + span.usage.cacheWrite
403      tokensOut += span.usage.output
404    }
405
406    modelCalls += span.kind === 'model' ? 1 : 0
407    toolCalls += isToolCall(span) ? 1 : 0
408    errors += span.isError ? 1 : 0
409  }
410
411  return { duration: end - start, cost, tokensIn, tokensOut, modelCalls, toolCalls, errors }
412}
413
414export function formatDuration(ms: number): string {
415  const safe = Math.max(0, ms)
416
417  if (safe < 1000) {
418    return `${Math.round(safe)}ms`
419  }
420
421  const seconds = safe / 1000
422
423  if (seconds < 10) {
424    return `${seconds.toFixed(1)}s`
425  }
426
427  const whole = Math.floor(seconds)
428
429  if (whole < 60) {
430    return `${whole}s`
431  }
432
433  if (whole < 3600) {
434    return `${Math.floor(whole / 60)}m ${String(whole % 60).padStart(2, '0')}s`
435  }
436
437  return `${Math.floor(whole / 3600)}h ${String(Math.floor((whole % 3600) / 60)).padStart(2, '0')}m`
438}
439
440export function formatTick(seconds: number): string {
441  if (seconds < 60) {
442    return `${seconds}s`
443  }
444
445  if (seconds < 3600) {
446    const rest = seconds % 60
447
448    return rest === 0 ? `${seconds / 60}m` : `${Math.floor(seconds / 60)}m ${rest}s`
449  }
450
451  const minutes = Math.floor((seconds % 3600) / 60)
452
453  return minutes === 0 ? `${Math.floor(seconds / 3600)}h` : `${Math.floor(seconds / 3600)}h ${minutes}m`
454}
455
456export function formatCost(usd: number | null): string {
457  if (usd === null) {
458    return ''
459  }
460
461  if (usd === 0) {
462    return '$0'
463  }
464
465  if (usd < 0.01) {
466    return '<$0.01'
467  }
468
469  if (usd < 100) {
470    return `$${usd.toFixed(2)}`
471  }
472
473  return `$${Math.round(usd)}`
474}
475
476export function formatTokens(count: number): string {
477  if (count < 1000) {
478    return String(count)
479  }
480
481  if (count < 1e6) {
482    return `${(count / 1e3).toFixed(count < 1e4 ? 1 : 0)}K`
483  }
484
485  return `${(count / 1e6).toFixed(1)}M`
486}
487
488const DOMAINS = [
489  5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 240, 300, 450, 600, 900, 1200, 1800, 2700, 3600, 5400, 7200, 10800, 14400,
490  21600, 43200, 86400,
491]
492
493export function domainOf(extent: number): number {
494  const wanted = (extent * 1.1) / 1000
495
496  return (DOMAINS.find(seconds => seconds >= wanted) ?? Math.ceil(wanted / 86400) * 86400) * 1000
497}
498
499const STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 10800, 21600, 43200, 86400]
500
501export function ticksOf(domain: number, cells: number): number[] {
502  const seconds = domain / 1000
503  const room = Math.max(1, Math.floor(cells / 8))
504  const step = STEPS.find(one => seconds / one <= room) ?? seconds
505  const ticks: number[] = []
506
507  for (let at = 0; at < seconds; at += step) {
508    ticks.push(at)
509  }
510
511  return ticks
512}
513
514export type Cell = { glyph: string; mode: 'empty' | 'solid' | 'inverse' }
515
516const LEFT_EIGHTHS = ' ▏▎▍▌▋▊▉'
517
518export function barCells(from: number, to: number, cells: number): Cell[] {
519  const start = Math.max(0, Math.min(from, cells * 8))
520  const end = Math.max(start + 1, Math.min(to, cells * 8))
521  const out: Cell[] = []
522
523  for (let cell = 0; cell < cells; cell += 1) {
524    const left = Math.max(start, cell * 8)
525    const right = Math.min(end, cell * 8 + 8)
526
527    if (right <= left) {
528      out.push({ glyph: ' ', mode: 'empty' })
529    } else if (left === cell * 8 && right === cell * 8 + 8) {
530      out.push({ glyph: '█', mode: 'solid' })
531    } else if (left === cell * 8) {
532      out.push({ glyph: LEFT_EIGHTHS[right - cell * 8] ?? '█', mode: 'solid' })
533    } else if (right === cell * 8 + 8) {
534      out.push({ glyph: LEFT_EIGHTHS[left - cell * 8] ?? ' ', mode: 'inverse' })
535    } else {
536      out.push({ glyph: LEFT_EIGHTHS[Math.max(1, right - left)] ?? '▏', mode: 'solid' })
537    }
538  }
539
540  return out
541}
542
types/index.d.ts 52 lines
1export type SpanKind = 'turn' | 'agent' | 'model' | 'tool'
2
3export type SpanUsage = {
4  input: number
5  output: number
6  cacheRead: number
7  cacheWrite: number
8}
9
10export type Span = {
11  id: string
12  parent: string | null
13  kind: SpanKind
14  name: string
15  details: string[]
16  loop: string | null
17  start: number
18  end: number
19  pending: number
20  isError: boolean
21  model: string | null
22  usage: SpanUsage | null
23  cost: number | null
24  prompt: string | null
25  agentId: string | null
26}
27
28export type Trace = {
29  id: string
30  prompt: string
31  start: number
32  spans: Span[]
33}
34
35export type AgentLink = { trace: string; span: string }
36
37export type Theme = 'dark' | 'light'
38
39declare module 'claude-code' {
40  interface PluginState {
41    'trace-waterfall': {
42      traces: Trace[]
43      agents: Record<string, AgentLink>
44      main: AgentLink | null
45      view: string | null
46      selected: string | null
47      now: number
48      theme: Theme
49    }
50  }
51}
52