SLOPSHOPPER

time-spent

A live card per turn: where the time and money went, across Claude and every tool type

newpanebandspinnerrowsguard
v0.9.0MITupdated 2026-10-05leviyehonatan/claude-time-spent
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · time-spent
│ ┃ time-spent-context ✕ › fix the failing auth test and add an audit log call │ ┃ No model requests recorded yet in this │ ┃ session. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /time-spent │ ⎿ time-spent: **Time spent:** 0.4s over 1 turn · **<$0.01** │ ⎿ time-spent: - **Claude**: <0.1s (thinking <0.1s, writing <0.1s, │ ⎿ time-spent: - **Commands**: <0.1s (4 calls: Bash) │ ⎿ time-spent: - **Edits**: <0.1s (3 calls: Edit, Write) │ ⎿ time-spent: - **Read/Search**: <0.1s (2 calls: Read, Grep) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · time-spent-context
No model requests recorded yet in this session.
README

claude-time-spent

A Claude Code mod that shows where the time in each turn went: Claude's own work (thinking, writing, composing tool input, waiting for the first token) and every tool type (commands, agents, edits, reads and searches, web, browser, MCP), and what the turn cost, as a card inline in the chat.

  • Live card next to the running turn, updated every second: in the terminal under the spinner; in the desktop app a one-line summary beside the spinner and the full card under the turn's live tool group, which stays in view while the app folds the turn's work into it; above the prompt elsewhere
  • Finished card under the final reply: a time strip of the turn in order, a legend with totals, and lanes per tool type (calls that ran together stack; parallel time is shaded; a subagent's own model and tool time shows on an "inside" lane)
  • Background tasks (commands and agents run in the background) on the card of the turn that launched them, from launch to their completion notice, each listed by name with how long it ran and how it ended (completed (exit code 0), failed (exit code 1))
  • A footer that compares recorded time with what the chart accounts for
  • Cost of each turn: the exact total from the session's cost ledger (what /cost reads), split into Claude's own requests, agents and anything else, the cost on the Claude and Agents lanes, and, with the lanes open, the priciest requests with the tool results each one read first. Requests are priced from their token counts at list price, then scaled to the ledger. On a subscription the figures are API list-price equivalents, not a bill.
  • Context pane (the context ↗ button on a card, or /time-spent context): the context size now and over the session, the median first-token wait by context size (cached requests only), cache misses with how much they re-read and what they cost over a cached read, and the session's cost (from the ledger, so cost between turns counts too) with a cost-per-turn trend
  • A quiet ⓘ line on a turn's card when cached requests now wait at least twice as long as early in the session at 100k+ tokens, suggesting /compact or a new session
  • Views: timeline, bars, compact, hide; click ▾ ◷ Time spent to fold the lanes. Both choices are remembered across sessions.

The desktop app draws the cards as SVG; the terminal gets a text version.

Install

git clone https://github.com/leviyehonatan/claude-time-spent ~/.claude/mods/time-spent

Then add the folder to the env block of ~/.claude/settings.json:

"env": {
  "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/time-spent"
}

New sessions load it, including ones the desktop app starts. A running session picks it up after a restart (in the terminal, claude --resume). For a single terminal session instead: claude --plugin-dir ~/.claude/mods/time-spent.

Settings

  • Cards to keep (25, 50, 100, 200, all; default 100): how many finished turns keep their card in a session. Older turns show as plain messages. Change it in /config.

Commands

  • /time-spent: totals for the whole session, per tool type
  • /time-spent context: open the Context pane
  • /time-spent view <timeline|bars|compact|hide>: switch the cards' view

Files

  • hooks/register.tsx: the hooks module
  • types/index.d.ts: the state contract
  • .claude-plugin/plugin.json: the manifest

License

MIT: see LICENSE.

Source 2 files
hooks/register.tsx 1583 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BgTask, Phase, PhaseSeg, Req, RunningCall, StepStat, SubSpan, ToolSpan, Totals, TurnRecord, ViewMode } from '../types'
5
6const turns = atom({ plugin: 'time-spent', key: 'turns' } as const, [])
7const mode = atom({ plugin: 'time-spent', key: 'mode' } as const, 'timeline' as ViewMode)
8const EMPTY_TOTALS: Totals = { turns: 0, turnMs: 0, claude: {}, kinds: {} }
9const totals = atom({ plugin: 'time-spent', key: 'totals' } as const, EMPTY_TOTALS)
10const steps = atom({ plugin: 'time-spent', key: 'steps' } as const, [])
11const lanesOpen = atom({ plugin: 'time-spent', key: 'lanes' } as const, true)
12const running = atom({ plugin: 'time-spent', key: 'running' } as const, {})
13const step = atom({ plugin: 'time-spent', key: 'step' } as const, null)
14const tick = atom({ plugin: 'time-spent', key: 'now' } as const, 0)
15const subRunning = atom({ plugin: 'time-spent', key: 'subRunning' } as const, {})
16const liveKey = atom({ plugin: 'time-spent', key: 'liveKey' } as const, null)
17const doneFamily = { plugin: 'time-spent', key: 'done' } as const
18const doneOrder = atom({ plugin: 'time-spent', key: 'doneOrder' } as const, [])
19
20const MODES: ViewMode[] = ['timeline', 'bars', 'compact', 'off']
21const isMode = (v: unknown): v is ViewMode => MODES.includes(v as ViewMode)
22
23// ---- Categories and colors ----
24
25type Kind = { label: string; color: string; match: (tool: string) => boolean }
26
27const KINDS: Kind[] = [
28  { label: 'Commands', color: '#F5A524', match: t => t === 'Bash' || t === 'PowerShell' || t === 'BashOutput' },
29  { label: 'Agents', color: '#A78BFA', match: t => t === 'Agent' || t === 'Task' || t === 'SendMessage' },
30  { label: 'Edits', color: '#34D399', match: t => t === 'Edit' || t === 'Write' || t === 'NotebookEdit' },
31  { label: 'Read/Search', color: '#60A5FA', match: t => t === 'Read' || t === 'Grep' || t === 'Glob' || t === 'LSP' },
32  { label: 'Web', color: '#22D3EE', match: t => t.startsWith('Web') },
33  { label: 'Browser', color: '#FB7185', match: t => t.includes('Browser') || t.includes('chrome') },
34  { label: 'MCP', color: '#F472B6', match: t => t.startsWith('mcp__') },
35  { label: 'Other', color: '#94A3B8', match: () => true },
36]
37
38const kindOf = (tool: string) => KINDS.find(k => k.match(tool)) as Kind
39
40const PHASES: { phase: Phase; label: string; color: string }[] = [
41  { phase: 'thinking', label: 'Thinking', color: '#D97757' },
42  { phase: 'writing', label: 'Writing', color: '#EDB49A' },
43  { phase: 'composing', label: 'Tool input', color: '#A8553A' },
44  { phase: 'waiting', label: 'Waiting', color: 'url(#hatch)' },
45]
46const CLAUDE = '#D97757'
47const BG = '#2DD4BF'
48const phaseOf = (p: Phase) => PHASES.find(x => x.phase === p) as (typeof PHASES)[number]
49
50// ---- Time arithmetic ----
51
52const fmt = (ms: number) => {
53  const s = ms / 1000
54  if (s < 0.1) return '<0.1s'
55  if (s < 10) return `${s.toFixed(1)}s`
56  if (s < 60) return `${Math.round(s)}s`
57  const m = Math.floor(s / 60)
58  if (m < 60) return `${m}m ${Math.round(s % 60)}s`
59  return `${Math.floor(m / 60)}h ${m % 60}m`
60}
61
62type Span = { start: number; end: number }
63
64const tokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
65
66// ---- Money: list prices in dollars per million tokens ----
67
68type Price = { in: number; out: number; read: number; write: number }
69
70const OPUS: Price = { in: 4, out: 20, read: 0.2, write: 5 }
71
72// First match wins, so a newer model sits above the family default. `write` is the 5-minute cache write.
73const PRICES: [RegExp, Price][] = [
74  [/fable|mythos/, { in: 10, out: 50, read: 0.25, write: 12.5 }],
75  [/opus-5-5/, OPUS],
76  [/opus-(5|4-[5-9])/, { in: 5, out: 25, read: 0.5, write: 6.25 }],
77  [/opus/, { in: 15, out: 75, read: 1.5, write: 18.75 }],
78  [/sonnet-5/, { in: 2, out: 10, read: 0.2, write: 2.5 }],
79  [/sonnet/, { in: 3, out: 15, read: 0.3, write: 3.75 }],
80  [/haiku-3/, { in: 0.8, out: 4, read: 0.08, write: 1 }],
81  [/haiku/, { in: 1, out: 5, read: 0.1, write: 1.25 }],
82]
83
84// An unknown model is priced as the current Opus.
85const priceOf = (model: string) => PRICES.find(([re]) => re.test(model))?.[1] ?? OPUS
86
87type Usage = { model?: string; input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }
88
89const usdOf = (u: Usage) => {
90  const p = priceOf(u.model ?? '')
91  return ((u.input_tokens ?? 0) * p.in + (u.output_tokens ?? 0) * p.out + (u.cache_read_input_tokens ?? 0) * p.read + (u.cache_creation_input_tokens ?? 0) * p.write) / 1e6
92}
93
94// What a request that missed the cache paid over the same request read from cache.
95const missUsdOf = (u: Usage) => {
96  const p = priceOf(u.model ?? '')
97  return ((u.input_tokens ?? 0) * (p.in - p.read) + (u.cache_creation_input_tokens ?? 0) * (p.write - p.read)) / 1e6
98}
99
100const money = (usd: number) => (usd < 0.005 ? '<$0.01' : usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`)
101
102const sumUsd = (xs: readonly { usd?: number }[]) => xs.reduce((a, x) => a + (x.usd ?? 0), 0)
103
104type Cost = {
105  /** The ledger's figure once the turn ended, else the sum of the estimates. */
106  total: number
107  isExact: boolean
108  isSub: boolean
109  claude: number
110  agents: number
111  /** Ledger cost no recorded request accounts for (side calls, compaction, price drift). */
112  other: number
113  reqs: (Req & { usd: number })[]
114}
115
116// The estimates are scaled (within ±25%) to add up to the ledger, which /cost reads; past that the rest shows as "other".
117const costOf = (turn: TurnRecord, sub: readonly SubSpan[]): Cost => {
118  const claude = sumUsd(turn.reqs ?? [])
119  const agents = sumUsd(sub)
120  const est = claude + agents
121  const isExact = turn.usd !== undefined
122  const k = isExact && est > 0 ? Math.min(1.25, Math.max(0.8, (turn.usd as number) / est)) : 1
123  const other = isExact ? (turn.usd as number) - est * k : 0
124  return {
125    total: isExact ? (turn.usd as number) : est,
126    isExact,
127    isSub: turn.isSub === true,
128    claude: claude * k,
129    agents: agents * k,
130    other: Math.abs(other) >= 0.01 ? other : 0,
131    reqs: (turn.reqs ?? []).map(r => ({ ...r, usd: r.usd * k })),
132  }
133}
134
135const costLine = (c: Cost) =>
136  [
137    `${money(c.total)}${c.isExact ? '' : ' so far'}`,
138    `Claude ${money(c.claude)}${c.reqs.length > 0 ? ` (${c.reqs.length} request${c.reqs.length === 1 ? '' : 's'})` : ''}`,
139    ...(c.agents > 0 ? [`agents ${money(c.agents)}`] : []),
140    ...(c.other !== 0 ? [`other ${money(c.other)}`] : []),
141  ].join(' · ') + (c.isSub ? ' · API list price' : '')
142
143// The requests that cost the most, with the tool results each was the first to read.
144const priciest = (c: Cost, n = 3) =>
145  [...c.reqs]
146    .sort((a, b) => b.usd - a.usd)
147    .slice(0, n)
148    .map(r => `${money(r.usd)}${r.after ? ` after ${r.after}` : ''} (${tokens(r.input + r.cacheWrite)} fresh, ${tokens(r.output)} out)`)
149
150const unionOf = (spans: readonly Span[]) => {
151  const out: [number, number][] = []
152  for (const b of [...spans].sort((x, y) => x.start - y.start)) {
153    const last = out[out.length - 1]
154    if (last !== undefined && b.start <= last[1]) last[1] = Math.max(last[1], b.end)
155    else out.push([b.start, b.end])
156  }
157  return out
158}
159
160// Wall-clock time covered by the spans: parallel ones are counted once.
161const covered = (spans: readonly Span[]) => unionOf(spans).reduce((a, [s, e]) => a + (e - s), 0)
162
163// Intervals during which two or more spans run at once.
164const parallelIntervals = (spans: readonly Span[]) => {
165  const edges = spans.flatMap(b => [
166    { at: b.start, d: 1 },
167    { at: b.end, d: -1 },
168  ])
169  edges.sort((a, b) => a.at - b.at || a.d - b.d)
170  const out: [number, number][] = []
171  let live = 0
172  let since = 0
173  for (const x of edges) {
174    if (live >= 2 && x.at > since) out.push([since, x.at])
175    live += x.d
176    since = x.at
177  }
178  return out
179}
180
181const shortName = (tool: string) => (tool.startsWith('mcp__') ? (tool.split('__').slice(-1)[0] ?? tool) : tool)
182
183type Group = { kind: Kind; ms: number; calls: number; tools: Record<string, number> }
184
185const breakdown = (spans: readonly ToolSpan[]) => {
186  const groups: Group[] = []
187  for (const kind of KINDS) {
188    const mine = spans.filter(s => kindOf(s.tool) === kind)
189    if (mine.length === 0) continue
190    const tools: Record<string, number> = {}
191    for (const s of mine) tools[shortName(s.tool)] = (tools[shortName(s.tool)] ?? 0) + 1
192    groups.push({ kind, ms: covered(mine), calls: mine.length, tools })
193  }
194  return groups.sort((a, b) => b.ms - a.ms)
195}
196
197const toolList = (g: Group) =>
198  Object.entries(g.tools)
199    .map(([t, n]) => (n > 1 ? `${t}×${n}` : t))
200    .join(' ')
201
202// ---- One turn, as everything the drawings need ----
203
204type Bar = ToolSpan & { isRunning: boolean }
205
206type View = {
207  turn: TurnRecord
208  isLive: boolean
209  from: number
210  to: number
211  total: number
212  bars: Bar[]
213  groups: Group[]
214  order: Group[]
215  model: PhaseSeg[]
216  phaseMs: { phase: Phase; ms: number }[]
217  claudeMs: number
218  toolMs: number
219  drawnMs: number
220  untrackedMs: number
221  parallel: [number, number][]
222  parallelMs: number
223  sub: SubSpan[]
224  /** Background tasks, each drawn up to its end or, while it runs, up to now (or the turn's end). */
225  bg: (BgTask & { end: number; isRunning: boolean })[]
226  /** The drawn axis: the turn, stretched to the last background task's end. */
227  axisTo: number
228  axisTotal: number
229  cost: Cost
230}
231
232const viewOf = (
233  turn: TurnRecord,
234  live: readonly RunningCall[],
235  liveStep: { phase: Phase; since: number } | null,
236  now: number,
237  subLive: readonly { agentId: string; tool: string; start: number }[] = [],
238): View => {
239  const isLive = turn.endedAt === undefined
240  const bars: Bar[] = [
241    ...turn.spans.map(b => ({ ...b, isRunning: false })),
242    ...live.map(c => ({ tool: c.tool, start: c.start, end: now, detail: c.detail, isRunning: true })),
243  ]
244  const model: PhaseSeg[] = [
245    ...(turn.model ?? []),
246    ...(isLive && liveStep !== null ? [{ phase: liveStep.phase, start: liveStep.since, end: now }] : []),
247  ]
248  const from = turn.startedAt
249  const to = Math.max(turn.endedAt ?? now, ...bars.map(b => b.end), ...model.map(m => m.end))
250  const total = Math.max(1, to - from)
251  const groups = breakdown(bars)
252  const firstStart = (g: Group) => Math.min(...bars.filter(s => kindOf(s.tool) === g.kind).map(s => s.start))
253  const order = [...groups].sort((a, b) => firstStart(a) - firstStart(b))
254  const phaseMs = PHASES.map(p => ({ phase: p.phase, ms: covered(model.filter(m => m.phase === p.phase)) })).filter(p => p.ms > 0)
255  const toolMs = covered(bars)
256  const claudeMs = covered(model)
257  const drawnMs = covered([...bars, ...model])
258  const parallel = parallelIntervals(bars)
259  const bg = (turn.bg ?? []).map(b => ({ ...b, end: b.end ?? Math.max(b.start, isLive ? now : to), isRunning: b.end === undefined }))
260  const axisTo = Math.max(to, ...bg.map(b => b.end))
261
262  return {
263    turn,
264    isLive,
265    from,
266    to,
267    total,
268    bars,
269    groups,
270    order,
271    model,
272    phaseMs,
273    claudeMs,
274    toolMs,
275    drawnMs,
276    untrackedMs: Math.max(0, total - drawnMs),
277    parallel,
278    parallelMs: parallel.reduce((a, [s, e]) => a + (e - s), 0),
279    sub: [...(turn.sub ?? []), ...(isLive ? subLive.map(x => ({ ...x, end: now })) : [])],
280    bg,
281    axisTo,
282    axisTotal: Math.max(1, axisTo - from),
283    cost: costOf(turn, turn.sub ?? []),
284  }
285}
286
287// ---- What was happening at each moment: the strip ----
288
289type Moment = { start: number; end: number; colors: string[]; label: string }
290
291// Between every two boundaries: the tool types running (they win), else Claude's phase, else nothing.
292const momentsOf = (v: View): Moment[] => {
293  const cuts = [...new Set([v.from, v.to, ...v.bars.flatMap(b => [b.start, b.end]), ...v.model.flatMap(m => [m.start, m.end])])]
294    .filter(t => t >= v.from && t <= v.to)
295    .sort((a, b) => a - b)
296  const out: Moment[] = []
297  for (let i = 0; i + 1 < cuts.length; i++) {
298    const a = cuts[i]
299    const b = cuts[i + 1]
300    if (b - a <= 0) continue
301    const mid = (a + b) / 2
302    const kinds = KINDS.filter(k => v.bars.some(x => kindOf(x.tool) === k && x.start <= mid && x.end > mid))
303    const phase = v.model.find(m => m.start <= mid && m.end > mid)
304    const colors = kinds.length > 0 ? kinds.map(k => k.color) : phase !== undefined ? [phaseOf(phase.phase).color] : []
305    const label = kinds.length > 0 ? kinds.map(k => k.label).join(' + ') : phase !== undefined ? phaseOf(phase.phase).label : 'untracked'
306    const last = out[out.length - 1]
307    if (last !== undefined && last.label === label) last.end = b
308    else out.push({ start: a, end: b, colors, label })
309  }
310  return out
311}
312
313// ---- The card: one SVG on desktop, mobile and VS Code ----
314
315const esc = (v: string) => v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
316
317const niceStep = (total: number) =>
318  [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600].map(x => x * 1000).find(x => total / x <= 6) ?? 3_600_000
319
320const tickLabel = (ms: number) => {
321  if (ms < 60_000) return `${ms / 1000}s`
322  const m = Math.floor(ms / 60_000)
323  const sec = Math.round((ms % 60_000) / 1000)
324  return sec === 0 ? `${m}m` : `${m}m${sec}s`
325}
326
327// Greedy tracks: spans of one lane that overlap stack on separate tracks.
328const tracksOf = <T extends Span>(spans: readonly T[]) => {
329  const ends: number[] = []
330  const at = new Map<T, number>()
331  for (const b of [...spans].sort((x, y) => x.start - y.start)) {
332    let i = ends.findIndex(e => e <= b.start)
333    if (i < 0) i = ends.push(0) - 1
334    ends[i] = b.end
335    at.set(b, i)
336  }
337  return { count: Math.max(1, ends.length), at }
338}
339
340const FONT = '-apple-system, BlinkMacSystemFont, Inter, "Segoe UI", system-ui, sans-serif'
341const MONO = 'ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace'
342
343const STYLE = `<style>
344text{font-family:${FONT}}
345.n{font-family:${MONO};font-variant-numeric:tabular-nums}
346.t{fill:#ececec}.m{fill:#a3a3a3}.f{fill:#6f6f6f}.g{stroke:#ffffff;stroke-opacity:.09}
347.lane{fill:#ffffff;fill-opacity:.035}
348.hit:hover{filter:brightness(1.2)}
349</style>`
350
351const HATCH = `<pattern id="hatch" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="5" height="5" fill="#ffffff" fill-opacity=".03"/><line x1="0" y1="0" x2="0" y2="5" stroke="#8a8a8a" stroke-opacity=".55" stroke-width="1.6"/></pattern>`
352
353const W = 680
354const P = 20
355
356const svgCard = (v: View, view: ViewMode, expanded: boolean) => {
357  const defs: string[] = [HATCH]
358  const out: string[] = []
359  const pct = (ms: number) => `${Math.round((ms / v.total) * 100)}%`
360  let y = P
361
362  // Header: a finished turn shows its total once; a live one leaves the time to the row above it.
363  if (!v.isLive) {
364    out.push(`<text x="${P}" y="${y + 22}" class="t n" font-size="24" font-weight="650">${esc(fmt(v.total))}</text>`)
365    out.push(`<text x="${P + fmt(v.total).length * 14.6 + 10}" y="${y + 22}" class="m" font-size="12.5">this turn<tspan class="t n" dx="10" font-weight="600">${esc(money(v.cost.total))}</tspan></text>`)
366    y += 34
367  }
368
369  // The share ribbon: Claude's phases, then each tool type, then what nothing accounts for.
370  const ribbon = [
371    ...v.phaseMs.map(p => ({ label: phaseOf(p.phase).label, color: phaseOf(p.phase).color, ms: p.ms })),
372    ...v.groups.map(g => ({ label: g.kind.label, color: g.kind.color, ms: g.ms })),
373    ...(v.untrackedMs > 50 ? [{ label: 'Untracked', color: 'transparent', ms: v.untrackedMs }] : []),
374  ]
375  const sum = Math.max(1, ribbon.reduce((a, r) => a + r.ms, 0))
376  const RW = W - 2 * P
377  if (expanded) {
378    // The lanes below draw the turn in order; the strip would repeat them.
379  } else if (view === 'timeline') {
380    // The strip: the turn in order, each moment in the color of what ran then.
381    const RH = 14
382    const XR = (t: number) => P + ((t - v.from) / v.axisTotal) * RW
383    defs.push(`<clipPath id="rib"><rect x="${P}" y="${y}" width="${RW}" height="${RH}" rx="5"/></clipPath>`)
384    out.push(`<rect x="${P}" y="${y}" width="${RW}" height="${RH}" rx="5" class="lane"/><g clip-path="url(#rib)">`)
385    for (const m of momentsOf(v)) {
386      const x = XR(m.start)
387      const w = Math.max(1, XR(m.end) - x)
388      const tip = `${m.label} · ${fmt(m.end - m.start)} · at ${fmt(m.start - v.from)}`
389      if (m.colors.length === 0) continue
390      const sh = RH / m.colors.length
391      m.colors.forEach((c, i) =>
392        out.push(`<rect class="hit" x="${x.toFixed(1)}" y="${(y + i * sh).toFixed(1)}" width="${w.toFixed(1)}" height="${sh.toFixed(1)}" fill="${c}"><title>${esc(tip)}</title></rect>`),
393      )
394    }
395    out.push(`</g>`)
396    const stepMs = niceStep(v.axisTotal)
397    for (let t = 0; t <= v.axisTotal + 1; t += stepMs) {
398      const x = XR(v.from + t)
399      out.push(`<line x1="${x.toFixed(1)}" y1="${y + RH + 2}" x2="${x.toFixed(1)}" y2="${y + RH + 5}" class="g" stroke-opacity=".5"/>`)
400      out.push(`<text x="${x.toFixed(1)}" y="${y + RH + 15}" text-anchor="${t === 0 ? 'start' : 'middle'}" class="f n" font-size="9.5">${tickLabel(t)}</text>`)
401    }
402    y += RH + 28
403  } else {
404    defs.push(`<clipPath id="rib"><rect x="${P}" y="${y}" width="${RW}" height="10" rx="5"/></clipPath>`)
405    out.push(`<rect x="${P}" y="${y}" width="${RW}" height="10" rx="5" class="lane"/><g clip-path="url(#rib)">`)
406    let rx = P
407    for (const r of ribbon) {
408      const w = (r.ms / sum) * RW
409      if (r.color !== 'transparent')
410        out.push(`<rect class="hit" x="${rx.toFixed(1)}" y="${y}" width="${Math.max(0, w - 1.5).toFixed(1)}" height="10" fill="${r.color}"><title>${esc(`${r.label} · ${fmt(r.ms)} · ${pct(r.ms)}`)}</title></rect>`)
411      rx += w
412    }
413    out.push(`</g>`)
414    y += 22
415  }
416
417  if (!expanded) {
418    y += 2
419  } else if (view === 'bars') {
420    const lx = P + 112
421    const lw = W - P - 64 - lx
422    for (const r of ribbon.filter(r => r.color !== 'transparent')) {
423      out.push(`<text x="${P}" y="${y + 9}" class="t" font-size="12.5">${esc(r.label)}</text>`)
424      out.push(`<rect x="${lx}" y="${y}" width="${lw}" height="10" rx="5" class="lane"/>`)
425      out.push(`<rect class="hit" x="${lx}" y="${y}" width="${Math.max(4, (r.ms / v.total) * lw).toFixed(1)}" height="10" rx="5" fill="${r.color}"><title>${esc(`${r.label} · ${fmt(r.ms)}`)}</title></rect>`)
426      out.push(`<text x="${W - P}" y="${y + 9}" text-anchor="end" class="m n" font-size="11">${pct(r.ms)}</text>`)
427      y += 20
428    }
429    y += 4
430  } else {
431    // The timeline: Claude's lane first, then each tool type in the order it first ran.
432    const labelW = 104
433    const durW = 52
434    const x0 = P + labelW
435    const x1 = W - P - durW
436    const cw = x1 - x0
437    const X = (t: number) => x0 + ((Math.min(Math.max(t, v.from), v.axisTo) - v.from) / v.axisTotal) * cw
438
439    type Lane = { label: string; color: string; ms: number; count?: number; isRunning?: boolean; h: number; draw: (top: number) => void; inner?: boolean }
440    const lanes: Lane[] = []
441    lanes.push({
442      label: 'Claude',
443      color: CLAUDE,
444      ms: v.claudeMs,
445      h: 24,
446      draw: top => {
447        for (const m of v.model) {
448          const p = phaseOf(m.phase)
449          out.push(`<rect class="hit" x="${X(m.start).toFixed(1)}" y="${top + 5}" width="${Math.max(1.5, X(m.end) - X(m.start)).toFixed(1)}" height="14" fill="${p.color}"><title>${esc(`${p.label} · ${fmt(m.end - m.start)}`)}</title></rect>`)
450        }
451      },
452    })
453    let gi = 0
454    for (const g of v.order) {
455      const bars = v.bars.filter(b => kindOf(b.tool) === g.kind)
456      const tr = tracksOf(bars)
457      const bh = tr.count === 1 ? 14 : Math.max(5, Math.floor(18 / tr.count))
458      const h = tr.count === 1 ? 24 : 8 + tr.count * (bh + 2)
459      lanes.push({
460        label: g.kind.label,
461        color: g.kind.color,
462        ms: g.ms,
463        count: g.calls,
464        isRunning: bars.some(b => b.isRunning),
465        h,
466        draw: top => {
467          const stackH = tr.count * bh + (tr.count - 1) * 2
468          for (const b of bars) {
469            const k = tr.at.get(b) ?? 0
470            const by = top + (h - stackH) / 2 + k * (bh + 2)
471            const bx = X(b.start)
472            const bw = Math.max(3, X(b.end) - bx)
473            let fill = g.kind.color
474            if (b.isRunning) {
475              fill = `url(#run${gi})`
476              defs.push(`<linearGradient id="run${gi}" x1="0" x2="1"><stop offset="0" stop-color="${g.kind.color}"/><stop offset=".7" stop-color="${g.kind.color}"/><stop offset="1" stop-color="${g.kind.color}" stop-opacity=".2"/></linearGradient>`)
477            }
478            const tip = `${shortName(b.tool)} · ${fmt(b.end - b.start)}${b.isRunning ? ' · running' : ''}${b.detail ? ` · ${b.detail}` : ''}`
479            out.push(`<rect class="hit" x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${bw.toFixed(1)}" height="${bh}" rx="${Math.min(3.5, bh / 2)}" fill="${fill}"><title>${esc(tip)}</title></rect>`)
480            gi += 1
481          }
482        },
483      })
484      // Inside the agents: their own model requests and tool calls, on a thin lane beneath.
485      if (g.kind.label === 'Agents' && v.sub.length > 0) {
486        const agents = [...new Set(v.sub.map(s => s.agentId))]
487        const subMs = covered(v.sub)
488        lanes.push({
489          label: 'inside',
490          color: 'none',
491          ms: subMs,
492          inner: true,
493          h: 8 + agents.length * 8,
494          draw: top => {
495            agents.forEach((id, i) => {
496              for (const s of v.sub.filter(x => x.agentId === id)) {
497                const color = s.tool === '@model' ? CLAUDE : kindOf(s.tool).color
498                const name = s.tool === '@model' ? 'model' : shortName(s.tool)
499                out.push(`<rect class="hit" x="${X(s.start).toFixed(1)}" y="${top + 4 + i * 8}" width="${Math.max(1.5, X(s.end) - X(s.start)).toFixed(1)}" height="6" rx="1.5" fill="${color}" fill-opacity="${s.tool === '@model' ? 0.75 : 1}"><title>${esc(`subagent ${i + 1} · ${name} · ${fmt(s.end - s.start)}`)}</title></rect>`)
500              }
501            })
502          },
503        })
504      }
505    }
506
507    if (v.bg.length > 0) {
508      const tr = tracksOf(v.bg)
509      const bh = tr.count === 1 ? 14 : Math.max(5, Math.floor(18 / tr.count))
510      const h = tr.count === 1 ? 24 : 8 + tr.count * (bh + 2)
511      lanes.push({
512        label: 'Background',
513        color: BG,
514        ms: covered(v.bg),
515        count: v.bg.length,
516        isRunning: v.bg.some(b => b.isRunning),
517        h,
518        draw: top => {
519          const stackH = tr.count * bh + (tr.count - 1) * 2
520          v.bg.forEach((b, i) => {
521            const k = tr.at.get(b) ?? 0
522            const by = top + (h - stackH) / 2 + k * (bh + 2)
523            const bx = X(b.start)
524            const bw = Math.max(3, X(b.end) - bx)
525            const color = kindOf(b.tool).color
526            let fill = color
527            if (b.isRunning) {
528              fill = `url(#bg${i})`
529              defs.push(`<linearGradient id="bg${i}" x1="0" x2="1"><stop offset="0" stop-color="${color}"/><stop offset=".6" stop-color="${color}"/><stop offset="1" stop-color="${color}" stop-opacity=".15"/></linearGradient>`)
530            }
531            const tip = `${shortName(b.tool)} in background · ${fmt(b.end - b.start)}${b.isRunning ? ' so far · running' : ` · ${b.status ?? 'done'}`}${b.detail ? ` · ${b.detail}` : ''}`
532            out.push(`<rect class="hit" x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${bw.toFixed(1)}" height="${bh}" rx="${Math.min(3.5, bh / 2)}" fill="${fill}" stroke="${BG}" stroke-opacity=".7" stroke-width="1"><title>${esc(tip)}</title></rect>`)
533          })
534        },
535      })
536    }
537
538    // Axis on top, grid through every lane.
539    const lanesH = lanes.reduce((a, l) => a + l.h + 4, 0)
540    const top = y + 12
541    const stepMs = niceStep(v.axisTotal)
542    for (let t = 0; t <= v.axisTotal + 1; t += stepMs) {
543      const x = X(v.from + t)
544      out.push(`<line x1="${x.toFixed(1)}" y1="${top}" x2="${x.toFixed(1)}" y2="${top + lanesH}" class="g" stroke-dasharray="2 3"/>`)
545      out.push(`<text x="${x.toFixed(1)}" y="${y + 6}" text-anchor="middle" class="f n" font-size="9.5">${tickLabel(t)}</text>`)
546    }
547    for (const [a, b] of v.parallel)
548      out.push(`<rect x="${X(a).toFixed(1)}" y="${top}" width="${Math.max(1, X(b) - X(a)).toFixed(1)}" height="${lanesH}" fill="#F5A524" fill-opacity=".08"><title>${esc(`${fmt(b - a)} with calls in parallel`)}</title></rect>`)
549
550    let ly = top
551    for (const l of lanes) {
552      const mid = ly + l.h / 2
553      if (l.inner) {
554        out.push(`<text x="${P + 14}" y="${mid + 3.5}" class="f" font-size="10.5">↳ inside</text>`)
555        out.push(`<text x="${W - P}" y="${mid + 3.5}" text-anchor="end" class="f n" font-size="10.5">${esc(fmt(l.ms))}</text>`)
556      } else {
557        out.push(`<rect x="${x0 - 4}" y="${ly}" width="${cw + 8}" height="${l.h}" rx="6" class="lane"/>`)
558        out.push(`<circle cx="${P + 4}" cy="${mid}" r="4" fill="${l.color}"/>`)
559        out.push(`<text x="${P + 14}" y="${mid + 4}" class="t" font-size="12.5" font-weight="500">${esc(l.label)}${l.count !== undefined ? `<tspan class="f n" font-size="10.5" dx="5">${l.count}</tspan>` : ''}${l.isRunning ? `<tspan fill="#F43F5E" font-size="9" dx="4">●</tspan>` : ''}</text>`)
560        out.push(`<text x="${W - P}" y="${mid + 4}" text-anchor="end" class="t n" font-size="11.5" font-weight="600">${esc(fmt(l.ms))}</text>`)
561      }
562      l.draw(ly)
563      ly += l.h + 4
564    }
565    if (v.isLive) {
566      const nx = X(v.to)
567      out.push(`<line x1="${nx.toFixed(1)}" y1="${top - 3}" x2="${nx.toFixed(1)}" y2="${ly - 4}" stroke="#F43F5E" stroke-width="1.5"/><circle cx="${nx.toFixed(1)}" cy="${top - 3}" r="2.8" fill="#F43F5E"/>`)
568    }
569    y = ly + 4
570  }
571
572  // The legend, under the timeline: every slice with its time, as chips.
573  let cx = P
574  let cy = y + 10
575  for (const r of ribbon) {
576    const label = `${r.label} `
577    const time = fmt(r.ms)
578    const w = 14 + label.length * 6.6 + time.length * 7 + 14
579    if (cx + w > W - P) {
580      cx = P
581      cy += 18
582    }
583    const dot =
584      r.color === 'transparent'
585        ? `<circle cx="${cx + 4}" cy="${cy - 4}" r="3.5" fill="none" stroke="#8a8a8a" stroke-dasharray="1.5 1.5"/>`
586        : `<circle cx="${cx + 4}" cy="${cy - 4}" r="4" fill="${r.color}"/>`
587    out.push(`${dot}<text x="${cx + 13}" y="${cy}" class="m" font-size="11.5">${esc(r.label)}<tspan class="t n" dx="5" font-weight="600">${esc(time)}</tspan></text>`)
588    cx += w
589  }
590  y = cy + 14
591
592
593  // Footer: what was recorded against what the chart accounts for.
594  out.push(`<line x1="${P}" y1="${y + 2}" x2="${W - P}" y2="${y + 2}" class="g"/>`)
595  const foot = [
596    `recorded ${fmt(v.total)}`,
597    `charted ${fmt(v.drawnMs)} (${pct(v.drawnMs)})`,
598    `untracked ${fmt(v.untrackedMs)}`,
599    ...(v.parallelMs > 0 ? [`${fmt(v.parallelMs)} parallel`] : []),
600    `${v.bars.length} tool call${v.bars.length === 1 ? '' : 's'}`,
601  ].join('   ·   ')
602  out.push(`<text x="${P}" y="${y + 18}" class="f n" font-size="10.5">${esc(foot)}</text>`)
603  y += 24
604  out.push(`<text x="${P}" y="${y + 12}" class="m n" font-size="10.5">${esc(costLine(v.cost))}</text>`)
605  y += 18
606
607  const H = y + P - 6
608  const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${STYLE}<defs>${defs.join('')}</defs><rect width="${W}" height="${H}" fill="#151515"/><rect x=".5" y=".5" width="${W - 1}" height="${H - 1}" rx="14" fill="#1c1c1c" stroke="#2c2c2c"/>${out.join('')}</svg>`
609  const alt = `${fmt(v.total)}, ${money(v.cost.total)}: Claude ${fmt(v.claudeMs)}, ${v.groups.map(g => `${g.kind.label} ${fmt(g.ms)}`).join(', ')}; untracked ${fmt(v.untrackedMs)}`
610
611  return { source, alt, width: W, height: H }
612}
613
614// ---- Text drawing, for the terminal and the compact view ----
615
616type Els = { Box: any; Text: any; Button: any; Svg?: any; Markdown?: any }
617
618const switcherOf = (
619  els: Els,
620  view: ViewMode,
621  setView: (v: ViewMode) => () => Promise<unknown>,
622  lanes?: { open: boolean; toggle: () => Promise<unknown> },
623  openContext?: () => Promise<unknown>,
624) => {
625  const { Box, Text, Button } = els
626  const canFold = lanes !== undefined && view !== 'off' && view !== 'compact'
627  return (
628    <Box flexDirection="row" gap={2} alignItems="center">
629      {canFold ? (
630        <Button key="lanes" label={`${lanes.open ? '▾' : '▸'}  ◷ Time spent`} plain dimColor={!lanes.open} onPress={lanes.toggle} />
631      ) : (
632        <Text>
633          <Text color={CLAUDE}>◷</Text>
634          <Text dimColor> Time spent</Text>
635        </Text>
636      )}
637      <Box flexDirection="row" gap={1}>
638        {MODES.map(m => (
639          <Button
640            key={`view:${m}`}
641            label={m === 'off' ? 'hide' : m}
642            plain={m === view ? undefined : true}
643            dimColor={m !== view}
644            variant={m === view ? 'primary' : 'secondary'}
645            onPress={setView(m)}
646          />
647        ))}
648      </Box>
649      {openContext !== undefined && <Button key="context" label="context ↗" plain dimColor onPress={openContext} />}
650    </Box>
651  )
652}
653
654const textCard = (els: Els, v: View, view: ViewMode, columns: number) => {
655  const { Box, Text } = els
656  const rows = [
657    { label: 'Claude', color: CLAUDE, ms: v.claudeMs, note: v.phaseMs.map(p => `${phaseOf(p.phase).label.toLowerCase()} ${fmt(p.ms)}`).join(', ') },
658    ...v.groups.map(g => ({ label: g.kind.label, color: g.kind.color, ms: g.ms, note: toolList(g) })),
659  ]
660  if (view === 'compact') {
661    return (
662      <Text>
663        {!v.isLive && <Text bold>{fmt(v.total)}  </Text>}
664        <Text bold>{money(v.cost.total)}  </Text>
665        {rows.map(r => (
666          <Text>
667            <Text color={r.color}>● </Text>
668            {r.label} <Text bold>{fmt(r.ms)}</Text>
669            <Text dimColor>   </Text>
670          </Text>
671        ))}
672        {v.untrackedMs > 50 && <Text dimColor>untracked {fmt(v.untrackedMs)}</Text>}
673      </Text>
674    )
675  }
676  const labelW = 13
677  const cells = Math.max(16, Math.min(60, columns - labelW - 26))
678  const cellMs = v.total / cells
679  const laneOf = (spans: readonly Span[]) =>
680    Array.from({ length: cells }, (_, i) => {
681      const a = v.from + i * cellMs
682      return spans.some(s => s.start < a + cellMs && s.end > a)
683    })
684  const draw = (cellsOn: boolean[], color: string) => cellsOn.map(on => (on ? <Text color={color}>█</Text> : <Text dimColor>·</Text>))
685
686  return (
687    <Box flexDirection="column">
688      {!v.isLive && <Text bold>{fmt(v.total)} this turn</Text>}
689      {rows.map(r => (
690        <Text>
691          <Text color={r.color}>{('● ' + r.label).padEnd(labelW)}</Text>
692          {view === 'timeline'
693            ? draw(laneOf(r.label === 'Claude' ? v.model : v.bars.filter(b => kindOf(b.tool).color === r.color)), r.color)
694            : <Text color={r.color}>{'█'.repeat(Math.max(1, Math.round((r.ms / v.total) * cells)))}</Text>}
695          <Text bold> {fmt(r.ms).padStart(6)}</Text>
696          <Text dimColor> {r.note}</Text>
697        </Text>
698      ))}
699      <Text dimColor>
700        recorded {fmt(v.total)} · charted {fmt(v.drawnMs)} · untracked {fmt(v.untrackedMs)}
701      </Text>
702      <Text dimColor>{costLine(v.cost)}</Text>
703    </Box>
704  )
705}
706
707const cardOf = (
708  els: Els,
709  v: View,
710  view: ViewMode,
711  columns: number,
712  setView: (m: ViewMode) => () => Promise<unknown>,
713  lanes: { open: boolean; toggle: () => Promise<unknown> },
714  doing = '',
715  openContext?: () => Promise<unknown>,
716) => {
717  const { Box } = els
718  const body =
719    view === 'timeline' || (v.isLive && view === 'bars')
720      ? nativeCard(els, v, lanes.open, doing)
721      : els.Svg !== undefined && view === 'bars'
722      ? (() => {
723          const c = svgCard(v, view, lanes.open)
724          const Svg = els.Svg
725          return <Svg source={c.source} alt={c.alt} width={c.width} height={c.height} isInteractive={false} />
726        })()
727      : textCard(els, v, view, columns)
728
729  return (
730    <Box flexDirection="column" gap={1}>
731      {switcherOf(els, view, setView, lanes, openContext)}
732      {body}
733    </Box>
734  )
735}
736
737// ---- The live card: native boxes, so a redraw each second never flashes ----
738
739type Seg = { w: number; color: string | null; key: string }
740
741// A track as alternating gaps and bars, widths in whole percent of the track summing to 100:
742// boundaries are rounded, and a bar shorter than a percent still takes one.
743const segsOf = (spans: readonly (Span & { color: string })[], from: number, total: number, key: string): Seg[] => {
744  const out: Seg[] = []
745  const pos = (t: number) => Math.min(100, Math.max(0, Math.round(((t - from) / total) * 100)))
746  let at = 0
747  spans
748    .slice()
749    .sort((a, b) => a.start - b.start)
750    .forEach((s, i) => {
751      if (at >= 100) return
752      const a = Math.max(at, pos(s.start))
753      const b = Math.min(100, Math.max(a + 1, pos(s.end)))
754      if (a > at) out.push({ w: a - at, color: null, key: `${key}g${i}` })
755      out.push({ w: b - a, color: s.color, key: `${key}b${i}` })
756      at = b
757    })
758  if (at < 100) out.push({ w: 100 - at, color: null, key: `${key}end` })
759  return out.filter(g => g.w > 0)
760}
761
762// The timeline card, live or finished, in one shape: when a turn ends the same boxes update in place.
763const nativeCard = (els: Els, v: View, expanded: boolean, doing: string) => {
764  const { Box, Text } = els
765  const track = (segs: Seg[]) => (
766    <Box flexDirection="row" width="100%">
767      {segs.map(g =>
768        g.color === null ? (
769          <Box key={g.key} width={`${g.w}%`} />
770        ) : (
771          <Box key={g.key} width={`${g.w}%`} backgroundColor={g.color}>
772            <Text> </Text>
773          </Box>
774        ),
775      )}
776    </Box>
777  )
778  const lane = (label: string, color: string, ms: number, tracks: Seg[][], mark?: string, usd?: number) => (
779    <Box key={`lane:${label}`} flexDirection="row" width="100%" alignItems="center">
780      <Box width="18%">
781        <Text>
782          <Text color={color}>● </Text>
783          {label}
784          {mark !== undefined && <Text color="#F43F5E"> {mark}</Text>}
785          {usd !== undefined && usd > 0 && <Text dimColor> {money(usd)}</Text>}
786        </Text>
787      </Box>
788      <Box width="70%" flexDirection="column" backgroundColor="#232323">
789        {tracks.map(t => track(t))}
790      </Box>
791      <Box width="12%" justifyContent="flex-end">
792        <Text bold>{fmt(ms)}</Text>
793      </Box>
794    </Box>
795  )
796  const waiting = '#3a3a3a'
797  const claude = v.model.map(m => ({ ...m, color: m.phase === 'waiting' ? waiting : phaseOf(m.phase).color }))
798  const lanes = [lane('Claude', CLAUDE, v.claudeMs, [segsOf(claude, v.from, v.axisTotal, 'c')], undefined, v.cost.claude)]
799  for (const g of v.order) {
800    const bars = v.bars.filter(b => kindOf(b.tool) === g.kind)
801    const tr = tracksOf(bars)
802    const tracks = Array.from({ length: tr.count }, (_, i) =>
803      segsOf(bars.filter(b => tr.at.get(b) === i).map(b => ({ ...b, color: g.kind.color })), v.from, v.axisTotal, `${g.kind.label}${i}`),
804    )
805    lanes.push(lane(g.kind.label, g.kind.color, g.ms, tracks, bars.some(b => b.isRunning) ? '●' : undefined, g.kind.label === 'Agents' ? v.cost.agents : undefined))
806    if (g.kind.label === 'Agents' && v.sub.length > 0) {
807      const agents = [...new Set(v.sub.map(x => x.agentId))]
808      const subTracks = agents.map((id, i) =>
809        segsOf(
810          v.sub.filter(x => x.agentId === id).map(x => ({ ...x, color: x.tool === '@model' ? '#B86A50' : kindOf(x.tool).color })),
811          v.from,
812          v.axisTotal,
813          `sub${i}`,
814        ),
815      )
816      lanes.push(lane(`↳ inside (${agents.length})`, '#6f6f6f', covered(v.sub), subTracks))
817    }
818  }
819  if (v.bg.length > 0) {
820    const tr = tracksOf(v.bg)
821    const tracks = Array.from({ length: tr.count }, (_, i) =>
822      segsOf(v.bg.filter(b => tr.at.get(b) === i).map(b => ({ ...b, color: kindOf(b.tool).color })), v.from, v.axisTotal, `bg${i}`),
823    )
824    lanes.push(lane(`Background ${v.bg.length}`, BG, covered(v.bg), tracks, v.bg.some(b => b.isRunning) ? '●' : undefined))
825    // Each task by name, with how long it ran and how it ended: the lane alone says neither.
826    lanes.push(
827      <Box key="bg:list" flexDirection="column" paddingLeft={2}>
828        {v.bg.map((b, i) => (
829          <Text key={`bg:${i}`} wrap="truncate-end">
830            <Text color={kindOf(b.tool).color}>● </Text>
831            <Text>{shortName(b.tool)}</Text>
832            {b.detail !== undefined && <Text dimColor> · {b.detail}</Text>}
833            <Text dimColor> · {fmt(b.end - b.start)}</Text>
834            {b.isRunning ? (
835              <Text color="#F43F5E"> · running</Text>
836            ) : (
837              <Text color={/fail|error|kill/i.test(b.status ?? '') || /exit code [1-9]/.test(b.result ?? '') ? '#F43F5E' : '#34D399'}>
838                {' '}· {b.status ?? 'done'}
839                {b.result !== undefined ? ` (${b.result})` : ''}
840              </Text>
841            )}
842          </Text>
843        ))}
844      </Box>,
845    )
846  }
847  const running = v.bars.filter(b => b.isRunning).length
848  const legend = [
849    ...v.phaseMs.map(p => ({ label: phaseOf(p.phase).label, color: p.phase === 'waiting' ? waiting : phaseOf(p.phase).color, ms: p.ms })),
850    ...v.groups.map(g => ({ label: g.kind.label, color: g.kind.color, ms: g.ms })),
851    ...(!v.isLive && v.untrackedMs > 50 ? [{ label: 'Untracked', color: '#555555', ms: v.untrackedMs }] : []),
852  ]
853  const facts = [
854    `${v.bars.length} call${v.bars.length === 1 ? '' : 's'}`,
855    ...(running > 0 ? [`${running} running`] : []),
856    ...(v.parallelMs > 0 ? [`${fmt(v.parallelMs)} parallel`] : []),
857  ].join(' · ')
858
859  return (
860    <Box key="card" flexDirection="column" gap={1} padding={1} borderStyle="round" borderColor="#2c2c2c" backgroundColor="#1c1c1c">
861      <Box key="head" flexDirection="row" justifyContent="space-between">
862        <Text>
863          <Text color={v.isLive ? '#F43F5E' : CLAUDE}>{v.isLive ? '● ' : '◷ '}</Text>
864          <Text bold>{v.isLive ? doing : fmt(v.total)}</Text>
865          {!v.isLive && <Text dimColor> this turn</Text>}
866          {v.cost.total > 0 && <Text dimColor>  ·  </Text>}
867          {v.cost.total > 0 && <Text bold>{money(v.cost.total)}</Text>}
868          {v.cost.total > 0 && v.isLive && <Text dimColor> so far</Text>}
869        </Text>
870        <Text dimColor>{facts}</Text>
871      </Box>
872      <Box key="body" flexDirection="column" gap={1}>
873        {expanded
874          ? lanes
875          : track(segsOf(momentsOf(v).filter(m => m.colors.length > 0).map(m => ({ start: m.start, end: m.end, color: m.colors[0] })), v.from, v.axisTotal, 'strip'))}
876      </Box>
877      <Box key="legend" flexDirection="row" flexWrap="wrap" columnGap={3}>
878        {legend.map(l => (
879          <Text key={`lg:${l.label}`}>
880            <Text color={l.color}>● </Text>
881            <Text dimColor>{l.label} </Text>
882            <Text bold>{fmt(l.ms)}</Text>
883          </Text>
884        ))}
885      </Box>
886      <Box key="foot">
887        <Text dimColor>
888          {v.isLive
889            ? ' '
890            : `recorded ${fmt(v.total)} · charted ${fmt(v.drawnMs)} (${Math.round((v.drawnMs / v.total) * 100)}%) · untracked ${fmt(v.untrackedMs)}`}
891        </Text>
892      </Box>
893      {!v.isLive && v.cost.total > 0 && (
894        <Box key="cost" flexDirection="column">
895          <Text dimColor>{costLine(v.cost)}</Text>
896          {expanded && v.cost.reqs.length > 1 && <Text dimColor>priciest: {priciest(v.cost).join(' · ')}</Text>}
897        </Box>
898      )}
899      {!v.isLive && v.turn.nudge !== undefined && (
900        <Box key="nudge">
901          <Text>
902            <Text color="#60A5FA">ⓘ </Text>
903            <Text dimColor>{v.turn.nudge}</Text>
904          </Text>
905        </Box>
906      )}
907    </Box>
908  )
909}
910
911// ---- Context: how the first-token wait relates to the context a request carries ----
912
913const CONTEXT_PANE = 'time-spent-context'
914
915const median = (xs: readonly number[]) => {
916  if (xs.length === 0) return 0
917  const s = [...xs].sort((a, b) => a - b)
918  const m = Math.floor(s.length / 2)
919  return s.length % 2 === 1 ? s[m] : (s[m - 1] + s[m]) / 2
920}
921
922
923const BUCKETS: { label: string; max: number }[] = [
924  { label: '<50k', max: 50_000 },
925  { label: '50–100k', max: 100_000 },
926  { label: '100–200k', max: 200_000 },
927  { label: '200–500k', max: 500_000 },
928  { label: '500k+', max: Number.POSITIVE_INFINITY },
929]
930
931// The advice, only when cached requests now wait at least twice as long as early in the session.
932const nudgeOf = (list: readonly StepStat[]) => {
933  const hits = list.filter(x => !x.isMiss)
934  if (hits.length < 10) return undefined
935  const early = median(hits.slice(0, 5).map(x => x.ttft))
936  const recent = median(hits.slice(-5).map(x => x.ttft))
937  const context = hits[hits.length - 1].context
938  if (early <= 0 || recent < 2 * early || recent < 2000 || context < 100_000) return undefined
939  return `First-token wait is ${(recent / early).toFixed(1)}× what it was at the start (${fmt(early)} → ${fmt(recent)} at ${tokens(context)} tokens). /compact or a new session would reset it.`
940}
941
942// `spent` is the ledger since the first recorded turn (between-turn work included), `live` the running turn's so far.
943const contextPane = (els: Els, list: readonly StepStat[], columns: number, sum: Totals, spent?: number, live?: number) => {
944  const { Box, Text } = els
945  if (list.length === 0) return <Text dimColor>No model requests recorded yet in this session.</Text>
946  const last = list[list.length - 1]
947  const hits = list.filter(x => !x.isMiss)
948  const misses = list.filter(x => x.isMiss)
949  const rows = BUCKETS.map((b, i) => {
950    const lo = i === 0 ? 0 : BUCKETS[i - 1].max
951    const mine = hits.filter(x => x.context >= lo && x.context < b.max)
952    return { label: b.label, n: mine.length, ms: median(mine.map(x => x.ttft)) }
953  }).filter(r => r.n > 0)
954  const top = Math.max(1, ...rows.map(r => r.ms))
955  const barW = Math.max(8, Math.min(30, columns - 26))
956  // Context per turn: the largest request each turn carried.
957  const perTurn = new Map<number, number>()
958  for (const x of list) perTurn.set(x.turn, Math.max(perTurn.get(x.turn) ?? 0, x.context))
959  const series = [...perTurn.values()].slice(-Math.max(8, columns - 4))
960  const peak = Math.max(1, ...series)
961  const spark = series.map(v => '▁▂▃▄▅▆▇█'[Math.min(7, Math.floor((v / peak) * 7.999))]).join('')
962  const missMs = misses.reduce((a, x) => a + Math.max(0, x.ttft - median(hits.map(h => h.ttft))), 0)
963  const missUsd = sumUsd(misses.map(x => ({ usd: x.missUsd })))
964  const turnUsd = (sum.turnUsd ?? []).slice(-Math.max(8, columns - 4))
965  const usdPeak = Math.max(0.0001, ...turnUsd)
966  const usdSpark = turnUsd.map(v => '▁▂▃▄▅▆▇█'[Math.min(7, Math.floor((v / usdPeak) * 7.999))]).join('')
967  const recent = turnUsd.slice(-5)
968  const early = turnUsd.slice(0, 5)
969  const avg = (xs: number[]) => (xs.length === 0 ? 0 : xs.reduce((a, x) => a + x, 0) / xs.length)
970  const advice = nudgeOf(list)
971
972  return (
973    <Box flexDirection="column" gap={1}>
974      <Text>
975        <Text bold>{tokens(last.context)} tokens now</Text>
976        <Text dimColor> · started at {tokens(list[0].context)} · {list.length} requests</Text>
977      </Text>
978      <Box flexDirection="column">
979        <Text dimColor>First-token wait by context size (cached requests, median)</Text>
980        {rows.map(r => (
981          <Text key={`b:${r.label}`}>
982            {r.label.padEnd(10)}
983            <Text color={CLAUDE}>{'█'.repeat(Math.max(1, Math.round((r.ms / top) * barW)))}</Text>
984            <Text bold> {fmt(r.ms)}</Text>
985            <Text dimColor> · {r.n}</Text>
986          </Text>
987        ))}
988      </Box>
989      <Box flexDirection="column">
990        <Text dimColor>Context over the session ({perTurn.size} turns)</Text>
991        <Text color={CLAUDE}>{spark}</Text>
992      </Box>
993      {turnUsd.length > 0 && (
994        <Box flexDirection="column">
995          <Text>
996            <Text bold>{money(spent ?? sum.usd ?? 0)}</Text>
997            <Text dimColor> over {sum.turns} turns{live !== undefined ? ` and this one (${money(live)} so far)` : ''} · last {money(turnUsd[turnUsd.length - 1] ?? 0)}</Text>
998            {spent !== undefined && spent - (sum.usd ?? 0) - (live ?? 0) >= 0.01 && <Text dimColor> · {money(spent - (sum.usd ?? 0) - (live ?? 0))} between turns</Text>}
999            {turnUsd.length >= 10 && <Text dimColor> · recent turns avg {money(avg(recent))} vs {money(avg(early))} at the start</Text>}
1000          </Text>
1001          <Text dimColor>Cost per turn</Text>
1002          <Text color="#34D399">{usdSpark}</Text>
1003        </Box>
1004      )}
1005      <Box flexDirection="column">
1006        <Text>
1007          <Text dimColor>Cache misses: </Text>
1008          <Text bold>{misses.length}</Text>
1009          {misses.length > 0 && <Text dimColor> (about +{fmt(missMs)} of waiting, +{money(missUsd)} over cached)</Text>}
1010        </Text>
1011        {misses.slice(-5).map(x => (
1012          <Text key={`m:${x.at}`} dimColor>
1013            {'  '}turn {x.turn} · re-read {tokens(x.fresh)} · waited {fmt(x.ttft)}{x.missUsd !== undefined ? ` · +${money(x.missUsd)}` : ''}
1014          </Text>
1015        ))}
1016        {misses.length > 0 && <Text dimColor>  A miss follows a pause of about 5 minutes or a change early in the conversation; compacting does not prevent it.</Text>}
1017      </Box>
1018      {advice !== undefined && (
1019        <Text>
1020          <Text color="#60A5FA">ⓘ </Text>
1021          {advice}
1022        </Text>
1023      )}
1024    </Box>
1025  )
1026}
1027
1028// ---- Hooks ----
1029
1030const headOf = (text: string) => text.trim().slice(0, 200)
1031
1032// A text block's key: its length and a hash of its head, the same from the stored row and the drawn one.
1033const keyOf = (head: string, length: number) => {
1034  let h = 5381
1035  for (let i = 0; i < head.length; i++) h = ((h << 5) + h + head.charCodeAt(i)) | 0
1036  return `${length}-${(h >>> 0).toString(36)}`
1037}
1038
1039// What a call did, for the card's tooltip: the first of the usual descriptive fields.
1040function detailOf(e: object) {
1041  const input = e as Record<string, unknown>
1042  const raw = ['description', 'command', 'file_path', 'pattern', 'url', 'query', 'prompt']
1043    .map(k => input[k])
1044    .find(v => typeof v === 'string' && v.trim() !== '')
1045  if (typeof raw !== 'string') return undefined
1046  const one = raw.replace(/\s+/g, ' ').trim()
1047  return one.length > 90 ? `${one.slice(0, 89)}…` : one
1048}
1049
1050// The id a background launch's result names its task by.
1051function taskIdOf(result: unknown) {
1052  const m = JSON.stringify(result ?? null).match(/"(?:backgroundTaskId|taskId|task_id|agentId|agent_id|shellId)"\s*:\s*"([^"]+)"/)
1053  return m?.[1]
1054}
1055
1056// Subagent work belongs to the turn that launched it in the background, else to the running turn.
1057function addSub($: EngineInterface, span: SubSpan) {
1058  return update($, turns, list => {
1059    let at = list.findIndex(t => (t.bg ?? []).some(b => b.taskId === span.agentId))
1060    if (at < 0) at = list.length - 1
1061    if (at < 0 || (list[at].endedAt !== undefined && !(list[at].bg ?? []).some(b => b.taskId === span.agentId))) return list
1062    return list.map((t, i) => (i === at ? { ...t, sub: [...(t.sub ?? []), span] } : t))
1063  })
1064}
1065
1066function lanesToggle($: EngineInterface, open: boolean) {
1067  return async () => {
1068    await update($, lanesOpen, () => !open)
1069    await $.store.set('lanes', !open)
1070  }
1071}
1072
1073// What the working set keeps: the running turn, the last finished one, and turns with background work still running.
1074const pruned = (list: readonly TurnRecord[]) => {
1075  const lastDone = [...list].reverse().find(t => t.endedAt !== undefined)
1076  return list.filter(t => t.endedAt === undefined || t === lastDone || (t.bg ?? []).some(b => b.end === undefined))
1077}
1078
1079// Adds one finished turn to the session's totals.
1080const addTotals = (sum: Totals, t: TurnRecord): Totals => {
1081  const v = viewOf(t, [], null, t.endedAt ?? t.startedAt)
1082  const claude = { ...sum.claude }
1083  for (const p of v.phaseMs) claude[p.phase] = (claude[p.phase] ?? 0) + p.ms
1084  const kinds = { ...sum.kinds }
1085  for (const g of v.groups) {
1086    const was = kinds[g.kind.label] ?? { ms: 0, calls: 0, tools: {} }
1087    const tools = { ...was.tools }
1088    for (const [name, n] of Object.entries(g.tools)) tools[name] = (tools[name] ?? 0) + n
1089    kinds[g.kind.label] = { ms: was.ms + g.ms, calls: was.calls + g.calls, tools }
1090  }
1091  return {
1092    turns: sum.turns + 1,
1093    turnMs: sum.turnMs + v.total,
1094    usdBase: sum.usdBase ?? t.usdStart,
1095    usd: (sum.usd ?? 0) + v.cost.total,
1096    turnUsd: [...(sum.turnUsd ?? []), v.cost.total].slice(-200),
1097    claude,
1098    kinds,
1099  }
1100}
1101
1102// Saves a finished turn's card copy and drops the oldest copies past the limit.
1103async function keepCard($: EngineInterface, id: string, record: TurnRecord, limit: number) {
1104  await update($, atom({ ...doneFamily, id }, null), () => record)
1105  const order = [...(await read($, doneOrder)).filter(k => k !== id), id]
1106  const dropped = order.length > limit ? order.slice(0, order.length - limit) : []
1107  for (const old of dropped) await update($, atom({ ...doneFamily, id: old }, null), () => null)
1108  await update($, doneOrder, () => order.slice(dropped.length))
1109}
1110
1111// Removes a card copy (its reply moved it to another text block).
1112async function dropCard($: EngineInterface, id: string) {
1113  await update($, atom({ ...doneFamily, id }, null), () => null)
1114  await update($, doneOrder, order => order.filter(k => k !== id))
1115}
1116
1117function contextOpener($: EngineInterface) {
1118  return () => $.ui.open({ id: CONTEXT_PANE, title: 'Context' })
1119}
1120
1121function viewSetter($: EngineInterface) {
1122  return (v: ViewMode) => async () => {
1123    await update($, mode, () => v)
1124    await $.store.set('mode', v)
1125  }
1126}
1127
1128// The session's cost as /cost totals it, and whether the account is on a subscription.
1129async function ledgerOf($: EngineInterface) {
1130  try {
1131    const u = await $.session.usage()
1132    return { usd: u.cost?.usd, isSub: u.rateLimits.length > 0 }
1133  } catch {
1134    return { usd: undefined, isSub: false }
1135  }
1136}
1137
1138// What a request read first: the tool calls that ended since the previous request.
1139const afterOf = (t: TurnRecord, at: number) => {
1140  // From the previous request's start: the tools it called can finish while it is still streaming.
1141  const since = (t.reqs ?? []).reduce((a, r) => Math.max(a, r.start), t.startedAt)
1142  const counts: Record<string, number> = {}
1143  for (const s of t.spans) if (s.end > since && s.end <= at) counts[shortName(s.tool)] = (counts[shortName(s.tool)] ?? 0) + 1
1144  const out = Object.entries(counts).map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(', ')
1145  return out === '' ? undefined : out
1146}
1147
1148// Adds to the turn still running, if there is one.
1149function onLiveTurn($: EngineInterface, fn: (t: TurnRecord) => TurnRecord) {
1150  return update($, turns, list => {
1151    const at = list.length - 1
1152    if (at < 0 || list[at].endedAt !== undefined) return list
1153    return list.map((t, i) => (i === at ? fn(t) : t))
1154  })
1155}
1156
1157const chunkPhase = (kind: string, was: Phase): Phase =>
1158  kind === 'thinking' ? 'thinking' : kind === 'text' ? 'writing' : kind === 'tool' || kind === 'input' ? 'composing' : was
1159
1160// The live card while a turn runs, or null when there is none to draw.
1161async function liveCardOf($: EngineInterface, e: any, columns: number, isLine = false) {
1162  const list = await read($, turns)
1163  const turn = list[list.length - 1]
1164  const view = await read($, mode)
1165  if (turn === undefined || turn.endedAt !== undefined || view === 'off') return null
1166  const els = $.ui.resolve(e) as unknown as Els
1167  try {
1168    const now = Math.max(await read($, tick), turn.startedAt)
1169    const live = Object.values(await read($, running))
1170    const phase = await read($, step)
1171    const v = viewOf(turn, live, phase, now, Object.values(await read($, subRunning)))
1172    const open = await read($, lanesOpen)
1173    const doing = live.length > 0 ? `Running ${[...new Set(live.map(c => shortName(c.tool)))].join(', ')}` : phase !== null ? phaseOf(phase.phase).label : 'Working'
1174    if (isLine) {
1175      // One line: what fits beside the desktop's spinner, which clips its row to a line.
1176      return (
1177        <els.Text dimColor wrap="truncate-end">
1178          {'  ◷ '}
1179          {fmt(v.total)}
1180          {v.cost.total > 0 ? ` · ${money(v.cost.total)} so far` : ''}
1181          {` · ${doing}`}
1182          {v.bg.some(b => b.isRunning) ? ` · ${v.bg.filter(b => b.isRunning).length} in background` : ''}
1183        </els.Text>
1184      )
1185    }
1186    return cardOf(els, v, view, Math.max(40, columns), viewSetter($), { open, toggle: lanesToggle($, open) }, doing, contextOpener($))
1187  } catch (err) {
1188    return <els.Text color="#F43F5E">time-spent: {String(err)}</els.Text>
1189  }
1190}
1191
1192export const register: Register = (on, options) => {
1193  const setting = String((options as Record<string, unknown>).cardsToKeep ?? '100')
1194  const limit = setting === 'all' ? Number.POSITIVE_INFINITY : Number(setting) > 0 ? Number(setting) : 100
1195  let ticker: { cancel: () => void } | undefined
1196
1197  on('session.start', async ($, e, next) => {
1198    await $.command.register({
1199      name: 'time-spent',
1200      description: 'Time per tool type; `view <mode>` switches the cards',
types/index.d.ts 129 lines
1export type ViewMode = 'timeline' | 'bars' | 'compact' | 'off'
2
3/** `detail` is what the call did, for the tooltip (a command's description, a file). */
4export type ToolSpan = { tool: string; start: number; end: number; detail?: string }
5
6export type RunningCall = { tool: string; start: number; detail?: string }
7
8/** What the model was doing while a request streamed. */
9export type Phase = 'waiting' | 'thinking' | 'writing' | 'composing'
10
11export type PhaseSeg = { phase: Phase; start: number; end: number }
12
13/** A subagent's own work: a model request (`tool: '@model'`) or one of its tool calls. */
14export type SubSpan = { agentId: string; tool: string; start: number; end: number; usd?: number }
15
16/** One main-loop model request and what it cost at list price. */
17export type Req = {
18  start: number
19  end: number
20  model: string
21  /** Estimated from the token counts and the model's list price. */
22  usd: number
23  input: number
24  output: number
25  cacheRead: number
26  cacheWrite: number
27  /** The tool results this request was the first to read, e.g. `Read ×2, Bash`. */
28  after?: string
29}
30
31/** A command or agent launched in the background: from launch until its completion notice. */
32/** `id` is the launching call's tool_use_id; `result` what its notification said beyond the status (`exit code 0`). */
33export type BgTask = { id: string; tool: string; start: number; end?: number; detail?: string; taskId?: string; status?: string; result?: string }
34
35export type TurnRecord = {
36  turnId: string
37  startedAt: number
38  endedAt?: number
39  spans: ToolSpan[]
40  /** The main loop's model requests, split by phase. */
41  model: PhaseSeg[]
42  /** Work done inside subagents during this turn. */
43  sub: SubSpan[]
44  /** Background work this turn launched; it is drawn on this turn's card alone. */
45  bg?: BgTask[]
46  /** The turn's latest text block so far: the finished card is drawn under it. */
47  anchor?: { id: string; head: string; length: number }
48  /** Advice drawn under this turn's card when the session's own data supports it. */
49  nudge?: string
50  /** The main loop's model requests, priced. */
51  reqs?: Req[]
52  /** The session cost ledger (what `/cost` totals) when the turn started. */
53  usdStart?: number
54  /** The ledger's growth over the turn: its exact cost. */
55  usd?: number
56  /** The account is on a subscription: the dollars are API list-price equivalents. */
57  isSub?: boolean
58}
59
60
61/** One main-loop model request: how long until its first token, and the context it carried. */
62export type StepStat = {
63  /** The turn's number in this session, from 1. */
64  turn: number
65  at: number
66  /** From sending the request to its first streamed piece. */
67  ttft: number
68  /** Tokens the request carried: uncached input, cache reads and cache writes. */
69  context: number
70  /** Tokens read fresh rather than from cache (uncached input plus cache writes). */
71  fresh: number
72  /** Most of the context was read fresh: the cache had expired or was invalidated. */
73  isMiss: boolean
74  /** What the request cost at list price. */
75  usd?: number
76  /** On a miss: what it cost over the same request read from cache. */
77  missUsd?: number
78}
79
80/** The session so far, summed as each turn ends, for `/time-spent`. */
81export type Totals = {
82  turns: number
83  turnMs: number
84  /** Dollars over the finished turns (ledger growth). */
85  usd?: number
86  /** The ledger when the first recorded turn started: what the session cost before this mod saw it. */
87  usdBase?: number
88  /** Turn costs in order, the last 200, for the Context pane. */
89  turnUsd?: number[]
90  /** Claude's time per phase. */
91  claude: Record<string, number>
92  /** Each tool type's time, calls and tools by name. */
93  kinds: Record<string, { ms: number; calls: number; tools: Record<string, number> }>
94}
95
96declare module 'claude-code' {
97  interface PluginState {
98    'time-spent': {
99      /**
100       * The working set: the running turn, the last finished one (its reply may land late)
101       * and any turn whose background tasks still run. Finished turns live in `done`.
102       */
103      turns: TurnRecord[]
104      /** The session's totals. */
105      totals: Totals
106      /** The session's main-loop model requests, the last 500, for the Context pane. */
107      steps: StepStat[]
108      /** How the summaries are drawn. */
109      mode: ViewMode
110      /** Whether the cards show their lanes under the strip. */
111      lanes: boolean
112      /** Main-loop tool calls still running, by tool_use_id. */
113      running: Record<string, RunningCall>
114      /** Subagent work still running (model requests and tool calls), drawn up to now. */
115      subRunning: Record<string, { agentId: string; tool: string; start: number }>
116      /** The main loop's model request in flight: its phase now and since when. */
117      step: { phase: Phase; since: number } | null
118      /** Each finished turn, by the key of the text block its card sits under. */
119      done: StateFamily<TurnRecord | null>
120      /** The keys of the cards kept in `done`, oldest first, pruned past the setting. */
121      doneOrder: string[]
122      /** The key of the text block the running turn's live card sits under. */
123      liveKey: string | null
124      /** The clock, ticked each second while a turn runs, so the live view moves. */
125      now: number
126    }
127  }
128}
129