Draws the current turn as a timeline in a pane (/timeline): model requests and tool calls per loop, where the time went, and the slowest steps

A pane that draws the current turn as a timeline, one lane per loop.
<img src="../docs/turn-timeline.png" alt="The turn-timeline pane with a timeline, time breakdown and slowest steps" width="520">
claude plugin marketplace add arasovic/claude-code-mods
claude plugin install turn-timeline@claude-code-mods
Restart Claude Code and type /timeline. It follows the running turn and keeps the last one after it ends.
claude plugin validate .
claude plugin test .
../typecheck.sh turn-timelinehooks/register.tsx 362 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TimelineSpan, TimelineTurn } from '../types'
5
6const PANE = 'timeline'
7const LABEL_W = 10
8// Spans kept per turn; a long agentic turn keeps its latest ones.
9const KEEP = 400
10
11const turn = atom({ plugin: 'turn-timeline', key: 'turn' } as const, null as TimelineTurn | null)
12
13// Subagent ids never change type, so one lookup per id is enough.
14const agentTypes = new Map<string, string>()
15
16export const clock = (ms: number) => {
17 const s = Math.max(0, ms / 1000)
18 if (s < 10) return `${s.toFixed(1)}s`
19 if (s < 60) return `${Math.round(s)}s`
20 const r = Math.round(s)
21 return r % 60 ? `${Math.floor(r / 60)}m${r % 60}s` : `${r / 60}m`
22}
23
24// What a tool call is about, in a few words: the command, the file's name, the pattern, the URL.
25export const target = (input: Record<string, unknown>) => {
26 for (const key of ['command', 'file_path', 'path', 'pattern', 'url', 'description', 'query', 'prompt', 'skill']) {
27 const value = input[key]
28 if (typeof value !== 'string' || !value) continue
29 const text = key === 'file_path' || key === 'path' ? (value.split('/').pop() ?? value) : value
30 return text.replace(/\s+/g, ' ').trim()
31 }
32 return ''
33}
34
35const live = (s: TimelineSpan, now: number) => s.end ?? now
36
37// One lane's cells: a tool call wins a cell over a model request, and any overlap counts, so a short call still shows.
38export const laneCells = (spans: readonly TimelineSpan[], start: number, end: number, cols: number, now: number) => {
39 const step = Math.max(1, end - start) / cols
40 return Array.from({ length: cols }, (_, i) => {
41 const t0 = start + i * step
42 const t1 = t0 + step
43 const hits = spans.filter(s => s.start < t1 && live(s, now) > t0)
44 const tool = hits.find(s => s.kind === 'tool')
45 if (tool) return tool.ok === false ? 'failed' : 'tool'
46 return hits.length ? 'model' : 'idle'
47 })
48}
49
50// Where the main loop's wall-clock went, sampled: at each instant the oldest running tool, else a model request, else idle.
51export const breakdown = (spans: readonly TimelineSpan[], start: number, end: number, now: number, samples = 600) => {
52 const totals = new Map<string, number>()
53 const step = Math.max(1, end - start) / samples
54 for (let i = 0; i < samples; i++) {
55 const t = start + (i + 0.5) * step
56 const running = spans.filter(s => s.start <= t && live(s, now) > t)
57 const tool = running.filter(s => s.kind === 'tool').sort((a, b) => a.start - b.start)[0]
58 const key = tool ? tool.name : running.length ? 'model' : 'idle'
59 totals.set(key, (totals.get(key) ?? 0) + step)
60 }
61 return [...totals].map(([name, ms]) => ({ name, ms, pct: (ms / Math.max(1, end - start)) * 100 })).sort((a, b) => b.ms - a.ms)
62}
63
64// An axis row with a few round labels, each left-aligned at its column and never overlapping the last.
65export const axis = (duration: number, cols: number) => {
66 const STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600].map(s => s * 1000)
67 const step = STEPS.find(s => duration / s <= 3) ?? 3_600_000
68 let row = ''
69 for (let t = 0; t <= duration; t += step) {
70 const col = Math.round((t / Math.max(1, duration)) * (cols - 1))
71 const label = t === 0 ? '0' : clock(t)
72 if (col < row.length + 1 && row.length) continue
73 if (col + label.length > cols) break
74 row = row.padEnd(col) + label
75 }
76 return row
77}
78
79const agentLabel = async ($: EngineInterface, id: string) => {
80 // The engine's own forks (compaction, memory) carry ids no list names, so they are never cached.
81 const type = agentTypes.get(id) ?? (await $.agent.list()).find(a => a.id === id)?.type
82 if (type) agentTypes.set(id, type)
83 return type ?? 'fork'
84}
85
86// Opens a span on its loop's lane, adding the lane on first sight; returns the span id to close it with.
87const open = async ($: EngineInterface, agentId: string | undefined, kind: TimelineSpan['kind'], name: string, detail: string) => {
88 const at = await $.clock.now()
89 const lane = agentId ?? 'main'
90 const label = agentId ? await agentLabel($, agentId) : 'main'
91 const id = `${lane}:${kind}:${at}:${Math.random().toString(36).slice(2, 6)}`
92 await update($, turn, t => {
93 const base = t ?? { start: at, lanes: [{ id: 'main', label: 'main' }], spans: [] }
94 const same = base.lanes.filter(l => l.label === label || l.label.startsWith(`${label} `)).length
95 const lanes = base.lanes.some(l => l.id === lane) ? base.lanes : [...base.lanes, { id: lane, label: same ? `${label} ${same + 1}` : label }]
96 return { ...base, lanes, spans: [...base.spans, { id, lane, kind, name, detail, start: at }].slice(-KEEP) }
97 })
98 return id
99}
100
101const close = async ($: EngineInterface, id: string, ok?: boolean) => {
102 const end = await $.clock.now()
103 await update($, turn, t => (t ? { ...t, spans: t.spans.map(s => (s.id === id ? { ...s, end, ok } : s)) } : t))
104}
105
106// Rows are runs of styled text. The terminal draws them on a grid of cells, so frames pad and cut them exactly.
107// Other surfaces draw text in a proportional font, where spaces line nothing up: there a row is a flex line.
108// `fill` stretches, `w` is a column that many characters wide (`right` aligns it), and a Bar draws `parts`
109// as real bars in place of its `cells`.
110type Seg = { t: string; c?: string; d?: boolean; b?: boolean; fill?: boolean; w?: number; right?: boolean }
111type Bar = { cells: Seg[]; parts: { n: number; c?: string }[] }
112type Row = (Seg | Bar)[]
113
114// The empty part of a bar where the surface draws real bars.
115const TRACK = 'userMessageBackgroundHover'
116
117const segs = (row: Row): Seg[] => row.flatMap(s => ('t' in s ? [s] : s.cells))
118
119const width = (row: Row) => segs(row).reduce((n, s) => n + s.t.length, 0)
120
121// Consecutive cells of one colour as one part of a real bar.
122const runs = (cells: readonly Seg[]) =>
123 cells.reduce<{ n: number; c?: string }[]>((out, s) => {
124 const last = out.at(-1)
125 if (last && last.c === s.c) last.n += s.t.length
126 else out.push({ n: s.t.length, c: s.c })
127 return out
128 }, [])
129
130const fit = (row: Row, w: number): Seg[] => {
131 const out: Seg[] = []
132 let n = 0
133 for (const s of segs(row)) {
134 const room = w - n
135 if (room <= 0) break
136 if (s.t.length <= room) {
137 out.push(s)
138 n += s.t.length
139 } else {
140 out.push({ ...s, t: `${s.t.slice(0, room - 1)}…` })
141 n = w
142 }
143 }
144 if (n < w) out.push({ t: ' '.repeat(w - n) })
145 return out
146}
147
148const spread = (left: Row, right: Row, w: number): Row => [...left, { t: ' '.repeat(Math.max(1, w - width(left) - width(right))), fill: true }, ...right]
149
150const CELL: Record<ReturnType<typeof laneCells>[number], Seg> = {
151 tool: { t: '█', c: 'success' },
152 failed: { t: '█', c: 'error' },
153 model: { t: '▒', c: 'suggestion' },
154 idle: { t: '·', d: true },
155}
156
157export const register: Register = on => {
158 on('session.start', async ($, e, next) => {
159 await $.command.register({ name: 'timeline', description: 'Show the current turn as a timeline of model requests and tool calls' })
160 return next(e)
161 })
162
163 on('command.run', { command: 'timeline' }, async $ => {
164 const opened = await $.ui.open({ id: PANE, title: 'Timeline' })
165 return { text: opened.isPlaced ? 'Timeline pane opened.' : `Timeline pane is waiting: ${opened.reason}` }
166 })
167
168 on('turn.start', async ($, e, next) => {
169 const at = await $.clock.now()
170 await update($, turn, () => ({ start: at, lanes: [{ id: 'main', label: 'main' }], spans: [] }))
171 return next(e)
172 })
173
174 on('turn.complete', async ($, e, next) => {
175 if (e.agentId === undefined) {
176 const at = await $.clock.now()
177 await update($, turn, t => (t ? { ...t, end: at } : t))
178 }
179 return next(e)
180 })
181
182 on('tool.call', async ($, e, next) => {
183 const id = await open($, e.agentId, 'tool', e.tool.startsWith('mcp__') ? (e.tool.split('__').at(-1) ?? e.tool) : e.tool, target(e as unknown as Record<string, unknown>))
184 let ok = false
185 try {
186 const result = await next(e)
187 ok = result.deny === undefined && !result.isError
188 return result
189 } finally {
190 await close($, id, ok)
191 }
192 })
193
194 on('turn.step', async function* ($, e, next) {
195 let id: string | undefined
196 try {
197 id = await open($, e.agentId, 'model', 'model', '')
198 } catch {
199 // The timeline is a convenience: a failed write must never block the model's request.
200 }
201 try {
202 return yield* next(e)
203 } finally {
204 if (id) await close($, id).catch(() => undefined)
205 }
206 })
207
208 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
209 const { Box, Text } = $.ui.resolve(e)
210 const t = await read($, turn)
211 const now = await $.clock.now()
212 const w = Math.max(30, e.props.bodyColumns - 1)
213 const inner = w - 6
214 const isGrid = e.surface === 'terminal'
215 if (!t) return <Text dimColor>The timeline starts with the next turn.</Text>
216
217 const draw = (row: Row, width: number) =>
218 fit(row, width).map((s, j) => <Text key={String(j)} color={s.c} dimColor={s.d} bold={s.b}>{s.t}</Text>)
219 // A row as a flex line, for surfaces that draw text in a proportional font.
220 const items = (row: Row) =>
221 row.map((s, j) => {
222 if ('cells' in s) {
223 const total = Math.max(1, s.parts.reduce((n, p) => n + p.n, 0))
224 return <Box flexGrow={1} height={0.5}>{s.parts.map(p => <Box flexGrow={(p.n / total) * 1000} backgroundColor={p.c ?? TRACK} />)}</Box>
225 }
226 if (s.fill) return <Box flexGrow={1} />
227 const text = <Text key={String(j)} color={s.c} dimColor={s.d} bold={s.b} wrap="truncate-end">{!s.w ? s.t : s.right ? s.t.trim() : s.t.trimEnd()}</Text>
228 return s.w ? <Box width={s.w} flexShrink={0} justifyContent={s.right ? 'flex-end' : 'flex-start'}>{text}</Box> : text
229 })
230 const line = (row: Row) => (row.length ? <Box alignItems="center">{items(row)}</Box> : <Box height={0.5} />)
231 const frame = (title: string, tone: string, rows: Row[], right = '') => {
232 if (!isGrid)
233 return (
234 <Box flexDirection="column" marginTop={2} borderStyle="round" borderColor={tone} borderDimColor>
235 <Box marginBottom={1}>
236 <Text color={tone} bold>{title}</Text>
237 <Box flexGrow={1} />
238 {right ? <Text dimColor>{right}</Text> : null}
239 </Box>
240 {rows.map(line)}
241 </Box>
242 )
243 const rule = Math.max(0, w - 6 - title.length - (right ? right.length + 2 : 0))
244 return (
245 <Box key={title} flexDirection="column" marginTop={1}>
246 <Text>
247 <Text color={tone} dimColor>╭─ </Text>
248 <Text color={tone} bold>{title}</Text>
249 <Text color={tone} dimColor> {'─'.repeat(rule)}</Text>
250 {right ? <Text dimColor> {right} </Text> : null}
251 <Text color={tone} dimColor>─╮</Text>
252 </Text>
253 {rows.map((row, i) => (
254 <Text key={String(i)}>
255 <Text color={tone} dimColor>│ </Text>
256 {draw(row, inner)}
257 <Text color={tone} dimColor> │</Text>
258 </Text>
259 ))}
260 <Text color={tone} dimColor>╰{'─'.repeat(w - 2)}╯</Text>
261 </Box>
262 )
263 }
264
265 // A background subagent outlives the main turn, so the timeline runs until its last step ends too.
266 const end = !t.end || t.spans.some(s => s.end === undefined) ? now : Math.max(t.end, ...t.spans.map(s => s.end ?? 0))
267 const duration = end - t.start
268 const cols = inner - LABEL_W
269 const main = t.spans.filter(s => s.lane === 'main')
270 const parts = breakdown(main, t.start, end, now)
271 const pct = (name: string) => Math.round(parts.find(p => p.name === name)?.pct ?? 0)
272 const toolPct = Math.max(0, 100 - pct('model') - pct('idle'))
273
274 const glance: Row = [
275 { t: t.end && end !== now ? 'Last turn ' : 'Running ', d: true },
276 { t: clock(duration), b: true },
277 { t: ' model ', d: true },
278 { t: `${pct('model')}%`, c: 'suggestion', b: true },
279 { t: ' tools ', d: true },
280 { t: `${toolPct}%`, c: 'success', b: true },
281 { t: ' idle ', d: true },
282 { t: `${pct('idle')}%`, b: true },
283 ]
284
285 // One row per loop, the busiest first after main; lanes past the room left fold into a count.
286 const maxLanes = Math.max(1, Math.floor((e.props.scroll.bodyRows - 20) / 2))
287 const lanes = t.lanes.slice(0, maxLanes)
288 const laneRows: Row[] = lanes.flatMap((lane, i) => {
289 const cells = laneCells(
290 t.spans.filter(s => s.lane === lane.id),
291 t.start,
292 end,
293 cols,
294 now,
295 ).map(k => CELL[k])
296 const row: Row = [{ t: lane.label.padEnd(LABEL_W).slice(0, LABEL_W), b: lane.id === 'main', d: lane.id !== 'main', w: LABEL_W }, { cells, parts: runs(cells) }]
297 return i ? [[], row] : [row]
298 })
299 const hidden = t.lanes.length - lanes.length
300 const timelineRows: Row[] = [
301 ...laneRows,
302 ...(hidden > 0 ? [[{ t: `+${hidden} more loops`, d: true }]] : []),
303 isGrid ? [{ t: ' '.repeat(LABEL_W) }, { t: axis(duration, cols), d: true }] : [{ t: '', w: LABEL_W }, { t: '0', d: true }, { t: '', fill: true }, { t: clock(duration), d: true }],
304 [],
305 isGrid
306 ? [{ t: '█', c: 'success' }, { t: ' tool ', d: true }, { t: '▒', c: 'suggestion' }, { t: ' model ', d: true }, { t: '█', c: 'error' }, { t: ' failed ', d: true }, { t: '·', d: true }, { t: ' idle', d: true }]
307 : [{ t: '■', c: 'success' }, { t: ' tool ', d: true }, { t: '■', c: 'suggestion' }, { t: ' model ', d: true }, { t: '■', c: 'error' }, { t: ' failed ', d: true }, { t: '■', c: TRACK }, { t: ' idle', d: true }],
308 ]
309
310 // Each share as a bar against the turn's length, model and idle named, tools by name.
311 const barW = inner - 24
312 const shareRows: Row[] = parts.slice(0, 6).map(p => {
313 const filled = Math.round((p.pct / 100) * barW)
314 const tone = p.name === 'model' ? 'suggestion' : p.name === 'idle' ? undefined : 'success'
315 const left: Row = [
316 { t: p.name.padEnd(9).slice(0, 9), d: p.name === 'idle', w: 9 },
317 {
318 cells: [{ t: '█'.repeat(filled), c: tone, d: p.name === 'idle' }, { t: '·'.repeat(Math.max(0, barW - filled)), d: true }],
319 parts: [{ n: filled, c: tone ?? 'promptBorder' }, { n: Math.max(0, barW - filled) }],
320 },
321 ]
322 const right: Row = [
323 { t: clock(p.ms).padStart(6), d: true, w: 6, right: true },
324 { t: `${Math.round(p.pct)}%`.padStart(5), b: true, w: 5, right: true },
325 ]
326 // A real bar stretches by itself; a spacer beside it would take half the room.
327 return isGrid ? spread(left, right, inner) : [...left, ...right]
328 })
329
330 const slowest = t.spans
331 .filter(s => s.end !== undefined)
332 .sort((a, b) => (b.end ?? 0) - b.start - ((a.end ?? 0) - a.start))
333 .slice(0, 3)
334 // A model request has no target of its own, so it is named by its place in its loop: "request 3 of 5".
335 const requestOf = (s: TimelineSpan) => {
336 const mine = t.spans.filter(x => x.lane === s.lane && x.kind === 'model')
337 return `request ${mine.indexOf(s) + 1} of ${mine.length}`
338 }
339 const slowRows: Row[] = slowest.length
340 ? slowest.map(s =>
341 spread(
342 [
343 { t: s.kind === 'model' ? 'model' : s.name, c: s.kind === 'model' ? 'suggestion' : s.ok === false ? 'error' : 'success', b: true },
344 { t: ` ${s.lane === 'main' ? '' : `${t.lanes.find(l => l.id === s.lane)?.label ?? ''} `}${s.kind === 'model' ? requestOf(s) : s.detail}`, d: true },
345 ],
346 [{ t: clock((s.end ?? now) - s.start), b: true }],
347 inner,
348 ),
349 )
350 : [[{ t: 'Nothing has finished yet.', d: true }]]
351
352 return (
353 <Box flexDirection="column" paddingTop={1}>
354 {isGrid ? <Text>{draw(glance, w - 3)}</Text> : line(glance)}
355 {frame('Timeline', 'suggestion', timelineRows, `${t.lanes.length} ${t.lanes.length === 1 ? 'loop' : 'loops'}`)}
356 {frame('Where time went', 'success', shareRows, 'main loop')}
357 {frame('Slowest', 'warning', slowRows)}
358 </Box>
359 )
360 })
361}
362types/index.d.ts 12 lines1export type TimelineSpan = { id: string; lane: string; kind: 'model' | 'tool'; name: string; detail: string; start: number; end?: number; ok?: boolean }
2export type TimelineLane = { id: string; label: string }
3export type TimelineTurn = { start: number; end?: number; lanes: TimelineLane[]; spans: TimelineSpan[] }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'turn-timeline': {
8 turn: TimelineTurn | null
9 }
10 }
11}
12