SLOPSHOPPER

turn-profiler

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…

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-12brahim1o1/claude-turn-profiler
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-profiler
│ ┃ Turn profiler ✕ › fix the failing auth test and add an audit log call │ ┃ No turn profiled yet. Send a prompt and this │ ┃ pane fills in while Claude works. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /profile │ ⎿ turn-profiler: Turn profiler opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Turn profiler
No turn profiled yet. Send a prompt and this pane fills in while Claude works.
README

Turn Profiler for Claude Code

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.

A turn's waterfall: model requests in violet (lighter while waiting for the first token), tool calls in blue, time spent at approval prompts in amber, failed calls in red

<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>

What it tells you

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:

  • A live waterfall. Every model request and tool call appears as it starts and grows while it runs. Parallel calls overlap, and a subagent's own requests and calls are marked ↳. The desktop app draws crisp SVG with hover tooltips; the terminal draws theme-colored bars.
  • An honest split of the wall clock. Model, tools, approvals, and other, with each millisecond counted once. Parallel calls count as wall time rather than a sum, and a subagent's work is counted inside its Agent call, never twice.
  • Insights that name the fix, the biggest saving first:
  • Approvals: the time you spent at permission prompts, and the exact allow rule Claude Code itself suggested (Bash(npm test:*)), one click to copy. It also counts repeats across the session.
  • Retry loops: the same command failing again and again.
  • One call that ate the turn: `Bash npm run build ran 48s, 62% of the turn`.
  • Slow first tokens: when the wait is the model rather than your tools, with the effort level if it is set high.
  • Serial reads: files read one per model round-trip, and the time parallel reads would save.
  • A per-tool table: calls, total time, slowest, and failures.
  • Perfetto export. /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:

  • A long-turn alert. When a turn over a minute finishes, you get a toast and a desktop notification saying where the time went, which helps when you've switched to another window.
  • Claude can read its own profile. The mod gives Claude a 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.

Install

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

Use

CommandWhat it does
/profileOpens the pane on the live turn, or on the latest finished turn. ‹ Prev and Next › walk this session's turns.
/profile exportSaves the shown turn to ~/.claude/turn-profiler/<time>.trace.json and copies the path.
/profile bandHides or shows the band above the prompt.
/profile clearForgets this session's profiles.

Options

Set these in /config or under pluginConfigs in settings:

OptionDefault
alertAfterSeconds60Toast and notify when a turn this long finishes. 0 turns it off.
bandMinSeconds10Show the band only after turns at least this long, so quick answers stay uncluttered.

How it measures

MeasurementWhere it comes from
Tool call, wall timeA tool.call hook around next(e): hooks, the permission prompt and the tool together.
Tool executionPostToolUse's duration_ms, which Claude Code reports without the permission prompt or hook time.
Approval waitWall time minus execution, for calls that raised a PermissionRequest; the rule is that request's own permission_suggestions.
Model requestA streaming turn.step hook: start, first chunk (time to first token), end, and output tokens.
Turnturn.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.

Develop

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.

License

MIT

Source 4 files
hooks/register.tsx 382 lines
1import { 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}
382
hooks/model.ts 381 lines
1import 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}
381
hooks/views.tsx 378 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
378
types/index.d.ts 76 lines
1/** 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