A live pane of every tool call: its status, what it worked on and how long it took.

A live pane of every tool call: its status, what it worked on and how long it took.
A Timeline pane that lists the session's tool calls as they happen:
✓ Read src/checkout.ts 0.1s
✓ Grep applyDiscount 0.2s
✓ Edit src/cart/total.ts 0.1s
⊘ Read .env 0.0s
✓ Write src/cart/total.test.ts 0.1s
✗ Bash npm test 12.4s
✓ Edit src/cart/total.ts 0.1s
◐ Bash npm test 3.0s
◐ running, in the warning color. ✓ done, in the success color. ✗ error and ⊘ denied, in the error color.PreToolUse), a permission rule, the permission mode, you at the prompt, or a plugin whose tool.call hook runs beneath this one.server:tool.+N earlier line above them.✗. So does a call that was still running when the plugin reloaded.| Command | What it does |
|---|---|
/timeline | Opens the Timeline pane. |
The pane also opens by itself on a fullscreen terminal at least 144 columns wide, or 110 once you have opened it before. On a narrower fullscreen terminal it waits, undrawn, until the window is wide enough. On the terminal's main screen it waits for /timeline. Once you close the pane, it does not open by itself again in that session.
| Option | Type | Default | What it does |
|---|---|---|---|
autoOpen | boolean | true | Opens the pane by itself on a fullscreen terminal. Off: only /timeline opens it. |
Set it with /config, or in settings under pluginConfigs["tool-timeline@mod-showroom"].options.
/plugin install tool-timeline --marketplace KROU4/claude-code-mod-showroomclaude plugin install tool-timeline --marketplace KROU4/claude-code-mod-showroomKROU4/claude-code-mod-showroom → Add plugintool.call hook runs above this one is not listed.✗.MIT. See LICENSE.
hooks/register.tsx 204 lines1import { atom, read, update } from 'claude-code'
2import type { Register, ThemeKey, ToolCallResult } from 'claude-code'
3
4import type { TimelineCall, TimelineStatus } from '../types'
5
6const PANE = 'timeline'
7const TITLE = 'Timeline'
8const KEEP = 200
9const NAME_MAX = 16
10const SUMMARY_MAX = 200
11
12const GLYPH: Record<TimelineStatus, string> = { running: '◐', done: '✓', error: '✗', denied: '⊘' }
13const COLOR: Record<TimelineStatus, ThemeKey> = { running: 'warning', done: 'success', error: 'error', denied: 'error' }
14
15// Where tools keep what a call is about, the most telling first.
16const SUBJECTS = ['file_path', 'notebook_path', 'command', 'pattern', 'path', 'url', 'query', 'description', 'skill', 'prompt']
17
18// How Claude Code's own refusals open, when a permission rule, the mode or the person turns a call down.
19const REFUSED = /^(?:The user doesn't want to proceed with this tool use|Permission (?:to .+|for this action) has been denied)/
20
21// This load of the module: `$.state` outlives a reload, the hooks watching a running call do not.
22const LOAD = crypto.randomUUID()
23
24const calls = atom({ plugin: 'tool-timeline', key: 'calls' } as const, [])
25const dismissed = atom({ plugin: 'tool-timeline', key: 'dismissed' } as const, false)
26
27export const register: Register = (on, options) => {
28 on('session.start', async ($, e, next) => {
29 await $.command.register({
30 name: 'timeline',
31 description: 'Show the tool calls of this session in the Timeline pane',
32 })
33
34 return next(e)
35 })
36
37 on('command.run', { command: 'timeline' }, async $ => {
38 const opened = await $.ui.open({ id: PANE, title: TITLE })
39
40 return { text: opened.isPlaced ? 'Timeline opened.' : `Timeline is open, not drawn yet: ${opened.reason}` }
41 })
42
43 // A pane the person closed stays closed until they ask for it.
44 on('ui.close', { id: PANE }, async ($, e, next) => {
45 if (e.origin.kind === 'person') await update($, dismissed, () => true)
46
47 return next(e)
48 }).catch(($, e, next) => next(e))
49
50 if (options.autoOpen !== false) {
51 let hasOpened = false
52
53 // Unasked, the pane opens only where the surface docks it beside the transcript.
54 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
55 if (!hasOpened && e.viewport?.isFullscreen === true) {
56 hasOpened = true
57 if (!(await read($, dismissed))) $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
58 }
59
60 return next(e)
61 })
62 }
63
64 // An observer: when it fails, the call goes on unrecorded.
65 on('tool.call', async ($, e, next) => {
66 const id = e.tool_use_id
67 const startedAt = await $.clock.now()
68 const { summary, isPath } = summarize(e, await $.session.cwd())
69 // A hook run again for the same call (a fresh copy of the plugin) replaces its row.
70 await update($, calls, list =>
71 [
72 ...list.filter(call => call.id !== id).map(call => orphanSettled(call, startedAt)),
73 { id, tool: e.tool, summary, isPath, status: 'running' as const, startedAt, load: LOAD },
74 ].slice(-KEEP),
75 )
76 // A running call's time moves on screen.
77 const ticker = $.clock.every(1000, () => $.ui.invalidate('ui.render'))
78 next.signal.addEventListener('abort', () => ticker.cancel())
79 // What a call counts as when its run ends without settling (interrupted).
80 let status: TimelineStatus = 'error'
81
82 try {
83 const ran = await next(e)
84 status = statusOf(ran)
85
86 return ran
87 } finally {
88 ticker.cancel()
89 const durationMs = (await $.clock.now()) - startedAt
90 // A settings hook's deny arrives as a plain error: the PreToolUse observer below already said so.
91 await update($, calls, list =>
92 list.map(call =>
93 call.id === id ? { ...call, status: call.status === 'denied' ? call.status : status, durationMs } : call,
94 ),
95 )
96 }
97 }).catch(($, e, next) => next(e))
98
99 // Settings PreToolUse hooks run beneath every tool.call hook; their deny is told apart here.
100 on('classic.PreToolUse', async ($, e, next) => {
101 const decided = await next(e)
102 if (decided.deny !== undefined) {
103 const id = e.tool_use_id
104 await update($, calls, list => list.map(call => (call.id === id ? { ...call, status: 'denied' as const } : call)))
105 }
106
107 return decided
108 }).catch(($, e, next) => next(e))
109
110 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
111 const { Box, Text } = $.ui.resolve(e)
112 const list = await read($, calls)
113
114 if (list.length === 0) {
115 return <Text dimColor>No tool calls yet.</Text>
116 }
117
118 // The pane's body bounds the rows, and the surface's own height bounds that.
119 const room = Math.max(1, Math.min(e.props.scroll.bodyRows, e.viewport?.rows ?? Infinity))
120 const shown = list.length <= room ? list : list.slice(list.length - Math.max(1, room - 1))
121 const now = await $.clock.now()
122 const nameWidth = Math.min(NAME_MAX, Math.max(...shown.map(call => label(call.tool).length)))
123 // Left of the summary: the glyph and the name; right of it: a duration of up to 6 cells; 3 gaps.
124 const summaryWidth = e.props.bodyColumns - nameWidth - 10
125
126 return (
127 <Box flexDirection="column">
128 {shown.length < list.length && <Text dimColor>+{list.length - shown.length} earlier</Text>}
129 {shown.map(call => (
130 <Box flexDirection="row" gap={1}>
131 <Text color={COLOR[call.status]}>{GLYPH[call.status]}</Text>
132 <Box width={nameWidth} flexShrink={0}>
133 <Text wrap="truncate-end">{label(call.tool)}</Text>
134 </Box>
135 <Box flexGrow={1} flexShrink={1}>
136 <Text dimColor wrap={call.isPath ? 'truncate-start' : 'truncate-end'}>
137 {clip(call.summary, summaryWidth, call.isPath)}
138 </Text>
139 </Box>
140 <Text dimColor tabular>
141 {duration(call.durationMs ?? now - call.startedAt)}
142 </Text>
143 </Box>
144 ))}
145 </Box>
146 )
147 })
148}
149
150function statusOf(ran: ToolCallResult): TimelineStatus {
151 if (ran.deny !== undefined) return 'denied'
152 if (ran.isError !== true) return 'done'
153
154 return REFUSED.test(ran.text ?? '') ? 'denied' : 'error'
155}
156
157// A row an earlier load left running lost the hook that would settle it: it ends as an error.
158function orphanSettled(call: TimelineCall, now: number): TimelineCall {
159 if (call.status !== 'running' || call.load === LOAD) return call
160
161 return { ...call, status: 'error', durationMs: now - call.startedAt }
162}
163
164function summarize(input: object, cwd: string): { summary: string; isPath: boolean } {
165 for (const key of SUBJECTS) {
166 const value: unknown = Reflect.get(input, key)
167 if (typeof value !== 'string' || value.trim() === '') continue
168
169 const isPath = key.endsWith('path')
170 const text = isPath ? relativeTo(cwd, value) : value.replace(/\s+/g, ' ').trim()
171
172 return { summary: clip(text, SUMMARY_MAX, isPath), isPath }
173 }
174
175 return { summary: '', isPath: false }
176}
177
178function relativeTo(cwd: string, path: string): string {
179 const root = cwd.replace(/\\/g, '/').replace(/\/$/, '')
180 const file = path.replace(/\\/g, '/')
181
182 return file.startsWith(`${root}/`) ? file.slice(root.length + 1) : path
183}
184
185// A path keeps its end (the file name), anything else its start.
186function clip(text: string, width: number, fromStart: boolean): string {
187 if (text.length <= width) return text
188 if (width < 2) return ''
189
190 return fromStart ? `…${text.slice(text.length - width + 1)}` : `${text.slice(0, width - 1)}…`
191}
192
193// mcp__github__create_issue reads as github:create_issue.
194function label(tool: string): string {
195 return tool.replace(/^mcp__(.+?)__/, '$1:')
196}
197
198function duration(ms: number): string {
199 if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
200 const seconds = Math.round(ms / 1000)
201
202 return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`
203}
204types/index.d.ts 27 lines1export type TimelineStatus = 'running' | 'done' | 'error' | 'denied'
2
3export type TimelineCall = {
4 id: string
5 tool: string
6 /** The path, command or pattern the call was about, on one line. */
7 summary: string
8 /** The summary is a path: cut from its start, so the file name stays. */
9 isPath: boolean
10 status: TimelineStatus
11 startedAt: number
12 /** Set once the call settled. */
13 durationMs?: number
14 /** Which load of the hooks module watches the call: a reload drops the old load's hooks. */
15 load: string
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'tool-timeline': {
21 calls: TimelineCall[]
22 /** The person closed the pane: it no longer opens unasked this session. */
23 dismissed: boolean
24 }
25 }
26}
27