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

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.
completed (exit code 0), failed (exit code 1))/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./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/compact or a new sessiontimeline, 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.
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.
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./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' viewhooks/register.tsx: the hooks moduletypes/index.d.ts: the state contract.claude-plugin/plugin.json: the manifestMIT: see LICENSE.
hooks/register.tsx 1583 lines1import { 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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 lines1export 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