A Gantt-style timeline of each turn: every model request and tool call, per agent lane, in a pane (or inline in the transcript). `/timeline` or the footer…

A mod (a plugin of function hooks) that charts where each turn's time went: every model request and every tool call, laid out as a Gantt chart with one lane for the lead and one per subagent.
| Entry point | What it does |
|---|---|
timeline button in the prompt footer | Opens the pane; pressed again, closes it. |
/timeline | Opens the pane. Where no pane can be placed, draws the chart inline in the transcript instead. |
/timeline inline | Draws the latest turn's chart inline in the transcript. |
/timeline stats | Prints the time the mod's own recording costs per tool call and model request (p50 / p95 / max). stats reset clears the samples. |
In the pane, running bars grow live, a picker switches between the last 20 turns, and the three slowest tool calls are listed under the chart. Hovering a bar shows its command, duration, and token counts. Colors are by kind: model, read/search, edit, Bash, subagent, other. A failed or interrupted call has a red outline in the Svg chart and is drawn red in the terminal grid.
The desktop app, VS Code, and mobile draw the chart as an Svg. The terminal draws the same chart as a Raster grid of colored cells, sized to the pane's body (inline, to the screen, capped at 80 columns while a pane is docked), with lane labels to its left and the time axis and legend below; running calls show as ▒. Hover details are desktop-only.
Spans are charted under the turn that started them, not the newest turn: a background subagent keeps working after its turn completes, and its work stays with that turn.
Recording never runs in front of the call it records. The hook hands the tool or model request on at once and writes the span beside it, waiting for that write only at the end. /timeline stats reports the cost that remains on the critical path as before + wait + after, and record as what the write itself takes.
Each turn is its own $.state family member, so a write touches one turn and finished turns never redraw. The pane redraws once a second only while it is open and something is running.
Nothing the mod records is sent to the model. The only text that reaches the conversation is what /timeline prints: one line, or the stats table for /timeline stats.
claude plugin validate plugins/turn-timeline
claude plugin test plugins/turn-timeline
tsconfig.json extends .claude-plugin/types/, which the engine writes only when an interactive session hot-reloads the folder. Start one with claude --plugin-dir plugins/turn-timeline once; after that tsc -p plugins/turn-timeline type-checks. A marketplace install never writes it.
hooks/register.tsx 407 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register, RenderInput, Timer } from 'claude-code'
3
4import type { Span, Turn, TurnEntry } from '../types'
5import { MAIN_LANE, formatDuration, laneLabels, legend, slowest, spanEnd, summarize, toRaster, toSvg } from './chart'
6import { measure, report, resetStats } from './stats'
7
8const PANE = 'timeline'
9const MAX_TURNS = 20
10const MAX_SPANS = 400
11const TITLE_PATTERN = /Turn #(\d+) のタイムライン/
12
13const index = atom({ plugin: 'turn-timeline', key: 'index' } as const, [])
14const lanes = atom({ plugin: 'turn-timeline', key: 'lanes' } as const, {})
15const selected = atom({ plugin: 'turn-timeline', key: 'selected' } as const, null)
16const seq = atom({ plugin: 'turn-timeline', key: 'seq' } as const, 0)
17const tick = atom({ plugin: 'turn-timeline', key: 'tick' } as const, 0)
18const isOpen = atom({ plugin: 'turn-timeline', key: 'isOpen' } as const, false)
19const turnFamily = atom({ plugin: 'turn-timeline', key: 'turn' } as const, null)
20
21// Spans go to the turn they belong to, not the newest one: a background
22// subagent keeps working after the turn that spawned it has completed.
23const turnFor = async ($: EngineInterface, lane: string): Promise<string | undefined> => {
24 if (lane !== MAIN_LANE) {
25 const owner = (await read($, lanes))[lane]
26 if (owner !== undefined) return owner.turnId
27 }
28 return (await read($, index)).at(-1)?.id
29}
30
31// A main-lane span that lands after its turn completed (the turn was
32// interrupted while the write was in flight) is stored closed, so it can
33// never stay running. Subagent lanes legitimately outlive their turn.
34const addSpan = ($: EngineInterface, turnId: string, span: Span) =>
35 update($, memberOf(turnFamily, { requestId: turnId }), turn => {
36 if (turn === null || turn.spans.length >= MAX_SPANS) return turn
37 const stored = span.lane === MAIN_LANE && turn.end !== null ? { ...span, end: turn.end, isError: true } : span
38 return { ...turn, spans: [...turn.spans, stored] }
39 })
40
41const endSpan = ($: EngineInterface, turnId: string, id: string, end: number, isError: boolean, detail?: string) =>
42 update($, memberOf(turnFamily, { requestId: turnId }), turn =>
43 turn === null || !turn.spans.some(span => span.id === id)
44 ? turn
45 : {
46 ...turn,
47 spans: turn.spans.map(span =>
48 span.id === id ? { ...span, end, isError, detail: detail ?? span.detail } : span,
49 ),
50 },
51 )
52
53// Recording runs beside the tool or model request instead of in front of it:
54// the hook hands `next` the call at once and waits for this only at the end.
55const beginSpan = async ($: EngineInterface, span: Span, knownTurnId?: string): Promise<string | undefined> => {
56 const began = performance.now()
57 try {
58 const start = await $.clock.now()
59 const turnId = knownTurnId ?? (await turnFor($, span.lane))
60 if (turnId !== undefined) {
61 await addSpan($, turnId, { ...span, start })
62 await startTicking($)
63 }
64 measure(`${span.kind}.record`, performance.now() - began)
65 return turnId
66 } catch {
67 measure(`${span.kind}.record.failed`, performance.now() - began)
68 return undefined
69 }
70}
71
72// The critical path a call pays is `before` + `wait` + `after`: `wait` is the
73// part of the recording still unfinished when the tool or request returned.
74const finishSpan = async (
75 $: EngineInterface,
76 recording: Promise<string | undefined>,
77 span: Span,
78 isError: boolean,
79 detail?: string,
80) => {
81 const end = await $.clock.now()
82 const waited = performance.now()
83 const turnId = await recording
84 const written = performance.now()
85 measure(`${span.kind}.wait`, written - waited)
86 if (turnId !== undefined) await endSpan($, turnId, span.id, end, isError, detail)
87 measure(`${span.kind}.after`, performance.now() - written)
88}
89
90const closeLane = (turn: Turn, lane: string, end: number): Turn => ({
91 ...turn,
92 spans: turn.spans.map(span => (span.lane === lane && span.end === null ? { ...span, end, isError: true } : span)),
93})
94
95const describeInput = (input: Readonly<Record<string, unknown>>): string => {
96 for (const key of ['command', 'file_path', 'pattern', 'url', 'query', 'description', 'prompt']) {
97 const value = input[key]
98 if (typeof value === 'string' && value !== '') return value.replace(/\s+/g, ' ').slice(0, 160)
99 }
100 return ''
101}
102
103type PaneControls = { entries: readonly TurnEntry[]; isPinned: boolean }
104
105const LABEL_COLUMNS = 12
106
107const draw = async ($: EngineInterface, e: RenderInput, turn: Turn, columns: number, controls?: PaneControls) => {
108 const now = await $.clock.now()
109 const names = Object.fromEntries(Object.entries(await read($, lanes)).map(([id, lane]) => [id, lane.label]))
110 const labels = laneLabels(turn, names)
111 // Every table is completed to every element name (a missing one draws a
112 // fragment), so the surface, not `in`, decides what is really drawn.
113 const els = $.ui.resolve(e)
114 const { Box, Text, Svg, Button, Select } = els as ElementTable<'desktop'>
115 const header = (
116 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
117 <Text bold>Turn #{turn.seq}</Text>
118 <Text dimColor>{summarize(turn, now)}</Text>
119 </Box>
120 )
121
122 let chart
123 if (e.surface === 'terminal') {
124 const { Raster } = els as ElementTable<'terminal'>
125 const grid = toRaster(turn, names, now, columns - LABEL_COLUMNS - 1)
126 const rowLabels = Array.from({ length: grid.rows }, (_, row) => grid.labels.find(label => label.row === row)?.text ?? ' ')
127 chart = (
128 <Box flexDirection="column">
129 <Box flexDirection="row" columnGap={1}>
130 <Box flexDirection="column" width={LABEL_COLUMNS}>
131 {rowLabels.map(text => (
132 <Text dimColor wrap="truncate-end">{text}</Text>
133 ))}
134 </Box>
135 <Raster key="chart" columns={grid.columns} rows={grid.rows} cells={grid.cells} />
136 </Box>
137 <Box flexDirection="row" columnGap={1}>
138 <Box width={LABEL_COLUMNS} />
139 <Text dimColor wrap="truncate-end">{grid.axis}</Text>
140 </Box>
141 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
142 {legend().map(item => (
143 <Box flexDirection="row" columnGap={1}>
144 <Text color={item.color}>{item.glyph}</Text>
145 <Text dimColor>{item.text}</Text>
146 </Box>
147 ))}
148 </Box>
149 </Box>
150 )
151 } else {
152 chart = <Svg source={toSvg(turn, names, now)} alt={`Turn #${turn.seq} のモデル呼び出しとツール実行のタイムライン`} isInteractive />
153 }
154
155 const slow = slowest(turn, now, 3)
156 const picker =
157 controls !== undefined && e.surface !== 'mobile' && controls.entries.length > 1 ? (
158 <Select
159 key="turn"
160 label="ターン"
161 value={turn.id}
162 options={[...controls.entries].reverse().map(entry => ({
163 value: entry.id,
164 label: `#${entry.seq} ${entry.prompt || '(継続)'}`,
165 }))}
166 onSelect={value => void update($, selected, () => value)}
167 />
168 ) : null
169
170 return (
171 <Box flexDirection="column" gap={1}>
172 {header}
173 {picker}
174 {chart}
175 {slow.length > 0 && (
176 <Box flexDirection="column">
177 <Text bold>時間がかかったツール</Text>
178 {slow.map(span => (
179 <Text wrap="truncate-end">
180 {formatDuration(spanEnd(span, now) - span.start)} · {labels.get(span.lane) ?? span.lane} · {span.label}
181 {span.detail ? ` · ${span.detail}` : ''}
182 </Text>
183 ))}
184 </Box>
185 )}
186 {controls?.isPinned === true && (
187 <Button key="follow" label="最新のターンに戻る" onPress={() => void update($, selected, () => null)} />
188 )}
189 </Box>
190 )
191}
192
193let ticker: Timer | undefined
194
195const advanceTick = async ($: EngineInterface) => {
196 const began = performance.now()
197 const recent = (await read($, index)).slice(-3)
198 const turns = await Promise.all(recent.map(entry => read($, memberOf(turnFamily, { requestId: entry.id }))))
199 const isRunning = turns.some(t => t !== null && (t.end === null || t.spans.some(s => s.end === null)))
200 // The engine's record, not the atom: a pane closed on an unload never raises
201 // the `ui.close` hook that would clear `isOpen`.
202 const isShown = (await $.ui.panes()).some(pane => pane.id === PANE)
203 if (!isShown) await update($, isOpen, () => false)
204 if (!isRunning || !isShown) {
205 ticker?.cancel()
206 ticker = undefined
207 return
208 }
209 await update($, tick, n => n + 1)
210 measure('tick', performance.now() - began)
211}
212
213// Running bars grow with time, which no span write reflects. Only the Pane
214// hook reads `tick`, so bumping it redraws the pane and nothing else; it runs
215// only while the pane is open and the recent turns hold a running span.
216const startTicking = async ($: EngineInterface) => {
217 if (ticker !== undefined || !(await read($, isOpen))) return
218 ticker ??= $.clock.every(1000, () => void advanceTick($))
219}
220
221const openPane = async ($: EngineInterface) => {
222 await update($, selected, () => null)
223 const opened = await $.ui.open({ id: PANE, title: 'Timeline' })
224 await update($, isOpen, () => opened.isPlaced)
225 await startTicking($)
226 return opened
227}
228
229const togglePane = async ($: EngineInterface) => {
230 if (await read($, isOpen)) {
231 await $.ui.close({ id: PANE })
232 await update($, isOpen, () => false)
233 } else {
234 await openPane($)
235 }
236}
237
238export const register: Register = on => {
239 on('session.start', async ($, e, next) => {
240 await $.command.register({
241 name: 'timeline',
242 description: 'ターン内のモデル呼び出しとツール実行をタイムラインで表示(inline でトランスクリプトに描く)',
243 argumentHint: '[inline | stats | stats reset]',
244 immediate: true,
245 })
246 return next(e)
247 })
248
249 on('turn.start', async ($, e, next) => {
250 const start = await $.clock.now()
251 const n = await update($, seq, value => value + 1)
252 const prompt = e.text.replace(/\s+/g, ' ').slice(0, 60)
253 await update($, memberOf(turnFamily, { requestId: e.turnId }), () => ({ id: e.turnId, seq: n, prompt, start, end: null, spans: [] }))
254 const before = await read($, index)
255 await update($, index, list => [...list, { id: e.turnId, seq: n, prompt }].slice(-MAX_TURNS))
256 for (const evicted of before.slice(0, Math.max(0, before.length + 1 - MAX_TURNS))) {
257 await update($, memberOf(turnFamily, { requestId: evicted.id }), () => null)
258 }
259 await startTicking($)
260 return next(e)
261 })
262
263 on('turn.complete', async ($, e, next) => {
264 const end = await $.clock.now()
265 if (e.agentId === undefined) {
266 await update($, memberOf(turnFamily, { requestId: e.turnId }), turn => (turn === null ? turn : { ...closeLane(turn, MAIN_LANE, end), end }))
267 } else {
268 const agentId = e.agentId
269 const turnId = await turnFor($, agentId)
270 if (turnId !== undefined) {
271 await update($, memberOf(turnFamily, { requestId: turnId }), turn => (turn === null ? turn : closeLane(turn, agentId, end)))
272 }
273 }
274 return next(e)
275 })
276
277 on('agent.spawn', async ($, e, next) => {
278 const spawned = await next(e)
279 const agentId = spawned.agentId
280 const turnId = (await read($, index)).at(-1)?.id
281 if (agentId !== undefined && turnId !== undefined) {
282 await update($, lanes, all => ({ ...all, [agentId]: { label: e.name ?? e.subagentType, turnId } }))
283 await startTicking($)
284 }
285 return spawned
286 })
287
288 on('tool.call', async ($, e, next) => {
289 const entered = performance.now()
290 const span: Span = {
291 id: e.tool_use_id,
292 lane: e.agentId ?? MAIN_LANE,
293 kind: 'tool',
294 label: e.tool,
295 detail: describeInput(e as unknown as Record<string, unknown>),
296 start: 0,
297 end: null,
298 isError: false,
299 }
300 const recording = beginSpan($, span)
301 measure('tool.before', performance.now() - entered)
302 let isError = true
303 try {
304 const ran = await next(e)
305 isError = ran.deny !== undefined || ran.isError === true
306 return ran
307 } finally {
308 await finishSpan($, recording, span, isError)
309 }
310 })
311
312 on('turn.step', async function* ($, e, next) {
313 const entered = performance.now()
314 const lane = e.agentId ?? MAIN_LANE
315 const span: Span = {
316 id: `${lane}:${e.turnId}:${e.index}`,
317 lane,
318 kind: 'model',
319 label: e.model,
320 detail: `リクエスト #${e.index + 1}`,
321 start: 0,
322 end: null,
323 isError: false,
324 }
325 const recording = beginSpan($, span, lane === MAIN_LANE ? e.turnId : undefined)
326 measure('model.before', performance.now() - entered)
327 let detail: string | undefined
328 let isError = true
329 try {
330 const result = yield* next(e)
331 isError = false
332 if (result.usage) {
333 const u = result.usage
334 const input = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
335 detail = `${u.model} · 入力 ${input} tok · 出力 ${u.output_tokens} tok`
336 }
337 return result
338 } finally {
339 await finishSpan($, recording, span, isError, detail)
340 }
341 })
342
343 on('command.run', { command: 'timeline' }, async ($, e) => {
344 const args = e.args.trim()
345 if (args === 'stats' || args === 'stats reset') {
346 const lines = report()
347 if (args === 'stats reset') resetStats()
348 return { text: lines.length === 0 ? '計測値はまだありません。' : `turn-timeline の計測値(直近 500 件)\n${lines.join('\n')}` }
349 }
350 const latest = (await read($, index)).at(-1)
351 const turn = latest === undefined ? null : await read($, memberOf(turnFamily, { requestId: latest.id }))
352 if (turn === null) return { text: 'まだ記録したターンがありません。' }
353
354 if (args !== 'inline') {
355 const opened = await openPane($)
356 if (opened.isPlaced) return { text: `タイムラインをペインに表示しました(最新は Turn #${turn.seq})。` }
357 await $.ui.close({ id: PANE })
358 $.ui.log(`turn-timeline: ペインが配置されなかったのでインラインで描きます(${opened.reason})`)
359 }
360 return { text: `Turn #${turn.seq} のタイムライン: ${summarize(turn, await $.clock.now())}` }
361 })
362
363 on('ui.close', { id: PANE }, async ($, e, next) => {
364 await update($, isOpen, () => false)
365 return next(e)
366 })
367
368 on('ui.render', { component: 'SessionMode' }, async ($, e) => {
369 const { Box, Text, Button } = $.ui.resolve(e)
370 const open = await read($, isOpen)
371 return (
372 <Box flexDirection="row" columnGap={1}>
373 {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')}</Text>}
374 <Button key="timeline" plain dimColor label={open ? 'timeline ×' : 'timeline'} onPress={() => void togglePane($)} />
375 </Box>
376 )
377 })
378
379 on('ui.render', { component: 'CommandOutput', props: { command: 'timeline' } }, async ($, e, next) => {
380 const match = TITLE_PATTERN.exec(e.props.text)
381 const entry = match === null ? undefined : (await read($, index)).find(one => one.seq === Number(match[1]))
382 const turn = entry === undefined ? null : await read($, memberOf(turnFamily, { requestId: entry.id }))
383 if (turn === null) return next(e)
384 // A command row is as wide as the transcript, which a docked pane narrows
385 // below the screen; the row reports no width of its own, so stay narrow then.
386 const screen = e.viewport?.columns ?? 80
387 const isDocked = e.viewport?.isFullscreen === true && (await $.ui.panes()).some(pane => pane.isPlaced)
388 return draw($, e, turn, isDocked ? Math.min(screen, 80) : screen)
389 })
390
391 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
392 await read($, tick)
393 const entries = await read($, index)
394 const pinned = await read($, selected)
395 const entry = entries.find(one => one.id === pinned) ?? entries.at(-1)
396 const turn = entry === undefined ? null : await read($, memberOf(turnFamily, { requestId: entry.id }))
397 if (turn === null) {
398 const { Text } = $.ui.resolve(e)
399 return <Text dimColor>次のターンから記録します。</Text>
400 }
401 const began = performance.now()
402 const tree = await draw($, e, turn, e.props.bodyColumns, { entries, isPinned: pinned !== null && entry?.id === pinned })
403 measure('pane.render', performance.now() - began)
404 return tree
405 })
406}
407hooks/chart.ts 283 lines1import type { Span, Turn } from '../types'
2
3export const MAIN_LANE = 'main'
4
5type Category = 'model' | 'read' | 'edit' | 'bash' | 'agent' | 'other'
6
7// The surface draws the SVG as an isolated image, so the host theme never
8// reaches it: these mid-tone colors are picked to read on light and dark.
9const COLORS: Record<Category, string> = {
10 model: '#8b6fd6',
11 read: '#4a7fd4',
12 edit: '#3a9d6a',
13 bash: '#d08a2c',
14 agent: '#2aa3a3',
15 other: '#8a8f99',
16}
17const INK = '#8a8f99'
18const ERROR = '#d9534f'
19
20const LEGEND: readonly [Category, string][] = [
21 ['model', 'モデル'],
22 ['read', '読み取り・検索'],
23 ['edit', '編集'],
24 ['bash', 'Bash'],
25 ['agent', 'サブエージェント'],
26 ['other', 'その他'],
27]
28
29const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch', 'ToolSearch'])
30const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
31
32export const categoryOf = (span: Span): Category => {
33 if (span.kind === 'model') return 'model'
34 if (span.label === 'Bash') return 'bash'
35 if (span.label === 'Agent' || span.label === 'Task') return 'agent'
36 if (EDIT_TOOLS.has(span.label)) return 'edit'
37 if (READ_TOOLS.has(span.label)) return 'read'
38 return 'other'
39}
40
41export const formatDuration = (ms: number): string => {
42 if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`
43 const seconds = ms / 1000
44 if (seconds < 10) return `${seconds.toFixed(1)}s`
45 if (seconds < 60) return `${Math.round(seconds)}s`
46 const minutes = Math.floor(seconds / 60)
47 const rest = Math.round(seconds - minutes * 60)
48 return rest === 0 ? `${minutes}m` : `${minutes}m${rest}s`
49}
50
51export const spanEnd = (span: Span, now: number): number => span.end ?? now
52
53export const turnEnd = (turn: Turn, now: number): number =>
54 Math.max(turn.end ?? now, ...turn.spans.map(span => spanEnd(span, now)))
55
56export const laneLabels = (
57 turn: Turn,
58 names: Readonly<Record<string, string>>,
59): Map<string, string> => {
60 const order = [MAIN_LANE, ...new Set(turn.spans.map(span => span.lane))]
61 const labels = new Map<string, string>()
62 const used = new Map<string, number>()
63 for (const lane of new Set(order)) {
64 if (lane !== MAIN_LANE && !turn.spans.some(span => span.lane === lane)) continue
65 const base = lane === MAIN_LANE ? 'lead' : (names[lane] ?? `agent ${lane.slice(0, 6)}`)
66 const count = (used.get(base) ?? 0) + 1
67 used.set(base, count)
68 labels.set(lane, count === 1 ? base : `${base} #${count}`)
69 }
70 return labels
71}
72
73export const summarize = (turn: Turn, now: number): string => {
74 const tools = turn.spans.filter(span => span.kind === 'tool')
75 const models = turn.spans.filter(span => span.kind === 'model')
76 const modelMs = models.reduce((sum, span) => sum + spanEnd(span, now) - span.start, 0)
77 const agents = new Set(turn.spans.map(span => span.lane).filter(lane => lane !== MAIN_LANE))
78 const parts = [
79 `${formatDuration(turnEnd(turn, now) - turn.start)}${turn.end === null ? '(実行中)' : ''}`,
80 `ツール ${tools.length} 回`,
81 `モデル ${models.length} 回(${formatDuration(modelMs)})`,
82 ]
83 if (agents.size > 0) parts.push(`サブエージェント ${agents.size}`)
84 return parts.join(' · ')
85}
86
87export const slowest = (turn: Turn, now: number, count: number): Span[] =>
88 turn.spans
89 .filter(span => span.kind === 'tool')
90 .sort((a, b) => spanEnd(b, now) - b.start - (spanEnd(a, now) - a.start))
91 .slice(0, count)
92
93const escape = (text: string): string =>
94 text.replace(/[&<>"]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[char] ?? char)
95
96const STEPS_S = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600]
97
98const tickStep = (totalMs: number): number => {
99 const step = STEPS_S.find(s => (totalMs / 1000) / s <= 6) ?? STEPS_S[STEPS_S.length - 1]!
100 return step * 1000
101}
102
103const LABEL_W = 104
104const CHART_W = 600
105const PAD_R = 20
106const SUB_ROW_H = 18
107const LANE_GAP = 6
108const BAR_H = 12
109const TOP = 6
110const AXIS_H = 18
111const LEGEND_H = 22
112const MAX_SUB_ROWS = 4
113const SVG_LIMIT = 125_000
114
115type Placed = { span: Span; y: number; row: number }
116
117// Parallel tool calls overlap in time, so each lane is packed greedily into
118// sub-rows; a lane never takes more than MAX_SUB_ROWS of them. `y` is the SVG
119// position, `row` the plain sub-row index the terminal grid uses.
120const pack = (turn: Turn, lanes: Map<string, string>, now: number) => {
121 const placed: Placed[] = []
122 const labels: { text: string; y: number; row: number }[] = []
123 const separators: number[] = []
124 let y = TOP
125 let rowBase = 0
126 for (const [lane, text] of lanes) {
127 const spans = turn.spans.filter(span => span.lane === lane).sort((a, b) => a.start - b.start)
128 const rowEnds: number[] = []
129 for (const span of spans) {
130 let row = rowEnds.findIndex(end => end <= span.start)
131 if (row === -1) row = rowEnds.length < MAX_SUB_ROWS ? rowEnds.length : MAX_SUB_ROWS - 1
132 rowEnds[row] = Math.max(rowEnds[row] ?? 0, spanEnd(span, now))
133 placed.push({ span, y: y + row * SUB_ROW_H, row: rowBase + row })
134 }
135 labels.push({ text, y, row: rowBase })
136 const rows = Math.max(1, rowEnds.length)
137 y += rows * SUB_ROW_H + LANE_GAP
138 rowBase += rows
139 separators.push(y - LANE_GAP / 2)
140 }
141 return { placed, labels, separators, height: y, rowCount: rowBase }
142}
143
144export const toSvg = (
145 turn: Turn,
146 names: Readonly<Record<string, string>>,
147 now: number,
148 withTitles = true,
149): string => {
150 const lanes = laneLabels(turn, names)
151 const end = Math.max(turnEnd(turn, now), turn.start + 1000)
152 const total = end - turn.start
153 const x = (t: number) => LABEL_W + ((t - turn.start) / total) * CHART_W
154 const { placed, labels, separators, height: lanesBottom } = pack(turn, lanes, now)
155 const axisY = lanesBottom + 2
156 const legendY = axisY + AXIS_H
157 const width = LABEL_W + CHART_W + PAD_R
158 const height = legendY + LEGEND_H
159
160 const parts: string[] = []
161 const step = tickStep(total)
162 for (let t = 0; t <= total; t += step) {
163 const tx = x(turn.start + t).toFixed(1)
164 parts.push(`<line x1="${tx}" y1="${TOP}" x2="${tx}" y2="${lanesBottom}" stroke="${INK}" stroke-opacity="0.25"/>`)
165 parts.push(`<text x="${tx}" y="${axisY + 12}" text-anchor="middle">${formatDuration(t)}</text>`)
166 }
167 for (const sy of separators.slice(0, -1)) {
168 parts.push(`<line x1="0" y1="${sy}" x2="${width}" y2="${sy}" stroke="${INK}" stroke-opacity="0.15"/>`)
169 }
170 for (const label of labels) {
171 parts.push(`<text x="4" y="${label.y + 12}">${escape(label.text.slice(0, 16))}</text>`)
172 }
173 for (const { span, y } of placed) {
174 const x1 = x(span.start)
175 const w = Math.max(2, x(spanEnd(span, now)) - x1)
176 const isRunning = span.end === null
177 const stroke = span.isError ? ` stroke="${ERROR}" stroke-width="1.5"` : ''
178 const title = withTitles
179 ? `<title>${escape(`${span.label} · ${formatDuration(spanEnd(span, now) - span.start)}${isRunning ? '(実行中)' : ''}${span.detail ? `\n${span.detail}` : ''}`)}</title>`
180 : ''
181 parts.push(
182 `<rect x="${x1.toFixed(1)}" y="${y + (SUB_ROW_H - BAR_H) / 2}" width="${w.toFixed(1)}" height="${BAR_H}" rx="2" fill="${COLORS[categoryOf(span)]}" fill-opacity="${isRunning ? 0.5 : 0.95}"${stroke}>${title}</rect>`,
183 )
184 }
185 let lx = 4
186 for (const [category, text] of LEGEND) {
187 parts.push(`<rect x="${lx}" y="${legendY + 6}" width="10" height="10" rx="2" fill="${COLORS[category]}"/>`)
188 parts.push(`<text x="${lx + 14}" y="${legendY + 15}">${text}</text>`)
189 lx += 22 + text.length * 11
190 }
191
192 const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" font-family="system-ui, -apple-system, sans-serif" font-size="11" fill="${INK}">${parts.join('')}</svg>`
193 return svg.length > SVG_LIMIT && withTitles ? toSvg(turn, names, now, false) : svg
194}
195
196export const legend = (): readonly { glyph: string; text: string; color: string }[] => [
197 ...LEGEND.map(([category, text]) => ({ glyph: '█', text, color: COLORS[category] })),
198 { glyph: '▒', text: '実行中', color: INK },
199 { glyph: '█', text: 'エラー・中断', color: ERROR },
200]
201
202const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
203
204export const toBase64 = (bytes: Uint8Array): string => {
205 let out = ''
206 for (let i = 0; i < bytes.length; i += 3) {
207 const a = bytes[i]!
208 const b = bytes[i + 1]
209 const c = bytes[i + 2]
210 out += BASE64[a >> 2]
211 out += BASE64[((a & 3) << 4) | ((b ?? 0) >> 4)]
212 out += b === undefined ? '=' : BASE64[((b & 15) << 2) | ((c ?? 0) >> 6)]
213 out += c === undefined ? '=' : BASE64[c & 63]
214 }
215 return out
216}
217
218const hex = (color: string) => Number.parseInt(color.slice(1), 16)
219const DEFAULT_COLOR = 0x01000000
220const FULL_BLOCK = 0x2588
221const SHADE = 0x2592
222const DOT = 0x254e
223
224export type RasterChart = {
225 columns: number
226 rows: number
227 cells: string
228 labels: readonly { text: string; row: number }[]
229 axis: string
230}
231
232// The terminal draws no Svg, so there the chart is a Raster: one cell row per
233// sub-row, the label column and the time axis drawn as Text beside it.
234export const toRaster = (
235 turn: Turn,
236 names: Readonly<Record<string, string>>,
237 now: number,
238 columns: number,
239): RasterChart => {
240 const width = Math.max(10, Math.min(512, Math.floor(columns)))
241 const lanes = laneLabels(turn, names)
242 const { placed, labels, rowCount } = pack(turn, lanes, now)
243 const rows = Math.max(1, Math.min(256, rowCount))
244 const end = Math.max(turnEnd(turn, now), turn.start + 1000)
245 const total = end - turn.start
246 const col = (t: number) => Math.max(0, Math.min(width - 1, Math.floor(((t - turn.start) / total) * width)))
247
248 const words = new Uint32Array(width * rows * 3)
249 const paint = (row: number, column: number, glyph: number, fg: number) => {
250 const at = (row * width + column) * 3
251 words[at] = glyph
252 words[at + 1] = fg
253 words[at + 2] = DEFAULT_COLOR
254 }
255 const step = tickStep(total)
256 for (let row = 0; row < rows; row++) {
257 for (let column = 0; column < width; column++) paint(row, column, 0x20, DEFAULT_COLOR)
258 for (let t = 0; t <= total; t += step) paint(row, col(turn.start + t), DOT, hex(INK))
259 }
260 for (const { span, row } of placed) {
261 if (row >= rows) continue
262 const from = col(span.start)
263 const to = Math.max(from, col(spanEnd(span, now)))
264 const fg = hex(span.isError ? ERROR : COLORS[categoryOf(span)])
265 for (let column = from; column <= to; column++) paint(row, column, span.end === null ? SHADE : FULL_BLOCK, fg)
266 }
267
268 const axis = Array.from({ length: width }, () => ' ')
269 for (let t = 0; t <= total; t += step) {
270 const text = formatDuration(t)
271 const at = Math.min(col(turn.start + t), width - text.length)
272 if (axis.slice(at, at + text.length).every(char => char === ' ')) axis.splice(at, text.length, ...text)
273 }
274
275 return {
276 columns: width,
277 rows,
278 cells: toBase64(new Uint8Array(words.buffer)),
279 labels: labels.filter(label => label.row < rows).map(({ text, row }) => ({ text, row })),
280 axis: axis.join('').trimEnd(),
281 }
282}
283hooks/stats.ts 28 lines1const LIMIT = 500
2
3// Module-level on purpose: measuring must not add `$.state` writes of its own,
4// so the samples start over on every reload of the mod.
5const samples = new Map<string, number[]>()
6
7export const measure = (name: string, ms: number) => {
8 const list = samples.get(name) ?? []
9 list.push(ms)
10 if (list.length > LIMIT) list.shift()
11 samples.set(name, list)
12}
13
14export const resetStats = () => samples.clear()
15
16const at = (sorted: readonly number[], q: number): number =>
17 sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] ?? 0
18
19const ms = (value: number) => `${value.toFixed(2)}ms`
20
21export const report = (): string[] =>
22 [...samples]
23 .sort(([a], [b]) => a.localeCompare(b))
24 .map(([name, list]) => {
25 const sorted = [...list].sort((a, b) => a - b)
26 return `${name}: n=${sorted.length} p50=${ms(at(sorted, 0.5))} p95=${ms(at(sorted, 0.95))} max=${ms(sorted.at(-1) ?? 0)}`
27 })
28types/index.d.ts 40 lines1export type SpanKind = 'model' | 'tool'
2
3export type Span = {
4 id: string
5 lane: string
6 kind: SpanKind
7 label: string
8 detail: string
9 start: number
10 end: number | null
11 isError: boolean
12}
13
14export type Turn = {
15 id: string
16 seq: number
17 prompt: string
18 start: number
19 end: number | null
20 spans: Span[]
21}
22
23export type TurnEntry = { id: string; seq: number; prompt: string }
24
25export type Lane = { label: string; turnId: string }
26
27declare module 'claude-code' {
28 interface PluginState {
29 'turn-timeline': {
30 index: TurnEntry[]
31 turn: StateFamily<Turn | null>
32 lanes: Record<string, Lane>
33 selected: string | null
34 seq: number
35 tick: number
36 isOpen: boolean
37 }
38 }
39}
40