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
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 312 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, 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}
312
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