A performance profiler for Claude's turns: a live waterfall of every model request and tool call, how much went to approvals, and the fixes that win the time…

The Performance tab for Claude's turns. When a turn takes four minutes, was it the model thinking, your test suite, or you clicking "Yes" on the same npm test prompt three times? Turn Profiler times every model request and tool call as Claude works. It draws them as a live waterfall and tells you what to change to get the time back.
<sub>The waterfall the desktop pane draws, rendered by the mod's own waterfallSvg from a sample turn. Hover a bar in the app for its tooltip.</sub>
After a long turn, a band above the prompt shows where the time went and the single most useful fix:
⏱ 1m00s ████████████████ model 35% · tools 44% · approvals 21% ▂▅▃█▆ [ Profile ] [ Hide ]
↳ 2 approvals cost 12s. Allowing Bash(npm test:*) would skip them all. [ Copy rule ]
/profile opens the full pane:
↳. The desktop app draws crisp SVG with hover tooltips; the terminal draws theme-colored bars.Agent call, never twice.Bash(npm test:*)), one click to copy. It also counts repeats across the session.Bash npm run build ran 48s, 62% of the turn`./profile export writes the turn as a Chrome Trace Event file. Drop it into ui.perfetto.dev for zoom, flame views and SQL over your agent's turn. Approval waits and time to first token are nested slices.It also does a few things outside the pane:
turn_profile tool covering recent turns and the one in progress. Ask "why was that so slow?" and it answers from measurements rather than guesses.From a Claude Code terminal session (Claude Code 2.1.287 or later, which is when mods arrived):
/plugin install turn-profiler --marketplace brahim1o1/claude-turn-profiler
Answer y to add the marketplace, then pick a scope. A user-scope install also loads in the desktop app's local sessions.
To try it without installing:
git clone https://github.com/brahim1o1/claude-turn-profiler.git
claude --plugin-dir ./claude-turn-profiler
| Command | What it does |
|---|---|
/profile | Opens the pane on the live turn, or on the latest finished turn. ‹ Prev and Next › walk this session's turns. |
/profile export | Saves the shown turn to ~/.claude/turn-profiler/<time>.trace.json and copies the path. |
/profile band | Hides or shows the band above the prompt. |
/profile clear | Forgets this session's profiles. |
Set these in /config or under pluginConfigs in settings:
| Option | Default | |
|---|---|---|
alertAfterSeconds | 60 | Toast and notify when a turn this long finishes. 0 turns it off. |
bandMinSeconds | 10 | Show the band only after turns at least this long, so quick answers stay uncluttered. |
| Measurement | Where it comes from |
|---|---|
| Tool call, wall time | A tool.call hook around next(e): hooks, the permission prompt and the tool together. |
| Tool execution | PostToolUse's duration_ms, which Claude Code reports without the permission prompt or hook time. |
| Approval wait | Wall time minus execution, for calls that raised a PermissionRequest; the rule is that request's own permission_suggestions. |
| Model request | A streaming turn.step hook: start, first chunk (time to first token), end, and output tokens. |
| Turn | turn.start to turn.complete's durationMs. |
The split ranks each instant as approval, then tool, then model, then other, so overlapping work is never counted twice. Everything stays on your machine: the mod makes no network calls, and it writes a file only when you export.
claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .
hooks/model.ts holds the measurements, insights and trace export as pure functions, and hooks/model.test.ts covers them. hooks/register.test.tsx drives whole turns through the real engine: model streams, a permission prompt, PostToolUse, the band and pane on terminal and desktop, export, and the model's tool. tsc resolves the API's types once Claude Code has loaded the mod from a --plugin-dir folder, which lays them in .claude-plugin/types/.
The mods API is early access and may change between Claude Code releases. This mod was built and tested on 2.1.295.
MIT
hooks/register.tsx 382 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Span, Turn } from '../types'
5import {
6 MAX_TURNS,
7 addSpan,
8 breakdown,
9 closeSpans,
10 describeCall,
11 fmtMs,
12 insights,
13 patchSpan,
14 percentages,
15 report,
16 ruleText,
17 sparkline,
18 toTrace,
19} from './model'
20import { BandView, Empty, Legendary, PaneFoot, PaneHead, WaterfallText, waterfallSvg } from './views'
21
22const PANE = 'turn-profiler'
23const TOOL = 'turn_profile'
24const MODEL_TOOL = `mcp__turn-profiler__${TOOL}`
25
26const live = atom({ plugin: 'turn-profiler', key: 'live' } as const, null)
27const turns = atom({ plugin: 'turn-profiler', key: 'turns' } as const, [])
28const viewing = atom({ plugin: 'turn-profiler', key: 'viewing' } as const, null)
29const isBandHidden = atom({ plugin: 'turn-profiler', key: 'isBandHidden' } as const, false)
30const tick = atom({ plugin: 'turn-profiler', key: 'tick' } as const, 0)
31
32type Running = { name: string; input: string; askedAt?: number }
33
34/** Milliseconds since the live turn began, or undefined with no turn running. */
35async function offset($: EngineInterface): Promise<number | undefined> {
36 const turn = await read($, live)
37 if (turn === null) return undefined
38 return (await $.clock.now()) - turn.startedAt
39}
40
41function argsOf(e: object): Record<string, unknown> {
42 const { tool, tool_use_id, agentId, consent, requestMeta, ...rest } = e as Record<string, unknown>
43 return rest
44}
45
46/** A finished span's approval wait: its wall time less what the tool itself ran. */
47function withExec(s: Span, execMs: number): Span {
48 if (s.end === undefined || !s.isAsked) return { ...s, execMs }
49 return { ...s, execMs, waitMs: Math.max(0, s.end - s.start - execMs) }
50}
51
52/** The turn the pane shows, its place among the finished ones, and whether it is live. */
53async function shown($: EngineInterface) {
54 const [now, list, id] = [await read($, live), await read($, turns), await read($, viewing)]
55 if (id !== null) {
56 const index = list.findIndex(t => t.id === id)
57 if (index !== -1) return { turn: list[index]!, index, list, isLive: false }
58 }
59 if (now !== null) return { turn: now, index: list.length, list, isLive: true }
60 return { turn: list[list.length - 1] ?? null, index: list.length - 1, list, isLive: false }
61}
62
63// Calls in flight and the execution times PostToolUse reported, for the
64// live turn; a reload starts them over, which costs one turn's waits at most.
65const running = new Map<string, Running>()
66const execs = new Map<string, number>()
67let seq = 0
68
69async function copy($: EngineInterface, text: string) {
70 const done = await $.ui.copy({ text })
71 $.ui.toast(done.isCopied ? `Copied ${text}` : text)
72}
73
74async function exportTrace($: EngineInterface): Promise<string> {
75 const { turn } = await shown($)
76 if (turn === null) return 'No turn to export yet.'
77 const now = turn.durationMs ?? (await $.clock.now()) - turn.startedAt
78 const home = (await $.env.get('HOME')) ?? '.'
79 const stamp = new Date(turn.startedAt).toISOString().replace(/[:.]/g, '-')
80 const path = `${home}/.claude/turn-profiler/${stamp}.trace.json`
81 try {
82 await $.fs.write(path, JSON.stringify(toTrace(turn, now)))
83 } catch (err) {
84 return `Could not save the trace to ${path}: ${err instanceof Error ? err.message : String(err)}`
85 }
86 const done = await $.ui.copy({ text: path })
87 return `Saved the trace to ${path}${done.isCopied ? ' (path copied)' : ''}. Drop it into https://ui.perfetto.dev to explore it.`
88}
89
90async function recordExec($: EngineInterface, id: string, ms: number | undefined) {
91 if (ms === undefined) return
92 execs.set(id, ms)
93 await update($, live, t => t && patchSpan(t, id, s => withExec(s, ms))).catch(() => undefined)
94}
95
96/** The model's own window on the profile, so "why was that slow?" has an answer. */
97async function answerProfile($: EngineInterface, e: object) {
98 const asked = Number(argsOf(e).turns ?? 3)
99 const count = Number.isFinite(asked) ? Math.min(10, Math.max(1, Math.floor(asked))) : 3
100 const finished = (await read($, turns)).slice(-count)
101 const current = await read($, live)
102 const sections = finished.length > 0 || current === null ? [report(finished, 0)] : []
103 if (current !== null) {
104 sections.push(`The turn in progress, so far:\n${report([current], (await $.clock.now()) - current.startedAt)}`)
105 }
106 const text = sections.join('\n\n')
107 return { result: text, text }
108}
109
110export const register: Register = (on, options) => {
111 const alertMs = Number(options.alertAfterSeconds ?? 60) * 1000
112 const bandMinMs = Number(options.bandMinSeconds ?? 10) * 1000
113
114 on('session.start', async ($, e, next) => {
115 await $.command.register({
116 name: 'profile',
117 description: 'Turn profiler: where each turn went (model, tools, approvals)',
118 argumentHint: '[export | band | clear]',
119 })
120 await $.tool.register({
121 name: TOOL,
122 description:
123 "Timing profile of this session's recent turns: how much went to model requests, tool calls and waiting for the user's approval, the slowest calls, and what would make them faster. Use when the user asks why something was slow or how to speed Claude up.",
124 inputSchema: {
125 type: 'object',
126 properties: { turns: { type: 'number', description: 'How many recent turns to report (default 3).' } },
127 },
128 })
129 $.clock.every(1000, () => {
130 void read($, live).then(t => (t === null ? undefined : update($, tick, n => n + 1)))
131 })
132 return next(e)
133 })
134
135 on('turn.start', async ($, e, next) => {
136 running.clear()
137 execs.clear()
138 const startedAt = await $.clock.now()
139 const prompt = e.text.replace(/\s+/g, ' ').trim()
140 const turn: Turn = {
141 id: e.turnId,
142 prompt: prompt === '' ? '(continuation)' : prompt.length > 100 ? `${prompt.slice(0, 99)}…` : prompt,
143 startedAt,
144 spans: [],
145 }
146 await update($, live, () => turn)
147 await update($, viewing, () => null)
148 return next(e)
149 })
150
151 on('turn.step', async function* ($, e, next) {
152 const t0 = await offset($).catch(() => undefined)
153 if (t0 === undefined) return yield* next(e)
154
155 const id = `m:${e.agentId ?? 'main'}:${e.turnId}:${e.index}`
156 const span: Span = {
157 id,
158 kind: 'model',
159 name: e.model,
160 detail: `#${e.index + 1}`,
161 start: t0,
162 ...(e.agentId !== undefined ? { agentId: e.agentId } : {}),
163 ...(e.effort !== undefined ? { effort: String(e.effort) } : {}),
164 }
165 await update($, live, t => t && addSpan(t, span)).catch(() => undefined)
166
167 const stream = next(e)
168 let first: number | undefined
169 for await (const chunk of stream) {
170 if (first === undefined) first = (await offset($).catch(() => undefined)) ?? t0
171 yield chunk
172 }
173 const result = await stream.result
174 const t1 = (await offset($).catch(() => undefined)) ?? t0
175 await update($, live, t =>
176 t && patchSpan(t, id, s => ({
177 ...s,
178 end: t1,
179 ...(first !== undefined ? { firstTokenMs: first - t0 } : {}),
180 ...(result.usage ? { outTokens: result.usage.output_tokens } : {}),
181 })),
182 ).catch(() => undefined)
183 return result
184 })
185
186 on('tool.call', async ($, e, next) => {
187 if (String(e.tool) === MODEL_TOOL) return answerProfile($, e)
188 const t0 = await offset($).catch(() => undefined)
189 if (t0 === undefined) return next(e)
190
191 const id = e.tool_use_id ?? `t:${++seq}`
192 const input = argsOf(e)
193 running.set(id, { name: e.tool, input: JSON.stringify(input) })
194 const span: Span = {
195 id,
196 kind: 'tool',
197 name: e.tool,
198 detail: describeCall(e.tool, input),
199 start: t0,
200 ...(e.agentId !== undefined ? { agentId: e.agentId } : {}),
201 }
202 await update($, live, t => t && addSpan(t, span)).catch(() => undefined)
203
204 const ran = await next(e)
205
206 const t1 = (await offset($).catch(() => undefined)) ?? t0
207 const call = running.get(id)
208 const exec = execs.get(id)
209 running.delete(id)
210 const isError = ran.deny !== undefined || ran.isError === true
211 await update($, live, t =>
212 t && patchSpan(t, id, s => {
213 const ended: Span = { ...s, end: t1, ...(isError ? { isError: true } : {}) }
214 if (exec !== undefined) return withExec(ended, exec)
215 if (call?.askedAt !== undefined) return { ...ended, waitMs: Math.max(0, t1 - call.askedAt) }
216 return ended
217 }),
218 ).catch(() => undefined)
219 return ran
220 }).catch(($, e, next) => next(e))
221
222 on('classic.PermissionRequest', async ($, e, next) => {
223 const at = await offset($).catch(() => undefined)
224 if (at !== undefined) {
225 const input = JSON.stringify(e.tool_input)
226 const waiting = [...running].filter(([, r]) => r.name === e.tool_name && r.askedAt === undefined)
227 const match = waiting.find(([, r]) => r.input === input) ?? waiting[0]
228 if (match !== undefined) {
229 const [id, call] = match
230 call.askedAt = at
231 const suggestion = e.permission_suggestions?.find(
232 (u): u is Extract<typeof u, { type: 'addRules' }> => u.type === 'addRules' && u.behavior === 'allow',
233 )?.rules[0]
234 await update($, live, t =>
235 t && patchSpan(t, id, s => ({ ...s, isAsked: true, ...(suggestion ? { rule: ruleText(suggestion) } : {}) })),
236 ).catch(() => undefined)
237 }
238 }
239 return next(e)
240 })
241
242 on('classic.PostToolUse', async ($, e, next) => {
243 await recordExec($, e.tool_use_id, e.duration_ms)
244 return next(e)
245 })
246
247 on('classic.PostToolUseFailure', async ($, e, next) => {
248 await recordExec($, e.tool_use_id, e.duration_ms)
249 return next(e)
250 })
251
252 on('turn.complete', async ($, e, next) => {
253 const result = await next(e)
254 if (e.agentId !== undefined) return result
255
256 const turn = await read($, live)
257 if (turn === null) return result
258 const done: Turn = {
259 ...closeSpans(turn, e.durationMs),
260 durationMs: e.durationMs,
261 ...(e.isAborted ? { isAborted: true } : {}),
262 }
263 const history = await read($, turns)
264 await update($, turns, list => [...list, done].slice(-MAX_TURNS))
265 await update($, live, () => null)
266 running.clear()
267 execs.clear()
268
269 if (alertMs > 0 && e.durationMs >= alertMs && !e.isAborted) {
270 const p = percentages(breakdown(done, e.durationMs))
271 const top = insights(done, history, e.durationMs)[0]
272 const line = `Turn done in ${fmtMs(e.durationMs)}: model ${p.model}%, tools ${p.tool}%${p.wait > 0 ? `, approvals ${p.wait}%` : ''}`
273 $.ui.toast(top ? `${line}. ${top.text}` : line)
274 void $.ui.notify(top ? `${line}. ${top.text}` : line, { title: 'Claude finished' }).catch(() => undefined)
275 }
276 return result
277 })
278
279 on('command.run', { command: 'profile' }, async ($, e) => {
280 const arg = e.args.trim().toLowerCase()
281 if (arg === 'export') return { text: await exportTrace($) }
282 if (arg === 'band') {
283 const hidden = await update($, isBandHidden, h => !h)
284 return { text: hidden ? 'The profiler band is hidden. /profile band shows it again.' : 'The profiler band is shown.' }
285 }
286 if (arg === 'clear') {
287 await update($, turns, () => [])
288 await update($, viewing, () => null)
289 return { text: "Cleared this session's turn profiles." }
290 }
291 if (arg !== '') return { text: 'Usage: /profile [export | band | clear]' }
292 await $.ui.open({ id: PANE, title: 'Turn profiler' })
293 return { text: 'Turn profiler opened.' }
294 })
295
296 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
297 if (e.props.hasSurvey || e.props.isWorking || (await read($, isBandHidden))) return next(e)
298 const list = await read($, turns)
299 const last = list[list.length - 1]
300 if (last === undefined || (last.durationMs ?? 0) < bandMinMs) return next(e)
301
302 const now = last.durationMs ?? 0
303 const els = $.ui.resolve(e)
304 return BandView(els, {
305 turn: last,
306 breakdown: breakdown(last, now),
307 insight: insights(last, list.slice(0, -1), now)[0],
308 spark: sparkline(list.slice(-12).map(t => t.durationMs ?? 0)),
309 columns: e.props.bodyColumns,
310 onOpen: () => void $.ui.open({ id: PANE, title: 'Turn profiler' }),
311 onHide: () => void update($, isBandHidden, () => true),
312 onCopy: rule => void copy($, rule),
313 })
314 })
315
316 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
317 const { turn, index, list, isLive } = await shown($)
318 const els = $.ui.resolve(e)
319 if (turn === null) return Empty(els)
320
321 if (isLive) await read($, tick)
322 const now = turn.durationMs ?? (await $.clock.now()) - turn.startedAt
323 const columns = e.props.bodyColumns
324 const rows = Math.max(6, (e.viewport?.rows ?? 40) - 18)
325 const ids = list.map(t => t.id)
326 const go = (id: string | null) => () => void update($, viewing, () => id)
327 const data = {
328 turn,
329 isLive,
330 position: { index, count: list.length },
331 breakdown: breakdown(turn, now),
332 insights: insights(turn, list.slice(0, Math.max(0, index)), now),
333 now,
334 columns,
335 isBandHidden: await read($, isBandHidden),
336 onPrev: go(ids[Math.max(0, index - 1)] ?? null),
337 onNext: go(index + 1 < ids.length ? ids[index + 1]! : null),
338 onLatest: go(null),
339 onExport: () => void exportTrace($).then(text => $.ui.toast(text)),
340 onToggleBand: () => void update($, isBandHidden, h => !h),
341 onCopy: (rule: string) => void copy($, rule),
342 }
343 const { Box } = els
344
345 if (e.surface === 'terminal') {
346 return (
347 <Box flexDirection="column" gap={1}>
348 {PaneHead(els, data)}
349 <Box flexDirection="column">
350 {Legendary(els)}
351 {WaterfallText(els, turn.spans, now, now, columns, rows)}
352 </Box>
353 {PaneFoot(els, data)}
354 </Box>
355 )
356 }
357
358 const { Svg } = $.ui.resolve(e)
359 const width = Math.max(360, Math.min(1200, Math.round(columns * 7.6)))
360 const chart = waterfallSvg(turn.spans, now, now, width, rows + 10)
361 return (
362 <Box flexDirection="column" gap={1}>
363 {PaneHead(els, data)}
364 {turn.spans.length > 0 && (
365 <Box flexDirection="column">
366 {Legendary(els)}
367 <Svg
368 source={chart.source}
369 alt={`Waterfall of ${turn.spans.length} model requests and tool calls over ${fmtMs(now)}`}
370 width={width}
371 height={chart.height}
372 isInteractive
373 />
374 {chart.hidden > 0 && <els.Text dimColor>+{chart.hidden} shorter spans not drawn</els.Text>}
375 </Box>
376 )}
377 {PaneFoot(els, data)}
378 </Box>
379 )
380 })
381}
382hooks/model.ts 381 lines1import type { Breakdown, Category, Insight, Span, Turn } from '../types'
2
3export const MAX_SPANS = 300
4export const MAX_TURNS = 30
5
6const READ_ONLY = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead'])
7const PRIORITY: Record<Category, number> = { other: 0, model: 1, tool: 2, wait: 3 }
8
9export function fmtMs(ms: number): string {
10 const n = Math.max(0, Math.round(ms))
11 if (n < 1000) return `${n}ms`
12 if (n < 9_950) return `${(n / 1000).toFixed(1)}s`
13 const seconds = Math.round(n / 1000)
14 if (seconds < 60) return `${seconds}s`
15 return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`
16}
17
18function oneLine(text: string, max: number): string {
19 const flat = text.replace(/\s+/g, ' ').trim()
20 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
21}
22
23function tail(path: string): string {
24 const parts = path.split('/').filter(Boolean)
25 return parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : path
26}
27
28/** A one-line description of what a tool call was asked to do. */
29export function describeCall(tool: string, input: Readonly<Record<string, unknown>>): string {
30 const str = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
31 switch (tool) {
32 case 'Bash':
33 return oneLine(str('command'), 80)
34 case 'Read':
35 case 'Write':
36 case 'Edit':
37 case 'NotebookEdit':
38 return tail(str('file_path') || str('notebook_path'))
39 case 'Grep':
40 case 'Glob':
41 return oneLine(str('pattern'), 60)
42 case 'WebFetch':
43 return oneLine(str('url').replace(/^https?:\/\//, ''), 60)
44 case 'WebSearch':
45 return oneLine(str('query'), 60)
46 case 'Agent':
47 case 'Task':
48 return oneLine(str('description') || str('subagent_type'), 60)
49 default: {
50 const first = Object.values(input).find(v => typeof v === 'string')
51 return typeof first === 'string' ? oneLine(first, 60) : ''
52 }
53 }
54}
55
56/** `Bash(npm test:*)` from the rule the engine suggested at a permission prompt. */
57export function ruleText(rule: { toolName: string; ruleContent?: string }): string {
58 return rule.ruleContent ? `${rule.toolName}(${rule.ruleContent})` : rule.toolName
59}
60
61export function spanEnd(span: Span, now: number): number {
62 return span.end ?? Math.max(span.start, now)
63}
64
65/** Closes every span still open at `at`, as a turn that ended does. */
66export function closeSpans(turn: Turn, at: number): Turn {
67 return {
68 ...turn,
69 spans: turn.spans.map(s => (s.end === undefined ? { ...s, end: Math.max(s.start, at) } : s)),
70 }
71}
72
73/** Adds a span, keeping the turn under the cap. */
74export function addSpan(turn: Turn, span: Span): Turn {
75 if (turn.spans.length >= MAX_SPANS) {
76 return { ...turn, droppedSpans: (turn.droppedSpans ?? 0) + 1 }
77 }
78 return { ...turn, spans: [...turn.spans, span] }
79}
80
81export function patchSpan(turn: Turn, id: string, patch: (s: Span) => Span): Turn {
82 return { ...turn, spans: turn.spans.map(s => (s.id === id ? patch(s) : s)) }
83}
84
85/**
86 * Where the main loop's wall clock went, each millisecond counted once:
87 * waiting on an approval over a tool running over the model streaming;
88 * whatever none covers (hooks, compaction, local work) is `other`.
89 */
90export function breakdown(turn: Turn, now: number): Breakdown {
91 const total = turn.durationMs ?? now
92 const parts: { from: number; to: number; cat: Category }[] = []
93
94 for (const s of turn.spans) {
95 if (s.agentId !== undefined) continue
96 const end = Math.min(spanEnd(s, now), total)
97 if (s.kind === 'model') {
98 parts.push({ from: s.start, to: end, cat: 'model' })
99 continue
100 }
101 const wait = Math.min(s.waitMs ?? 0, end - s.start)
102 if (wait > 0) parts.push({ from: s.start, to: s.start + wait, cat: 'wait' })
103 parts.push({ from: s.start + wait, to: end, cat: 'tool' })
104 }
105
106 const edges = [...new Set([0, total, ...parts.flatMap(p => [p.from, p.to])])]
107 .filter(t => t >= 0 && t <= total)
108 .sort((a, b) => a - b)
109 const out: Breakdown = { model: 0, tool: 0, wait: 0, other: 0 }
110
111 for (let i = 0; i < edges.length - 1; i++) {
112 const from = edges[i]!
113 const to = edges[i + 1]!
114 let cat: Category = 'other'
115 for (const p of parts) {
116 if (p.from <= from && p.to >= to && PRIORITY[p.cat] > PRIORITY[cat]) cat = p.cat
117 }
118 out[cat] += to - from
119 }
120
121 return out
122}
123
124export function percentages(b: Breakdown): Breakdown {
125 const total = b.model + b.tool + b.wait + b.other
126 if (total <= 0) return { model: 0, tool: 0, wait: 0, other: 0 }
127 return {
128 model: Math.round((b.model / total) * 100),
129 tool: Math.round((b.tool / total) * 100),
130 wait: Math.round((b.wait / total) * 100),
131 other: Math.round((b.other / total) * 100),
132 }
133}
134
135function dur(s: Span, now: number): number {
136 return spanEnd(s, now) - s.start
137}
138
139/** The model requests of the main loop, oldest first, with the tool calls each asked for. */
140export function steps(turn: Turn, now: number): { model: Span; tools: Span[] }[] {
141 const main = turn.spans.filter(s => s.agentId === undefined)
142 const models = main.filter(s => s.kind === 'model').sort((a, b) => a.start - b.start)
143 return models.map((model, i) => {
144 const from = spanEnd(model, now)
145 const until = models[i + 1]?.start ?? Number.POSITIVE_INFINITY
146 const tools = main.filter(s => s.kind === 'tool' && s.start >= from - 1 && s.start < until)
147 return { model, tools }
148 })
149}
150
151/**
152 * What a turn's profile says to do about it, the biggest saving first.
153 * `history` is the session's earlier turns, for patterns across turns.
154 */
155export function insights(turn: Turn, history: readonly Turn[], now: number): Insight[] {
156 const total = turn.durationMs ?? now
157 const tools = turn.spans.filter(s => s.kind === 'tool')
158 const found: Insight[] = []
159
160 // Approvals: time the person spent at permission prompts.
161 const asked = tools.filter(s => s.isAsked)
162 const waited = asked.reduce((sum, s) => sum + (s.waitMs ?? 0), 0)
163 if (asked.length > 0 && waited >= 3000) {
164 const byRule = new Map<string, number>()
165 for (const s of asked) {
166 const key = s.rule ?? s.name
167 byRule.set(key, (byRule.get(key) ?? 0) + 1)
168 }
169 const [rule, count] = [...byRule].sort((a, b) => b[1] - a[1])[0]!
170 const before = history.reduce(
171 (n, t) => n + t.spans.filter(s => s.isAsked && (s.rule ?? s.name) === rule).length,
172 0,
173 )
174 const prompts = asked.length === 1 ? '1 approval' : `${asked.length} approvals`
175 const hasRule = asked.some(s => s.rule === rule)
176 const seen = before > 0 ? ` Asked ${before + count}× this session.` : ''
177 const which = count === asked.length ? (count === 1 ? 'it' : 'them all') : `${count} of them`
178 found.push({
179 kind: 'approvals',
180 text: hasRule
181 ? `${prompts} cost ${fmtMs(waited)}. Allowing ${rule} would skip ${which}.${seen}`
182 : `${prompts} cost ${fmtMs(waited)} (${rule}).${seen}`,
183 savableMs: waited,
184 ...(hasRule ? { rule } : {}),
185 })
186 }
187
188 // Retries: the same call failing again and again.
189 const failures = new Map<string, Span[]>()
190 for (const s of tools) {
191 if (!s.isError) continue
192 const key = `${s.name}\u0000${s.detail}`
193 failures.set(key, [...(failures.get(key) ?? []), s])
194 }
195 const worst = [...failures.values()].sort((a, b) => b.length - a.length)[0]
196 if (worst !== undefined && worst.length >= 2) {
197 const spent = worst.reduce((sum, s) => sum + dur(s, now), 0)
198 const what = worst[0]!.detail ? `${worst[0]!.name} \`${worst[0]!.detail}\`` : worst[0]!.name
199 found.push({
200 kind: 'retries',
201 text: `${what} failed ${worst.length}× (${fmtMs(spent)}): a retry loop, not progress.`,
202 savableMs: spent - dur(worst[0]!, now),
203 })
204 }
205
206 // One call that was most of the turn.
207 const longest = tools
208 .filter(s => s.agentId === undefined)
209 .sort((a, b) => dur(b, now) - dur(a, now))[0]
210 if (longest !== undefined && total > 0) {
211 const ran = longest.execMs ?? dur(longest, now) - (longest.waitMs ?? 0)
212 if (ran >= 10_000 && ran / total >= 0.4) {
213 const what = longest.detail ? `${longest.name} \`${longest.detail}\`` : longest.name
214 found.push({
215 kind: 'dominant',
216 text: `${what} ran ${fmtMs(ran)}, ${Math.round((ran / total) * 100)}% of the turn.`,
217 })
218 }
219 }
220
221 // Slow first tokens: the model, not the tools, is the wait.
222 const models = turn.spans.filter(s => s.kind === 'model' && s.firstTokenMs !== undefined)
223 if (models.length >= 3) {
224 const avg = models.reduce((sum, s) => sum + s.firstTokenMs!, 0) / models.length
225 if (avg >= 4000) {
226 const effort = models.find(s => s.effort !== undefined)?.effort
227 const hint =
228 effort === 'high' || effort === 'xhigh' || effort === 'max'
229 ? ` Effort is ${effort}; a lower effort answers sooner.`
230 : ''
231 found.push({
232 kind: 'first-token',
233 text: `${models.length} model requests waited ${fmtMs(avg)} on average for a first token.${hint}`,
234 })
235 }
236 }
237
238 // Serial reads: one read per model round-trip.
239 const all = steps(turn, now)
240 let run: typeof all = []
241 let best: typeof all = []
242 for (const step of all) {
243 const isSingleRead = step.tools.length === 1 && READ_ONLY.has(step.tools[0]!.name)
244 run = isSingleRead ? [...run, step] : []
245 if (run.length > best.length) best = run
246 }
247 if (best.length >= 4) {
248 const saved = best.slice(1).reduce((sum, s) => sum + dur(s.model, now), 0)
249 found.push({
250 kind: 'serial-reads',
251 text: `${best.length} reads went out one per model request; reading them in parallel saves ~${best.length - 1} round-trips (${fmtMs(saved)}).`,
252 savableMs: saved,
253 })
254 }
255
256 return found.sort((a, b) => (b.savableMs ?? 0) - (a.savableMs ?? 0))
257}
258
259/** Packs spans into lanes so that no two in one lane overlap. */
260export function lanes(spans: readonly Span[], now: number): Map<string, number> {
261 const ends: number[] = []
262 const out = new Map<string, number>()
263 for (const s of [...spans].sort((a, b) => a.start - b.start)) {
264 let lane = ends.findIndex(end => end <= s.start)
265 if (lane === -1) lane = ends.length
266 ends[lane] = spanEnd(s, now)
267 out.set(s.id, lane)
268 }
269 return out
270}
271
272type TraceEvent = Record<string, unknown>
273
274/**
275 * The turn as a Chrome Trace Event file: open it in https://ui.perfetto.dev
276 * or chrome://tracing. One process per loop, the model on thread 1 and
277 * parallel tool calls on threads of their own.
278 */
279export function toTrace(turn: Turn, now: number): { traceEvents: TraceEvent[]; otherData: Record<string, unknown> } {
280 const events: TraceEvent[] = []
281 const loops = [...new Set(turn.spans.map(s => s.agentId ?? ''))]
282 const us = (ms: number) => Math.round(ms * 1000)
283
284 loops.forEach((loop, i) => {
285 const pid = i + 1
286 const spans = turn.spans.filter(s => (s.agentId ?? '') === loop)
287 events.push({ ph: 'M', name: 'process_name', pid, args: { name: loop === '' ? 'main loop' : `subagent ${loop}` } })
288 events.push({ ph: 'M', name: 'thread_name', pid, tid: 1, args: { name: 'model' } })
289 const toolLanes = lanes(spans.filter(s => s.kind === 'tool'), now)
290 for (const lane of new Set(toolLanes.values())) {
291 events.push({ ph: 'M', name: 'thread_name', pid, tid: lane + 2, args: { name: `tools ${lane + 1}` } })
292 }
293
294 for (const s of spans) {
295 const tid = s.kind === 'model' ? 1 : toolLanes.get(s.id)! + 2
296 const end = spanEnd(s, now)
297 events.push({
298 ph: 'X',
299 name: s.detail ? `${s.name}: ${s.detail}` : s.name,
300 cat: s.kind,
301 pid,
302 tid,
303 ts: us(s.start),
304 dur: us(end - s.start),
305 args: {
306 ...(s.execMs !== undefined ? { execMs: s.execMs } : {}),
307 ...(s.waitMs !== undefined ? { approvalWaitMs: s.waitMs } : {}),
308 ...(s.rule !== undefined ? { suggestedRule: s.rule } : {}),
309 ...(s.firstTokenMs !== undefined ? { firstTokenMs: s.firstTokenMs } : {}),
310 ...(s.outTokens !== undefined ? { outputTokens: s.outTokens } : {}),
311 ...(s.effort !== undefined ? { effort: s.effort } : {}),
312 ...(s.isError ? { isError: true } : {}),
313 },
314 })
315 if (s.waitMs !== undefined && s.waitMs > 0) {
316 events.push({ ph: 'X', name: 'waiting for approval', cat: 'wait', pid, tid, ts: us(s.start), dur: us(Math.min(s.waitMs, end - s.start)) })
317 }
318 if (s.firstTokenMs !== undefined) {
319 events.push({ ph: 'X', name: 'time to first token', cat: 'model', pid, tid, ts: us(s.start), dur: us(Math.min(s.firstTokenMs, end - s.start)) })
320 }
321 }
322 })
323
324 return {
325 traceEvents: events,
326 otherData: {
327 turnId: turn.id,
328 prompt: turn.prompt,
329 startedAt: new Date(turn.startedAt).toISOString(),
330 durationMs: turn.durationMs ?? now,
331 generator: 'turn-profiler',
332 },
333 }
334}
335
336const BARS = '▁▂▃▄▅▆▇█'
337
338export function sparkline(values: readonly number[]): string {
339 const max = Math.max(...values, 1)
340 return values.map(v => BARS[Math.min(7, Math.floor((v / max) * 7.999))]).join('')
341}
342
343export type ToolRow = { name: string; calls: number; totalMs: number; maxMs: number; failed: number }
344
345export function toolTable(turn: Turn, now: number): ToolRow[] {
346 const rows = new Map<string, ToolRow>()
347 for (const s of turn.spans) {
348 if (s.kind !== 'tool') continue
349 const row = rows.get(s.name) ?? { name: s.name, calls: 0, totalMs: 0, maxMs: 0, failed: 0 }
350 const d = dur(s, now)
351 rows.set(s.name, {
352 ...row,
353 calls: row.calls + 1,
354 totalMs: row.totalMs + d,
355 maxMs: Math.max(row.maxMs, d),
356 failed: row.failed + (s.isError ? 1 : 0),
357 })
358 }
359 return [...rows.values()].sort((a, b) => b.totalMs - a.totalMs)
360}
361
362/** A plain-text report of recent turns, for the model's own tool. */
363export function report(turns: readonly Turn[], now: number): string {
364 if (turns.length === 0) return 'No turn has been profiled yet in this session.'
365 return turns
366 .map(t => {
367 const b = breakdown(t, now)
368 const p = percentages(b)
369 const lines = [
370 `Turn "${t.prompt}" took ${fmtMs(t.durationMs ?? now)}${t.isAborted ? ' (interrupted)' : ''}: model ${p.model}%, tools ${p.tool}%, waiting for approval ${p.wait}%, other ${p.other}%.`,
371 ]
372 const top = toolTable(t, now).slice(0, 4)
373 if (top.length > 0) {
374 lines.push(`Tools: ${top.map(r => `${r.name} ×${r.calls} ${fmtMs(r.totalMs)}${r.failed ? ` (${r.failed} failed)` : ''}`).join(', ')}.`)
375 }
376 for (const i of insights(t, [], now)) lines.push(`- ${i.text}`)
377 return lines.join('\n')
378 })
379 .join('\n\n')
380}
381hooks/views.tsx 378 lines1import type { Elements } from 'claude-code'
2
3import type { Breakdown, Category, Insight, Span, Turn } from '../types'
4import { fmtMs, percentages, spanEnd, toolTable } from './model'
5
6type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
7
8const ORDER: readonly Category[] = ['model', 'tool', 'wait', 'other']
9const NAME: Record<Category, string> = { model: 'model', tool: 'tools', wait: 'approvals', other: 'other' }
10/** Theme keys, so the colors follow the person's theme. */
11const THEME: Record<Category | 'error', string> = {
12 model: 'merged',
13 tool: 'suggestion',
14 wait: 'warning',
15 other: 'inactive',
16 error: 'error',
17}
18
19/** Splits `width` cells among the categories, largest remainder first. */
20function cells(b: Breakdown, width: number): Record<Category, number> {
21 const total = ORDER.reduce((sum, c) => sum + b[c], 0)
22 const out: Record<Category, number> = { model: 0, tool: 0, wait: 0, other: 0 }
23 if (total <= 0) return out
24 const exact = ORDER.map(c => ({ c, v: (b[c] / total) * width }))
25 for (const { c, v } of exact) out[c] = Math.floor(v)
26 let left = width - ORDER.reduce((sum, c) => sum + out[c], 0)
27 for (const { c } of [...exact].sort((x, y) => (y.v % 1) - (x.v % 1))) {
28 if (left-- <= 0) break
29 out[c] += 1
30 }
31 return out
32}
33
34function StackBar({ Text }: Els, b: Breakdown, width: number) {
35 const n = cells(b, width)
36 return (
37 <Text>
38 {ORDER.filter(c => n[c] > 0).map(c => (
39 <Text color={THEME[c]}>{'█'.repeat(n[c])}</Text>
40 ))}
41 </Text>
42 )
43}
44
45function Legend({ Text }: Els, b: Breakdown, isShort: boolean) {
46 const p = percentages(b)
47 const shown = ORDER.filter(c => p[c] > 0 && (c !== 'other' || !isShort))
48 return (
49 <Text>
50 {shown.map((c, i) => (
51 <Text>
52 {i > 0 ? ' · ' : ''}
53 <Text color={THEME[c]}>{NAME[c]}</Text>
54 <Text dimColor> {p[c]}%</Text>
55 </Text>
56 ))}
57 </Text>
58 )
59}
60
61export type BandData = {
62 turn: Turn
63 breakdown: Breakdown
64 insight: Insight | undefined
65 spark: string
66 columns: number
67 onOpen: () => void
68 onHide: () => void
69 onCopy: (rule: string) => void
70}
71
72export function BandView(els: Els, d: BandData) {
73 const { Box, Text, Button } = els
74 const isNarrow = d.columns < 80
75 return (
76 <Box flexDirection="column">
77 <Box flexDirection="row" gap={1}>
78 <Text bold>⏱ {fmtMs(d.turn.durationMs ?? 0)}</Text>
79 {StackBar(els, d.breakdown, isNarrow ? 10 : 16)}
80 {Legend(els, d.breakdown, isNarrow)}
81 {!isNarrow && d.spark.length > 1 && <Text dimColor>{d.spark}</Text>}
82 <Button key="open" label="Profile" onPress={d.onOpen} />
83 <Button key="hide" label="Hide" onPress={d.onHide} />
84 </Box>
85 {d.insight !== undefined && (
86 <Box flexDirection="row" gap={1}>
87 <Text dimColor wrap="truncate-end">
88 ↳ {d.insight.text}
89 </Text>
90 {d.insight.rule !== undefined && (
91 <Button key="copy-rule" label="Copy rule" onPress={() => d.onCopy(d.insight!.rule!)} />
92 )}
93 </Box>
94 )}
95 </Box>
96 )
97}
98
99/** The spans worth drawing in `rows` rows: every one, or the longest in time order. */
100export function pickRows(spans: readonly Span[], rows: number, now: number): { shown: Span[]; hidden: number } {
101 const sorted = [...spans].sort((a, b) => a.start - b.start)
102 if (sorted.length <= rows) return { shown: sorted, hidden: 0 }
103 const keep = new Set(
104 [...sorted]
105 .sort((a, b) => spanEnd(b, now) - b.start - (spanEnd(a, now) - a.start))
106 .slice(0, rows)
107 .map(s => s.id),
108 )
109 return { shown: sorted.filter(s => keep.has(s.id)), hidden: sorted.length - keep.size }
110}
111
112function label(s: Span): string {
113 const who = s.agentId !== undefined ? '↳ ' : ''
114 if (s.kind === 'model') return `${who}model ${s.detail}`
115 return `${who}${s.name}${s.detail ? ` ${s.detail}` : ''}`
116}
117
118function cut(text: string, max: number): string {
119 return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text
120}
121
122function tooltip(s: Span, now: number): string {
123 const total = spanEnd(s, now) - s.start
124 const parts = [`${s.kind === 'model' ? `model request · ${s.name}` : s.name}${s.detail ? ` · ${s.detail}` : ''}`]
125 parts.push(`${fmtMs(total)}${s.end === undefined ? ' so far' : ''}`)
126 if (s.waitMs) parts.push(`${fmtMs(s.waitMs)} waiting for approval`)
127 if (s.execMs !== undefined) parts.push(`${fmtMs(s.execMs)} running`)
128 if (s.firstTokenMs !== undefined) parts.push(`${fmtMs(s.firstTokenMs)} to first token`)
129 if (s.outTokens !== undefined) parts.push(`${s.outTokens} output tokens`)
130 if (s.rule) parts.push(`suggested rule: ${s.rule}`)
131 if (s.isError) parts.push('failed')
132 return parts.join('\n')
133}
134
135/** Each cell of a span's bar: what it was doing then. */
136function barCells(s: Span, total: number, width: number, now: number): (Category | 'error' | 'first' | null)[] {
137 const row: (Category | 'error' | 'first' | null)[] = Array(width).fill(null)
138 if (total <= 0) return row
139 const at = (t: number) => Math.min(width - 1, Math.max(0, Math.floor((t / total) * width)))
140 const from = at(s.start)
141 const to = Math.max(from, at(spanEnd(s, now) - 1))
142 const wait = s.waitMs ? at(s.start + s.waitMs) : -1
143 const first = s.firstTokenMs !== undefined ? at(s.start + s.firstTokenMs) : -1
144 for (let i = from; i <= to; i++) {
145 if (s.kind === 'model') row[i] = i < first ? 'first' : 'model'
146 else if (i < wait) row[i] = 'wait'
147 else row[i] = s.isError ? 'error' : 'tool'
148 }
149 return row
150}
151
152export function WaterfallText(els: Els, spans: readonly Span[], total: number, now: number, columns: number, rows: number) {
153 const { Box, Text } = els
154 const { shown, hidden } = pickRows(spans, rows, now)
155 const labelWidth = Math.min(28, Math.max(12, Math.floor(columns * 0.3)))
156 const barWidth = Math.max(10, columns - labelWidth - 9)
157 return (
158 <Box flexDirection="column">
159 {shown.map(s => {
160 const row = barCells(s, total, barWidth, now)
161 const runs: { kind: (typeof row)[number]; n: number }[] = []
162 for (const kind of row) {
163 const last = runs[runs.length - 1]
164 if (last !== undefined && last.kind === kind) last.n += 1
165 else runs.push({ kind, n: 1 })
166 }
167 return (
168 <Box flexDirection="row">
169 <Box width={labelWidth}>
170 <Text dimColor={s.kind === 'model'} wrap="truncate-end">
171 {cut(label(s), labelWidth - 1)}
172 </Text>
173 </Box>
174 <Text>
175 {runs.map(r =>
176 r.kind === null ? (
177 ' '.repeat(r.n)
178 ) : (
179 <Text color={THEME[r.kind === 'first' ? 'model' : r.kind]} dimColor={r.kind === 'first'}>
180 {(r.kind === 'first' ? '░' : '█').repeat(r.n)}
181 </Text>
182 ),
183 )}
184 </Text>
185 <Text dimColor>
186 {' '}
187 {fmtMs(spanEnd(s, now) - s.start).padStart(6)}
188 {s.end === undefined ? '…' : ''}
189 </Text>
190 </Box>
191 )
192 })}
193 {hidden > 0 && <Text dimColor>+{hidden} shorter spans not drawn</Text>}
194 </Box>
195 )
196}
197
198function esc(text: string): string {
199 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
200}
201
202const STEPS = [100, 200, 500, 1000, 2000, 5000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000, 600_000, 1_800_000]
203
204/** A waterfall as an SVG document, each bar with a hover tooltip. */
205export function waterfallSvg(spans: readonly Span[], total: number, now: number, width: number, rows: number): { source: string; height: number; hidden: number } {
206 const { shown, hidden } = pickRows(spans, rows, now)
207 const labelWidth = Math.round(Math.min(260, width * 0.34))
208 const durWidth = 58
209 const barX = labelWidth + 8
210 const barWidth = Math.max(40, width - barX - durWidth - 6)
211 const rowHeight = 20
212 const top = 22
213 const height = top + shown.length * rowHeight + 6
214 const span = Math.max(total, 1)
215 const x = (t: number) => barX + (Math.min(Math.max(t, 0), span) / span) * barWidth
216 const step = STEPS.find(s => span / s <= 6) ?? 3_600_000
217 const out: string[] = []
218
219 out.push(
220 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
221 '<style>',
222 'text{font:11px -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;fill:#5f6368}',
223 '.v{font-variant-numeric:tabular-nums}.grid{stroke:#8b929a;stroke-opacity:.22}',
224 '.row:hover .bg{fill-opacity:.12}.bg{fill:#8b929a;fill-opacity:0}',
225 '@media (prefers-color-scheme:dark){text{fill:#bdc1c6}}',
226 '</style>',
227 )
228 for (let t = 0; t <= span + 1; t += step) {
229 out.push(`<line class="grid" x1="${x(t)}" x2="${x(t)}" y1="${top - 4}" y2="${height - 4}"/>`)
230 out.push(`<text class="v" x="${x(t)}" y="12" text-anchor="${t === 0 ? 'start' : 'middle'}">${t === 0 ? '0' : fmtMs(t)}</text>`)
231 }
232 shown.forEach((s, i) => {
233 const y = top + i * rowHeight
234 const end = spanEnd(s, now)
235 const x0 = x(s.start)
236 const w = Math.max(2, x(end) - x0)
237 const running = s.end === undefined ? ' fill-opacity="0.55"' : ''
238 out.push(`<g class="row"><title>${esc(tooltip(s, now))}</title>`)
239 out.push(`<rect class="bg" x="0" y="${y}" width="${width}" height="${rowHeight}"/>`)
240 out.push(`<text x="4" y="${y + 14}">${esc(cut(label(s), Math.floor(labelWidth / 6.3)))}</text>`)
241 if (s.kind === 'model') {
242 out.push(`<rect x="${x0}" y="${y + 4}" width="${w}" height="12" rx="2" fill="#8b5cf6"${running}/>`)
243 if (s.firstTokenMs !== undefined) {
244 const fw = Math.min(w, x(s.start + s.firstTokenMs) - x0)
245 out.push(`<rect x="${x0}" y="${y + 4}" width="${fw}" height="12" rx="2" fill="#c4b5fd"/>`)
246 }
247 } else {
248 out.push(`<rect x="${x0}" y="${y + 4}" width="${w}" height="12" rx="2" fill="${s.isError ? '#ef4444' : '#3b82f6'}"${running}/>`)
249 if (s.waitMs) {
250 const ww = Math.min(w, x(s.start + s.waitMs) - x0)
251 out.push(`<rect x="${x0}" y="${y + 4}" width="${ww}" height="12" rx="2" fill="#f59e0b"/>`)
252 }
253 }
254 out.push(`<text class="v" x="${width - 4}" y="${y + 14}" text-anchor="end">${fmtMs(end - s.start)}${s.end === undefined ? '…' : ''}</text></g>`)
255 })
256 out.push('</svg>')
257 return { source: out.join(''), height, hidden }
258}
259
260export type PaneData = {
261 turn: Turn | null
262 isLive: boolean
263 position: { index: number; count: number }
264 breakdown: Breakdown
265 insights: readonly Insight[]
266 now: number
267 columns: number
268 isBandHidden: boolean
269 onPrev: () => void
270 onNext: () => void
271 onLatest: () => void
272 onExport: () => void
273 onToggleBand: () => void
274 onCopy: (rule: string) => void
275}
276
277export function PaneHead(els: Els, d: PaneData) {
278 const { Box, Text, Button } = els
279 const t = d.turn!
280 const total = t.durationMs ?? d.now
281 const models = t.spans.filter(s => s.kind === 'model').length
282 const tools = t.spans.filter(s => s.kind === 'tool').length
283 const out = t.spans.reduce((sum, s) => sum + (s.outTokens ?? 0), 0)
284 return (
285 <Box flexDirection="column" gap={1}>
286 <Box flexDirection="row" gap={1}>
287 <Text bold color={d.isLive ? 'success' : undefined}>
288 {d.isLive ? '● live' : `turn ${d.position.index + 1}/${d.position.count}`}
289 </Text>
290 <Text dimColor wrap="truncate-end">
291 “{t.prompt}”
292 </Text>
293 <Button key="prev" label="‹ Prev" onPress={d.onPrev} />
294 <Button key="next" label="Next ›" onPress={d.onNext} />
295 <Button key="latest" label="Latest" onPress={d.onLatest} />
296 </Box>
297 <Box flexDirection="column">
298 <Text>
299 <Text bold>{fmtMs(total)}</Text>
300 {d.isLive ? <Text dimColor> so far</Text> : ''}
301 {t.isAborted ? <Text color="warning"> interrupted</Text> : ''}
302 <Text dimColor>
303 {' '}
304 · {models} model request{models === 1 ? '' : 's'} · {tools} tool call{tools === 1 ? '' : 's'}
305 {out > 0 ? ` · ${out.toLocaleString('en-US')} output tokens` : ''}
306 {t.droppedSpans ? ` · ${t.droppedSpans} spans over the cap` : ''}
307 </Text>
308 </Text>
309 <Box flexDirection="row" gap={1}>
310 {StackBar(els, d.breakdown, Math.min(32, Math.max(12, Math.floor(d.columns / 3))))}
311 {Legend(els, d.breakdown, false)}
312 </Box>
313 </Box>
314 {d.insights.length > 0 && (
315 <Box flexDirection="column">
316 {d.insights.slice(0, 4).map((i, n) => (
317 <Box flexDirection="row" gap={1}>
318 <Text color="warning">▲</Text>
319 <Text wrap="wrap">{i.text}</Text>
320 {i.rule !== undefined && (
321 <Button key={`copy-${n}`} label="Copy rule" onPress={() => d.onCopy(i.rule!)} />
322 )}
323 </Box>
324 ))}
325 </Box>
326 )}
327 </Box>
328 )
329}
330
331export function PaneFoot(els: Els, d: PaneData) {
332 const { Box, Text, Button } = els
333 const rows = toolTable(d.turn!, d.now).slice(0, 6)
334 return (
335 <Box flexDirection="column" gap={1}>
336 {rows.length > 0 && (
337 <Box flexDirection="column">
338 <Box flexDirection="row">
339 <Box width={16}><Text dimColor>tool</Text></Box>
340 <Box width={8}><Text dimColor>calls</Text></Box>
341 <Box width={10}><Text dimColor>total</Text></Box>
342 <Box width={10}><Text dimColor>slowest</Text></Box>
343 <Text dimColor>failed</Text>
344 </Box>
345 {rows.map(r => (
346 <Box flexDirection="row">
347 <Box width={16}><Text wrap="truncate-end">{r.name}</Text></Box>
348 <Box width={8}><Text>{r.calls}</Text></Box>
349 <Box width={10}><Text>{fmtMs(r.totalMs)}</Text></Box>
350 <Box width={10}><Text>{fmtMs(r.maxMs)}</Text></Box>
351 <Text color={r.failed > 0 ? 'error' : undefined} dimColor={r.failed === 0}>{r.failed}</Text>
352 </Box>
353 ))}
354 </Box>
355 )}
356 <Box flexDirection="row" gap={1}>
357 <Button key="export" label="Export trace" onPress={d.onExport} />
358 <Button key="band" label={d.isBandHidden ? 'Show band' : 'Hide band'} onPress={d.onToggleBand} />
359 <Text dimColor>Export opens in ui.perfetto.dev</Text>
360 </Box>
361 </Box>
362 )
363}
364
365export function Legendary(els: Els) {
366 const { Text } = els
367 return (
368 <Text dimColor>
369 <Text color={THEME.model}>█</Text> model <Text color={THEME.model} dimColor>░</Text> to first token{' '}
370 <Text color={THEME.tool}>█</Text> tool <Text color={THEME.wait}>█</Text> approval <Text color={THEME.error}>█</Text> failed
371 </Text>
372 )
373}
374
375export function Empty({ Text }: Els) {
376 return <Text dimColor>No turn profiled yet. Send a prompt and this pane fills in while Claude works.</Text>
377}
378types/index.d.ts 76 lines1/** One timed piece of a turn: a model request or a tool call. */
2export type Span = {
3 /** The tool_use_id for a tool call; `m:<loop>:<step>` for a model request. */
4 id: string
5 kind: 'model' | 'tool'
6 /** The tool's name, or the model's. */
7 name: string
8 /** A one-line summary: the command, the file, the pattern. */
9 detail: string
10 /** The subagent's loop it ran in; absent on the main loop. */
11 agentId?: string
12 /** Milliseconds from the turn's start. */
13 start: number
14 /** Milliseconds from the turn's start; absent while it runs. */
15 end?: number
16 /** Tool calls: time the tool itself ran (PostToolUse's duration_ms). */
17 execMs?: number
18 /** Tool calls: time spent waiting on a permission prompt. */
19 waitMs?: number
20 /** Tool calls: whether the person was asked to approve it. */
21 isAsked?: boolean
22 /** Tool calls: the allow rule the engine suggested at the prompt. */
23 rule?: string
24 /** Model requests: time to the first streamed chunk. */
25 firstTokenMs?: number
26 /** Model requests: the effort level the request asked for. */
27 effort?: string
28 /** Model requests: output tokens. */
29 outTokens?: number
30 isError?: boolean
31}
32
33export type Turn = {
34 id: string
35 /** The prompt, cut to one line. */
36 prompt: string
37 /** Epoch milliseconds. */
38 startedAt: number
39 /** Wall-clock length; absent while the turn runs. */
40 durationMs?: number
41 isAborted?: boolean
42 spans: Span[]
43 /** Spans left out once a turn passes the cap. */
44 droppedSpans?: number
45}
46
47export type Category = 'model' | 'tool' | 'wait' | 'other'
48
49export type Breakdown = Record<Category, number>
50
51export type Insight = {
52 kind: 'approvals' | 'retries' | 'dominant' | 'first-token' | 'serial-reads'
53 /** One line, already worded for the person. */
54 text: string
55 /** Milliseconds the insight says could be saved, when it can say. */
56 savableMs?: number
57 /** An allow rule to copy, for an approvals insight. */
58 rule?: string
59}
60
61declare module 'claude-code' {
62 interface PluginState {
63 'turn-profiler': {
64 /** The main loop's turn while it runs. */
65 live: Turn | null
66 /** Finished turns, oldest first, the newest 40 kept. */
67 turns: Turn[]
68 /** The turn the pane shows: its id, or null for the newest. */
69 viewing: string | null
70 isBandHidden: boolean
71 /** Bumped every second while a turn runs, so running spans redraw. */
72 tick: number
73 }
74 }
75}
76