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 312 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, so their width is known before drawing: frames pad and cut them exactly.
107type Seg = { t: string; c?: string; d?: boolean; b?: boolean }
108type Row = Seg[]
109
110const width = (row: Row) => row.reduce((n, s) => n + s.t.length, 0)
111
112const fit = (row: Row, w: number): Row => {
113 const out: Row = []
114 let n = 0
115 for (const s of row) {
116 const room = w - n
117 if (room <= 0) break
118 if (s.t.length <= room) {
119 out.push(s)
120 n += s.t.length
121 } else {
122 out.push({ ...s, t: `${s.t.slice(0, room - 1)}…` })
123 n = w
124 }
125 }
126 if (n < w) out.push({ t: ' '.repeat(w - n) })
127 return out
128}
129
130const spread = (left: Row, right: Row, w: number): Row => [...left, { t: ' '.repeat(Math.max(1, w - width(left) - width(right))) }, ...right]
131
132const CELL: Record<ReturnType<typeof laneCells>[number], Seg> = {
133 tool: { t: '█', c: 'success' },
134 failed: { t: '█', c: 'error' },
135 model: { t: '▒', c: 'suggestion' },
136 idle: { t: '·', d: true },
137}
138
139export const register: Register = on => {
140 on('session.start', async ($, e, next) => {
141 await $.command.register({ name: 'timeline', description: 'Show the current turn as a timeline of model requests and tool calls' })
142 return next(e)
143 })
144
145 on('command.run', { command: 'timeline' }, async $ => {
146 const opened = await $.ui.open({ id: PANE, title: 'Timeline' })
147 return { text: opened.isPlaced ? 'Timeline pane opened.' : `Timeline pane is waiting: ${opened.reason}` }
148 })
149
150 on('turn.start', async ($, e, next) => {
151 const at = await $.clock.now()
152 await update($, turn, () => ({ start: at, lanes: [{ id: 'main', label: 'main' }], spans: [] }))
153 return next(e)
154 })
155
156 on('turn.complete', async ($, e, next) => {
157 if (e.agentId === undefined) {
158 const at = await $.clock.now()
159 await update($, turn, t => (t ? { ...t, end: at } : t))
160 }
161 return next(e)
162 })
163
164 on('tool.call', async ($, e, next) => {
165 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>))
166 let ok = false
167 try {
168 const result = await next(e)
169 ok = result.deny === undefined && !result.isError
170 return result
171 } finally {
172 await close($, id, ok)
173 }
174 })
175
176 on('turn.step', async function* ($, e, next) {
177 let id: string | undefined
178 try {
179 id = await open($, e.agentId, 'model', 'model', '')
180 } catch {
181 // The timeline is a convenience: a failed write must never block the model's request.
182 }
183 try {
184 return yield* next(e)
185 } finally {
186 if (id) await close($, id).catch(() => undefined)
187 }
188 })
189
190 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
191 const { Box, Text } = $.ui.resolve(e)
192 const t = await read($, turn)
193 const now = await $.clock.now()
194 const w = Math.max(30, e.props.bodyColumns - 1)
195 const inner = w - 6
196 if (!t) return <Text dimColor>The timeline starts with the next turn.</Text>
197
198 const draw = (row: Row, width: number) =>
199 fit(row, width).map((s, j) => <Text key={String(j)} color={s.c} dimColor={s.d} bold={s.b}>{s.t}</Text>)
200 const frame = (title: string, tone: string, rows: Row[], right = '') => {
201 const rule = Math.max(0, w - 6 - title.length - (right ? right.length + 2 : 0))
202 return (
203 <Box key={title} flexDirection="column" marginTop={1}>
204 <Text>
205 <Text color={tone} dimColor>╭─ </Text>
206 <Text color={tone} bold>{title}</Text>
207 <Text color={tone} dimColor> {'─'.repeat(rule)}</Text>
208 {right ? <Text dimColor> {right} </Text> : null}
209 <Text color={tone} dimColor>─╮</Text>
210 </Text>
211 {rows.map((row, i) => (
212 <Text key={String(i)}>
213 <Text color={tone} dimColor>│ </Text>
214 {draw(row, inner)}
215 <Text color={tone} dimColor> │</Text>
216 </Text>
217 ))}
218 <Text color={tone} dimColor>╰{'─'.repeat(w - 2)}╯</Text>
219 </Box>
220 )
221 }
222
223 // A background subagent outlives the main turn, so the timeline runs until its last step ends too.
224 const end = !t.end || t.spans.some(s => s.end === undefined) ? now : Math.max(t.end, ...t.spans.map(s => s.end ?? 0))
225 const duration = end - t.start
226 const cols = inner - LABEL_W
227 const main = t.spans.filter(s => s.lane === 'main')
228 const parts = breakdown(main, t.start, end, now)
229 const pct = (name: string) => Math.round(parts.find(p => p.name === name)?.pct ?? 0)
230 const toolPct = Math.max(0, 100 - pct('model') - pct('idle'))
231
232 const glance: Row = [
233 { t: t.end && end !== now ? 'Last turn ' : 'Running ', d: true },
234 { t: clock(duration), b: true },
235 { t: ' model ', d: true },
236 { t: `${pct('model')}%`, c: 'suggestion', b: true },
237 { t: ' tools ', d: true },
238 { t: `${toolPct}%`, c: 'success', b: true },
239 { t: ' idle ', d: true },
240 { t: `${pct('idle')}%`, b: true },
241 ]
242
243 // One row per loop, the busiest first after main; lanes past the room left fold into a count.
244 const maxLanes = Math.max(1, Math.floor((e.props.scroll.bodyRows - 20) / 2))
245 const lanes = t.lanes.slice(0, maxLanes)
246 const laneRows: Row[] = lanes.flatMap((lane, i) => {
247 const cells = laneCells(
248 t.spans.filter(s => s.lane === lane.id),
249 t.start,
250 end,
251 cols,
252 now,
253 ).map(k => CELL[k])
254 const row: Row = [{ t: lane.label.padEnd(LABEL_W).slice(0, LABEL_W), b: lane.id === 'main', d: lane.id !== 'main' }, ...cells]
255 return i ? [[], row] : [row]
256 })
257 const hidden = t.lanes.length - lanes.length
258 const timelineRows: Row[] = [
259 ...laneRows,
260 ...(hidden > 0 ? [[{ t: `+${hidden} more loops`, d: true }]] : []),
261 [{ t: ' '.repeat(LABEL_W) }, { t: axis(duration, cols), d: true }],
262 [],
263 [{ 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 }],
264 ]
265
266 // Each share as a bar against the turn's length, model and idle named, tools by name.
267 const barW = inner - 24
268 const shareRows: Row[] = parts.slice(0, 6).map(p => {
269 const filled = Math.round((p.pct / 100) * barW)
270 const tone = p.name === 'model' ? 'suggestion' : p.name === 'idle' ? undefined : 'success'
271 return spread(
272 [{ t: p.name.padEnd(9).slice(0, 9), d: p.name === 'idle' }, { t: '█'.repeat(filled), c: tone, d: p.name === 'idle' }, { t: '·'.repeat(Math.max(0, barW - filled)), d: true }],
273 [{ t: clock(p.ms).padStart(6), d: true }, { t: `${Math.round(p.pct)}%`.padStart(5), b: true }],
274 inner,
275 )
276 })
277
278 const slowest = t.spans
279 .filter(s => s.end !== undefined)
280 .sort((a, b) => (b.end ?? 0) - b.start - ((a.end ?? 0) - a.start))
281 .slice(0, 3)
282 // A model request has no target of its own, so it is named by its place in its loop: "request 3 of 5".
283 const requestOf = (s: TimelineSpan) => {
284 const mine = t.spans.filter(x => x.lane === s.lane && x.kind === 'model')
285 return `request ${mine.indexOf(s) + 1} of ${mine.length}`
286 }
287 const slowRows: Row[] = slowest.length
288 ? slowest.map(s =>
289 spread(
290 [
291 { t: s.kind === 'model' ? 'model' : s.name, c: s.kind === 'model' ? 'suggestion' : s.ok === false ? 'error' : 'success', b: true },
292 { t: ` ${s.lane === 'main' ? '' : `${t.lanes.find(l => l.id === s.lane)?.label ?? ''} `}${s.kind === 'model' ? requestOf(s) : s.detail}`, d: true },
293 ],
294 [{ t: clock((s.end ?? now) - s.start), b: true }],
295 inner,
296 ),
297 )
298 : [[{ t: 'Nothing has finished yet.', d: true }]]
299
300 return (
301 <Box flexDirection="column" paddingTop={1}>
302 <Text>
303 {draw(glance, w - 3)}
304 </Text>
305 {frame('Timeline', 'suggestion', timelineRows, `${t.lanes.length} ${t.lanes.length === 1 ? 'loop' : 'loops'}`)}
306 {frame('Where time went', 'success', shareRows, 'main loop')}
307 {frame('Slowest', 'warning', slowRows)}
308 </Box>
309 )
310 })
311}
312types/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