Splits each Claude Code turn's time into Claude, tools, you and other, and prints a /blame receipt for the session. Nothing leaves the machine. Claude Code…

A Claude Code mod that answers one question after every turn: was Claude slow, or was it your npm test?
It splits each turn's time by cause and shows it under the answer: in the terminal as a coloured line in place of Claude Code's "Worked for" line, elsewhere as a dim log line. /blame opens a panel with the whole session's split as a coloured bar. Claude never reads any of it, and nothing leaves your machine.

A real run on Claude Code 2.1.302, in a small demo project whose test suite takes about 13 seconds. The waits are shown at 6× speed, as the clip says. Of the 47 seconds, Claude took 15 and npm test took 27. Watch it as a video.
The same two outputs in plain text, from an earlier live session. This is what you get outside the terminal, or where nothing can draw a panel:
⏺ time-blame: 26s · Claude 8.0s (7.2s before output) · sleep 4.7s · you 12s · other 0.3s
⏺ time-blame: this session, this machine · 1 turn · 26s of turn time
⏺ time-blame: Claude 8.0s 31% before output 7.2s, writing 0.8s
⏺ time-blame: tools 4.7s 19% sleep 4.7s
⏺ time-blame: you 12s 49% approvals 12s
⏺ time-blame: other 0.3s 1% not attributed: hooks, local work, gaps
⏺ time-blame: longest turn 26s. Times are summed waits measured in this session, not server speed.
Both blocks are copied as text from that session, where a permission dialog was left open for 12 seconds before it was approved.
claude plugin marketplace add karanb192/time-blame
claude plugin install time-blame@time-blame
Then restart Claude Code or run /reload-plugins. Mods are on by default from Claude Code 2.1.287; time-blame needs 2.1.302 or later. To try it for one session without installing, clone the repo and run claude --plugin-dir ./time-blame.
Landing page: https://timeblame.aidojo.si/
Every millisecond of a turn lands in exactly one bucket, so the parts always add up to the total.
| Bucket | Measured as | When two overlap |
|---|---|---|
| you | A permission dialog, from the moment it opens to the moment the tool starts. Also the whole of an AskUserQuestion or ExitPlanMode call. | Wins over everything |
| tools | The tool's own execution time, which Claude Code reports with each result. Named by tool, and by command for Bash. | Wins over Claude |
| Claude, before output | From the moment a model request is sent to its first text, thinking or tool-call chunk. Includes queueing, retries and network. | |
| Claude, writing | From that first chunk to the end of the request. | |
| other | Whatever is left: hooks, local work, the gaps between steps. Always shown, never hidden. |
Two or more tools running at once count as parallel tools, rather than blaming whichever one was slowest to return.
A subagent's time sits inside the Agent call that started it, labelled subagent. Its own steps are not counted again.
The line appears for turns of 3 seconds or more. /blame counts every turn.
npm, pytest, cargo, git, make and others) and their common subcommands (npm test, cargo build) are shown. A script name, a path or anything else shows as Bash, because a command line can carry private names. In a chained command such as git stash && npm test, the test or build step names the call, since that is where the time usually goes.PermissionRequest and PostToolUse settings-hook events. Where an organization manages Claude Code, those events skip plugins a person installs. The dialog wait then falls into the tool's time./blame panel draws in all three./clear, /resume and when the plugin reloads.Validated on Claude Code 2.1.302:
❯ ./register.ts hooks: session.start, command.run{command=blame}, ui.render{component=Pane}, ui.render{component=TurnDuration}, session.end, turn.start, turn.step, classic.PermissionRequest, classic.PostToolUse, classic.PostToolUseFailure, tool.call, turn.complete ❯ ./register.ts calls: $.command.register, $.session.surfaces, $.ui.invalidate, $.ui.log, $.ui.open, $.ui.resolve
Reach L0, draws and remembers. It sees every tool call and every model request, as timing only. It draws in two places: the line that closes a turn in the terminal, and its own /blame panel.
Threat model for time-blame (reach L0, draws and remembers)
If a hook throws, Claude Code skips it and the tool call or request runs as it would without the mod.
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
hooks/ledger.ts holds the accounting and the labels as pure functions; hooks/register.ts only collects timestamps. For type checking, run /plugin-types in a session in this folder, then npx tsc -p .. The generated types are git-ignored.
MIT. The landing page uses icons from Lucide (ISC) and the GitHub mark from Simple Icons (CC0).
hooks/register.ts 233 lines1import type { Register } from 'claude-code'
2import { add, bucketTimes, cells, COLORS, emptyTotals, fmt, receipt, receiptModel, split, toolLabel, turnLine, turnSegments } from './ledger'
3import type { Span, Totals, TurnReport } from './ledger'
4
5const MIN_LINE_MS = 3000
6const PANE = 'time-blame'
7const LINE_CELLS = 10
8
9// Tools whose whole call is a person deciding, so their time is yours.
10const USER_TOOLS: Record<string, string> = { AskUserQuestion: 'questions', ExitPlanMode: 'plan approval' }
11
12type Open = { start: number; first: number; kind: 'step' } | { start: number; label: string; tool: string; kind: 'call' }
13
14type Turn = {
15 id: string
16 start: number
17 spans: Span[]
18 open: Set<Open>
19}
20
21let turn: Turn | null = null
22let totals: Totals = emptyTotals()
23let paneOpened = false
24// Reports by the turn's durationMs: the TurnDuration site carries the same
25// number, which is how its redraw finds the turn it closes.
26// A report waits here by duration until its closing line first draws, then
27// moves to that line's message id, so it draws at one place only. A duration
28// already waiting maps to null and the newer turn gets a log line instead.
29const recent = new Map<number, TurnReport | null>()
30const byMessage = new Map<string, TurnReport>()
31const KEEP = 1000
32
33function remember<K, V>(map: Map<K, V>, key: K, value: V) {
34 map.set(key, value)
35 if (map.size > KEEP) map.delete(map.keys().next().value as K)
36}
37const execs = new Map<string, { start: number; end: number }>()
38const asks: Array<{ at: number; tool: string; claimed: boolean }> = []
39
40function finish(t: Turn, end: number, durationMs: number, aborted: boolean) {
41 for (const o of t.open) {
42 if (o.kind === 'step') {
43 if (o.first) t.spans.push({ kind: 'waiting', start: o.start, end: o.first, label: '' }, { kind: 'writing', start: o.first, end, label: '' })
44 else t.spans.push({ kind: 'waiting', start: o.start, end, label: '' })
45 } else t.spans.push({ kind: USER_TOOLS[o.tool] ? 'you' : 'tool', start: o.start, end, label: USER_TOOLS[o.tool] || o.label })
46 }
47 t.open.clear()
48 const start = Math.min(t.start, end - durationMs)
49 return split(t.spans, start, end, aborted)
50}
51
52export const register: Register = on => {
53 on('session.start', async ($, e, next) => {
54 const r = await next(e)
55 try {
56 await $.command.register({ name: 'blame', description: 'Where this session\'s time went: Claude, tools, you' })
57 } catch {}
58 return r
59 })
60
61 on('command.run', { command: 'blame' }, async $ => {
62 const surfaces = await $.session.surfaces()
63 if (surfaces.length === 0) {
64 for (const line of receipt(totals)) $.ui.log(line)
65 return {}
66 }
67 const opened = await $.ui.open({ id: PANE, title: 'time-blame', focus: true, closeOnEscape: true })
68 if (opened.isPlaced) paneOpened = true
69 else for (const line of receipt(totals)) $.ui.log(line)
70 return {}
71 })
72
73 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
74 if (e.requestId !== PANE) return next(e)
75 const { Box, Text } = $.ui.resolve(e)
76 const m = receiptModel(totals)
77 if (!m) return Box({ flexDirection: 'column', children: [Text({ children: ['No finished turns yet in this session.'] }), Text({ dimColor: true, children: ['Esc closes this pane.'] })] })
78 const width = Math.max(10, Math.min(60, (e.props.bodyColumns || 40) - 2))
79 const bar = Text({ children: cells(m.times, width).map(b => Text({ color: COLORS[b], children: ['█'] })) })
80 return Box({
81 flexDirection: 'column',
82 children: [
83 Text({ children: [m.header] }),
84 Text({ children: [' '] }),
85 bar,
86 Text({ children: [' '] }),
87 ...m.rows.flatMap(r => [
88 Text({ children: [Text({ color: COLORS[r.bucket], children: ['██ '] }), r.name.padEnd(8), Text({ weight: 'bold', children: [fmt(r.ms).padStart(7)] }), (r.pct + '%').padStart(6)] }),
89 ...(r.note ? [Text({ dimColor: true, children: [' ' + r.note] })] : []),
90 ]),
91 Text({ children: [' '] }),
92 Text({ dimColor: true, children: ['Summed waits in this session, not server speed. Esc closes.'] }),
93 ],
94 })
95 })
96
97 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
98 let r = byMessage.get(e.requestId)
99 if (!r) {
100 const match = recent.get(e.props.durationMs)
101 if (!match) return next(e)
102 r = match
103 recent.delete(e.props.durationMs)
104 remember(byMessage, e.requestId, r)
105 }
106 const { Box, Text } = $.ui.resolve(e)
107 const line = Text({
108 children: [
109 ...cells(bucketTimes(r), LINE_CELLS).map(b => Text({ color: COLORS[b], children: ['█'] })),
110 ' ',
111 Text({ dimColor: true, children: ['time-blame '] }),
112 ...turnSegments(r).map(seg =>
113 seg.bucket ? Text({ color: COLORS[seg.bucket], children: [seg.text] }) : seg.strong ? Text({ weight: 'bold', children: [seg.text] }) : seg.text,
114 ),
115 ],
116 })
117 return Box({ marginTop: 1, children: [line] })
118 })
119
120 on('session.end', async ($, e, next) => {
121 if (e.reason === 'clear' || e.reason === 'resume') {
122 totals = emptyTotals()
123 turn = null
124 recent.clear()
125 byMessage.clear()
126 }
127 return next(e)
128 })
129
130 on('turn.start', async ($, e, next) => {
131 if (e.agentId === undefined) {
132 turn = { id: e.turnId, start: Date.now(), spans: [], open: new Set() }
133 execs.clear()
134 asks.length = 0
135 }
136 return next(e)
137 })
138
139 on('turn.step', async function* ($, e, next) {
140 const t = e.agentId === undefined && turn && turn.id === e.turnId ? turn : null
141 if (!t) return yield* next(e)
142 const o: Open = { kind: 'step', start: Date.now(), first: 0 }
143 t.open.add(o)
144 const gen = next(e)
145 let done = false
146 try {
147 while (true) {
148 const step = await gen.next()
149 if (step.done) { done = true; return step.value }
150 const kind = step.value.kind
151 if (!o.first && (kind === 'text' || kind === 'thinking' || kind === 'tool')) o.first = Date.now()
152 yield step.value
153 }
154 } finally {
155 if (!done) await gen.return(undefined as never)
156 if (t.open.delete(o)) {
157 const end = Date.now()
158 if (o.first) t.spans.push({ kind: 'waiting', start: o.start, end: o.first, label: '' }, { kind: 'writing', start: o.first, end, label: '' })
159 else t.spans.push({ kind: 'waiting', start: o.start, end, label: '' })
160 }
161 }
162 })
163
164 on('classic.PermissionRequest', async ($, e, next) => {
165 const r = await next(e)
166 if (turn) asks.push({ at: Date.now(), tool: e.tool_name, claimed: false })
167 return r
168 })
169
170 on('classic.PostToolUse', async ($, e, next) => {
171 const r = await next(e)
172 if (typeof e.duration_ms === 'number') { const end = Date.now(); execs.set(e.tool_use_id, { start: end - e.duration_ms, end }) }
173 return r
174 })
175
176 on('classic.PostToolUseFailure', async ($, e, next) => {
177 const r = await next(e)
178 if (typeof e.duration_ms === 'number') { const end = Date.now(); execs.set(e.tool_use_id, { start: end - e.duration_ms, end }) }
179 return r
180 })
181
182 on('tool.call', async ($, e, next) => {
183 const t = turn
184 if (!t) return next(e)
185 const o: Open = { kind: 'call', start: Date.now(), tool: e.tool, label: toolLabel(e.tool, e as { command?: unknown }) }
186 const sub = e.agentId !== undefined
187 if (!sub) t.open.add(o)
188 try {
189 return await next(e)
190 } finally {
191 const end = Date.now()
192 const exec = execs.get(e.tool_use_id)
193 execs.delete(e.tool_use_id)
194 const ask = USER_TOOLS[e.tool] ? undefined : asks.find(a => !a.claimed && a.tool === e.tool && a.at >= o.start && a.at <= end)
195 if (ask) {
196 ask.claimed = true
197 t.spans.push({ kind: 'you', start: ask.at, end: exec ? exec.start : end, label: 'approvals' })
198 }
199 if (!sub && t.open.delete(o)) {
200 if (USER_TOOLS[e.tool]) t.spans.push({ kind: 'you', start: o.start, end, label: USER_TOOLS[e.tool] as string })
201 else if (exec) t.spans.push({ kind: 'tool', start: exec.start, end: exec.end, label: o.label })
202 else t.spans.push({ kind: 'tool', start: ask ? end : o.start, end, label: o.label })
203 }
204 }
205 })
206
207 on('turn.complete', async ($, e, next) => {
208 const r = await next(e)
209 if (e.agentId === undefined && turn && turn.id === e.turnId) {
210 const report = finish(turn, Date.now(), e.durationMs, e.isAborted)
211 turn = null
212 totals = add(totals, report)
213 if (paneOpened) $.ui.invalidate('ui.render')
214 if (report.wall >= MIN_LINE_MS) {
215 // A terminal-only session draws the line in place of "Worked for";
216 // anywhere else, and for an interrupted turn, it is a log line.
217 const surfaces = await $.session.surfaces()
218 const terminalOnly = surfaces.length > 0 && surfaces.every(s => s === 'terminal')
219 if (terminalOnly && !report.aborted) {
220 if (recent.has(e.durationMs)) {
221 recent.set(e.durationMs, null)
222 $.ui.log(turnLine(report))
223 } else {
224 remember(recent, e.durationMs, report)
225 $.ui.invalidate('ui.render')
226 }
227 } else $.ui.log(turnLine(report))
228 }
229 }
230 return r
231 })
232}
233hooks/ledger.ts 261 lines1export type Kind = 'you' | 'tool' | 'writing' | 'waiting'
2
3export type Span = { kind: Kind; start: number; end: number; label: string }
4
5export type TurnReport = {
6 wall: number
7 aborted: boolean
8 waiting: number
9 writing: number
10 you: number
11 other: number
12 tools: Record<string, number>
13 youBy: Record<string, number>
14}
15
16// When spans overlap, the earlier kind in this list owns the time. A tool
17// started while Claude is still streaming is the tool's time; a dialog open
18// while a tool is running is yours.
19const PRIORITY: Kind[] = ['you', 'tool', 'writing', 'waiting']
20
21export const PARALLEL = 'parallel tools'
22
23export function split(spans: Span[], start: number, end: number, aborted = false): TurnReport {
24 const report: TurnReport = { wall: Math.max(0, end - start), aborted, waiting: 0, writing: 0, you: 0, other: 0, tools: {}, youBy: {} }
25 const clipped = spans
26 .map(s => ({ ...s, start: Math.max(s.start, start), end: Math.min(s.end, end) }))
27 .filter(s => s.end > s.start)
28 const cuts = new Set<number>([start, end])
29 for (const s of clipped) { cuts.add(s.start); cuts.add(s.end) }
30 const points = [...cuts].sort((a, b) => a - b)
31 for (let i = 0; i + 1 < points.length; i++) {
32 const a = points[i] as number, b = points[i + 1] as number, len = b - a
33 const over = clipped.filter(s => s.start <= a && s.end >= b)
34 const kind = PRIORITY.find(k => over.some(s => s.kind === k))
35 if (!kind) { report.other += len; continue }
36 if (kind === 'tool') {
37 const labels = new Set(over.filter(s => s.kind === 'tool').map(s => s.label))
38 const label = labels.size === 1 ? [...labels][0] as string : PARALLEL
39 report.tools[label] = (report.tools[label] || 0) + len
40 } else if (kind === 'you') {
41 const label = over.find(s => s.kind === 'you')!.label
42 report.you += len
43 report.youBy[label] = (report.youBy[label] || 0) + len
44 } else report[kind] += len
45 }
46 return report
47}
48
49export function toolTotal(r: { tools: Record<string, number> }): number {
50 return Object.values(r.tools).reduce((a, b) => a + b, 0)
51}
52
53export function fmt(ms: number): string {
54 if (ms < 10_000) return (Math.round(ms / 100) / 10).toFixed(1) + 's'
55 const s = Math.round(ms / 1000)
56 if (s < 60) return s + 's'
57 if (s < 3600) return Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's'
58 return Math.floor(s / 3600) + 'h' + String(Math.floor((s % 3600) / 60)).padStart(2, '0') + 'm'
59}
60
61function ranked(m: Record<string, number>): Array<[string, number]> {
62 return Object.entries(m).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1])
63}
64
65export type Bucket = 'claude' | 'tools' | 'you' | 'other'
66
67export const COLORS: Record<Bucket, string> = { claude: '#d97757', tools: '#6a9bcc', you: '#788c5d', other: '#b0aea5' }
68
69export type Segment = { text: string; bucket?: Bucket; strong?: boolean }
70
71// The turn line as pieces, so a surface that draws colour can tint each
72// bucket's name while the plain line stays the same text.
73export function turnSegments(r: TurnReport): Segment[] {
74 const out: Segment[] = [{ text: fmt(r.wall), strong: true }]
75 const sep = () => out.push({ text: ' · ' })
76 const claude = r.waiting + r.writing
77 if (claude > 0) {
78 sep()
79 out.push({ text: 'Claude', bucket: 'claude' }, { text: ' ' }, { text: fmt(claude), strong: true })
80 if (r.waiting > 0) out.push({ text: ' (' + fmt(r.waiting) + ' before output)' })
81 }
82 const tools = ranked(r.tools)
83 const only = tools.length === 1 ? tools[0] : undefined
84 if (only) {
85 sep()
86 out.push({ text: only[0], bucket: 'tools' }, { text: ' ' }, { text: fmt(only[1]), strong: true })
87 } else if (tools.length > 1) {
88 const top = tools.slice(0, 2).map(([k, v]) => k + ' ' + fmt(v)).join(', ')
89 sep()
90 out.push({ text: 'tools', bucket: 'tools' }, { text: ' ' }, { text: fmt(toolTotal(r)), strong: true }, { text: ' (' + top + (tools.length > 2 ? ', +' + (tools.length - 2) : '') + ')' })
91 }
92 if (r.you > 0) {
93 sep()
94 out.push({ text: 'you', bucket: 'you' }, { text: ' ' }, { text: fmt(r.you), strong: true })
95 }
96 sep()
97 out.push({ text: 'other', bucket: 'other' }, { text: ' ' }, { text: fmt(r.other), strong: true })
98 if (r.aborted) out.push({ text: ' · interrupted' })
99 return out
100}
101
102export function turnLine(r: TurnReport): string {
103 return turnSegments(r).map(s => s.text).join('')
104}
105
106export function bucketTimes(r: TurnReport): Record<Bucket, number> {
107 return { claude: r.waiting + r.writing, tools: toolTotal(r), you: r.you, other: r.other }
108}
109
110// Splits `width` cells between the buckets in proportion to their time, by
111// largest remainder. A bucket with time but no cell then takes one from the
112// biggest bucket, so a short wait stays visible without skewing the rest.
113export function cells(times: Record<Bucket, number>, width: number): Bucket[] {
114 const order: Bucket[] = ['claude', 'tools', 'you', 'other']
115 const total = order.reduce((a, k) => a + times[k], 0)
116 if (total <= 0 || width <= 0) return []
117 const live = order.filter(k => times[k] > 0)
118 const exact = live.map(k => ({ k, share: (times[k] / total) * width }))
119 const counts = new Map<Bucket, number>(exact.map(({ k, share }) => [k, Math.floor(share)]))
120 let left = width - exact.reduce((a, { share }) => a + Math.floor(share), 0)
121 for (const { k } of [...exact].sort((x, y) => (y.share % 1) - (x.share % 1))) {
122 if (left <= 0) break
123 counts.set(k, (counts.get(k) as number) + 1)
124 left--
125 }
126 for (const k of live) {
127 if ((counts.get(k) as number) > 0) continue
128 const donor = live.reduce((a, b) => ((counts.get(b) as number) > (counts.get(a) as number) ? b : a))
129 if ((counts.get(donor) as number) <= 1) break
130 counts.set(donor, (counts.get(donor) as number) - 1)
131 counts.set(k, 1)
132 }
133 return live.flatMap(k => Array<Bucket>(counts.get(k) as number).fill(k))
134}
135
136export type Totals = TurnReport & { turns: number; interrupted: number; longest: number }
137
138export function emptyTotals(): Totals {
139 return { wall: 0, aborted: false, waiting: 0, writing: 0, you: 0, other: 0, tools: {}, youBy: {}, turns: 0, interrupted: 0, longest: 0 }
140}
141
142export function add(t: Totals, r: TurnReport): Totals {
143 const sum = (a: Record<string, number>, b: Record<string, number>) => {
144 const out = { ...a }
145 for (const [k, v] of Object.entries(b)) out[k] = (out[k] || 0) + v
146 return out
147 }
148 return {
149 ...t,
150 wall: t.wall + r.wall,
151 waiting: t.waiting + r.waiting,
152 writing: t.writing + r.writing,
153 you: t.you + r.you,
154 other: t.other + r.other,
155 tools: sum(t.tools, r.tools),
156 youBy: sum(t.youBy, r.youBy),
157 turns: t.turns + 1,
158 interrupted: t.interrupted + (r.aborted ? 1 : 0),
159 longest: Math.max(t.longest, r.wall),
160 }
161}
162
163export type ReceiptRow = { bucket: Bucket; name: string; ms: number; pct: number; note: string }
164
165export type ReceiptModel = { header: string; rows: ReceiptRow[]; times: Record<Bucket, number>; footer: string }
166
167export function receiptModel(t: Totals): ReceiptModel | null {
168 if (t.turns === 0) return null
169 const pct = (ms: number) => (t.wall > 0 ? Math.round((ms / t.wall) * 100) : 0)
170 const tools = ranked(t.tools)
171 const toolNote = tools.slice(0, 3).map(([k, v]) => k + ' ' + fmt(v)).join(', ') + (tools.length > 3 ? ', +' + (tools.length - 3) + ' more' : '')
172 const youNote = ranked(t.youBy).map(([k, v]) => k + ' ' + fmt(v)).join(', ')
173 const times = bucketTimes(t)
174 return {
175 header: 'this session, this machine · ' + t.turns + ' turn' + (t.turns === 1 ? '' : 's') + ' · ' + fmt(t.wall) + ' of turn time' + (t.interrupted ? ' · ' + t.interrupted + ' interrupted' : ''),
176 rows: [
177 { bucket: 'claude', name: 'Claude', ms: times.claude, pct: pct(times.claude), note: 'before output ' + fmt(t.waiting) + ', writing ' + fmt(t.writing) },
178 { bucket: 'tools', name: 'tools', ms: times.tools, pct: pct(times.tools), note: toolNote },
179 { bucket: 'you', name: 'you', ms: times.you, pct: pct(times.you), note: youNote },
180 { bucket: 'other', name: 'other', ms: times.other, pct: pct(times.other), note: 'not attributed: hooks, local work, gaps' },
181 ],
182 times,
183 footer: 'longest turn ' + fmt(t.longest) + '. Times are summed waits measured in this session, not server speed.',
184 }
185}
186
187export function receiptRow(r: ReceiptRow): string {
188 return r.name.padEnd(8) + fmt(r.ms).padStart(7) + (r.pct + '%').padStart(6) + (r.note ? ' ' + r.note : '')
189}
190
191export function receipt(t: Totals): string[] {
192 const m = receiptModel(t)
193 if (!m) return ['No finished turns yet in this session.']
194 return [m.header, ...m.rows.map(receiptRow), m.footer]
195}
196
197const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun'])
198const PM_SUBCOMMANDS = new Set(['test', 'install', 'i', 'ci', 'build', 'lint', 'dev', 'start', 'typecheck', 'format', 'add', 'exec'])
199const SCRIPT_NAMES = new Set(['test', 'build', 'lint', 'dev', 'start', 'typecheck', 'format', 'check', 'e2e'])
200const SUBCOMMANDS: Record<string, Set<string>> = {
201 cargo: new Set(['test', 'build', 'run', 'check', 'clippy', 'fmt', 'bench']),
202 go: new Set(['test', 'build', 'run', 'vet', 'mod', 'generate']),
203 git: new Set(['status', 'diff', 'log', 'commit', 'push', 'pull', 'fetch', 'clone', 'checkout', 'switch', 'rebase', 'merge', 'add', 'show', 'stash', 'grep']),
204 docker: new Set(['build', 'run', 'compose', 'pull', 'push']),
205 gh: new Set(['pr', 'issue', 'run', 'api', 'repo']),
206 dotnet: new Set(['test', 'build', 'run', 'restore']),
207 swift: new Set(['test', 'build', 'run']),
208}
209const PROGRAMS = new Set([
210 'npm', 'pnpm', 'yarn', 'bun', 'npx', 'node', 'deno', 'tsc', 'jest', 'vitest', 'eslint', 'prettier', 'playwright',
211 'python', 'python3', 'pip', 'pip3', 'uv', 'pytest', 'ruff', 'mypy', 'poetry',
212 'cargo', 'rustc', 'go', 'make', 'cmake', 'ninja', 'gradle', 'gradlew', 'mvn', 'java', 'dotnet', 'swift', 'xcodebuild',
213 'git', 'gh', 'docker', 'kubectl', 'terraform', 'curl', 'wget',
214 'ls', 'cat', 'grep', 'rg', 'find', 'sed', 'awk', 'head', 'tail', 'wc', 'sleep', 'echo', 'mkdir', 'rm', 'cp', 'mv', 'chmod', 'tar', 'jq', 'expr',
215])
216
217// A chained command runs several programs; the one that ran tests or a build
218// is almost always where the time went, so it names the call over the others.
219const RUNNERS = /^(?:(?:npm|pnpm|yarn|bun) (?:test|run (?:test|build|e2e|typecheck)|build|e2e|typecheck)|npx|node --test|pytest|tsc|jest|vitest|playwright|cargo (?:test|build|bench)|go (?:test|build)|make|cmake|ninja|gradle|gradlew|mvn|dotnet (?:test|build)|swift (?:test|build)|xcodebuild)$/
220
221// Only a program on PROGRAMS, and only a subcommand on its own list, is ever
222// shown: a script name or path can be private, so anything else reads "Bash".
223export function bashLabel(command: unknown): string {
224 if (typeof command !== 'string') return 'Bash'
225 const labels = command
226 .split(/&&|\|\||;|\n/)
227 .map(part => labelOne((part.split('|')[0] as string).trim()))
228 .filter((label): label is string => label !== null)
229 return labels.find(label => RUNNERS.test(label)) || labels.find(label => label !== 'Bash') || 'Bash'
230}
231
232function labelOne(segment: string): string | null {
233 const words = segment.replace(/[()]/g, ' ').split(/\s+/).filter(Boolean)
234 let i = 0
235 while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i] as string) || words[i] === 'time' || words[i] === 'env')) i++
236 const raw = words[i]
237 if (!raw || raw === 'cd') return null
238 const program = raw.replace(/^\.\//, '').split('/').pop() as string
239 if (!PROGRAMS.has(program)) return 'Bash'
240 const next = words[i + 1]
241 const after = words[i + 2]
242 if ((program === 'python' || program === 'python3') && next === '-m' && after === 'pytest') return 'pytest'
243 if (program === 'node' && next === '--test') return 'node --test'
244 if (PACKAGE_MANAGERS.has(program) && next) {
245 if (next === 'run' && after && SCRIPT_NAMES.has(after)) return program + ' run ' + after
246 if (PM_SUBCOMMANDS.has(next)) return program + ' ' + next
247 if (program !== 'npm' && SCRIPT_NAMES.has(next)) return program + ' ' + next
248 return program
249 }
250 const subs = SUBCOMMANDS[program]
251 if (subs && next && subs.has(next)) return program + ' ' + next
252 return program
253}
254
255export function toolLabel(tool: string, input: { command?: unknown }): string {
256 if (tool === 'Bash') return bashLabel(input.command)
257 if (tool.startsWith('mcp__')) return 'MCP'
258 if (tool === 'Agent' || tool === 'Task') return 'subagent'
259 return tool
260}
261