SLOPSHOPPER

turn-timeline

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

newpaneguardcommand
★ 5v0.1.4MITupdated 2026-10-07arasovic/claude-code-mods/turn-timeline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-timeline
│ ┃ Timeline ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ Last turn 0.4s model 0% tools 47% idle ⏺ Read(src/auth.ts) │ ┃ 53% ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭─ Timeline ⎿ Added 2 lines, removed 1 line │ ┃ ───────────────────────────────── 1 loop ─╮ ⏺ Bash(bun test) │ ┃ │ main ⎿ 3 pass, 1 fail │ ┃ ··███·███·███·███·███·███·███·███·███·· │ │ ┃ │ 0 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ ┃ │ █ tool ▒ model █ failed · idle › /timeline │ ┃ │ ⎿ turn-timeline: Timeline pane opened. │ ┃ ╰─────────────────────────────────────────── │ ┃ ──────────╯ │ ┃ │ ┃ ╭─ Where time went ─────────────────────── │ ┃ main loop ─╮ │ ┃ │ idle █████████████············ │ ┃ 0.2s 53% │ │ ┃ │ Bash █████···················· │ ┃ 0.1s 21% │ │ ┃ │ Write ███······················ │ ┃ 0.0s 11% │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Timeline
Last turn 0.4s model 0% tools 47% idle 53% ╭─ Timeline ───────────────────────────────── 1 loop ─╮ │ main ··███·███·███·███·███·███·███·███·███·· │ │ 0 │ │ │ │ █ tool ▒ model █ failed · idle │ ╰─────────────────────────────────────────────────────╯ ╭─ Where time went ─────────────────────── main loop ─╮ │ idle █████████████············ 0.2s 53% │ │ Bash █████···················· 0.1s 21% │ │ Write ███······················ 0.0s 11% │ │ Read █························ 0.0s 5% │ │ Grep █························ 0.0s 5% │ │ Edit █························ 0.0s 5% │ ╰─────────────────────────────────────────────────────╯ ╭─ Slowest ───────────────────────────────────────────╮ │ Read auth.ts 0.0s │ │ Grep src 0.0s │ │ Edit auth.ts 0.0s │ ╰─────────────────────────────────────────────────────╯
README

turn-timeline

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

What it shows

  • Glance row: how long the turn has run, and the share spent in model requests, tools and idle.
  • Timeline: a lane for the main loop and one per subagent. Green blocks are tool calls, red ones failed, shaded blocks are model requests, dots are idle time. Background subagents keep drawing after the main loop ends.
  • Where time went: the main loop's time split between model, each tool and idle.
  • Slowest: the three longest steps of the turn, with their target or request number.

Install

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.

Develop

claude plugin validate .
claude plugin test .
../typecheck.sh turn-timeline
Source 2 files
hooks/register.tsx 362 lines
1import { 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}
362
types/index.d.ts 12 lines
1export 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