Draws the desktop Code tab transcript like the Claude VS Code extension: a dotted timeline, tool calls with IN/OUT cards, a plain spinner line.

Small mods for the Claude desktop app.
I like how the Claude extension for VS Code shows its work: every step is a row on a dotted line, and commands get a small box with what went in and what came out. The desktop app looks different, so I made this mod to give it the same look.
What it changes:
Add this folder as a plugin marketplace in Claude Code and install vscode-timeline from it.
MIT, see LICENSE.
hooks/register.tsx 610 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { Elements, Register, RenderChildren, RenderElement, RenderViewport } from 'claude-code'
3
4// The desktop Code tab, drawn the way the Claude VS Code extension draws its
5// transcript: every step a row on a dotted timeline, tool calls as a bold name
6// with a dim summary and, for shell-like calls, one IN/OUT card in monospace.
7
8type UI = Elements['desktop']
9type Tone = 'done' | 'failed' | 'running' | 'neutral'
10
11type Call = {
12 id: string
13 tool: string
14 input: unknown
15 output?: unknown
16 isRunning: boolean
17 isErrored: boolean
18 isInterrupted: boolean
19}
20
21const expanded = atom({ plugin: 'vscode-timeline', key: 'expanded' } as const, false)
22
23const DOT: Record<Tone, string> = {
24 done: '#4fb867',
25 failed: '#f14c4c',
26 running: '#d97757',
27 neutral: '#8a8a8a',
28}
29const LINE = '#80808059'
30const ORANGE = '#d97757'
31// File paths in a header, in the theme's link-like blue as VS Code draws them.
32const PATH = 'suggestion'
33
34const IN_LINES = 3
35const OUT_LINES = 4
36const DIFF_LINES = 12
37const WRITE_LINES = 8
38const EXPANDED_LINES = 200
39const MAX_LINE_CHARS = 300
40
41// The card is an SVG drawn at the row's width, so its text keeps its size:
42// the desktop reports its width in cells of its code font, about 7.8px each.
43const CELL_PX = 7.8
44const MONO = "Consolas, 'Cascadia Mono', 'Courier New', monospace"
45const SANS = "'Segoe UI', system-ui, sans-serif"
46const LINE_H = 18
47const PAD_Y = 7
48const LABEL_X = 12
49const TEXT_X = 52
50
51const SHELLS: Record<string, string> = { Bash: 'bash', PowerShell: 'powershell' }
52const TITLES: Record<string, string> = {
53 WebFetch: 'Fetch',
54 WebSearch: 'Web Search',
55 TodoWrite: 'Update Todos',
56 ToolSearch: 'Tool Search',
57}
58// Built-in tools this mod draws; every other built-in keeps the app's own row
59// (questions, plans, artifacts, file cards and the rest).
60const DRAWN = new Set([
61 'Bash', 'PowerShell', 'Read', 'Edit', 'Write', 'NotebookEdit', 'Grep', 'Glob',
62 'WebFetch', 'WebSearch', 'Agent', 'TodoWrite', 'Skill', 'ToolSearch', 'Monitor',
63])
64const CARDED = new Set(['Bash', 'PowerShell', 'Agent', 'WebFetch', 'Monitor', 'NotebookEdit'])
65const COMPACT = new Set(['Read', 'Grep', 'Glob', 'WebSearch', 'Skill', 'ToolSearch'])
66// MCP tools whose rows are the app's own widgets (task list, inline visuals,
67// browser and desktop screenshots) stay as the app draws them.
68const NATIVE_MCP = /^mcp__(ccd_|visualize|Claude_Browser|computer-use|terminal)|__(show_widget|read_me|screenshot|zoom)$/
69// A group holding one of these keeps the app's drawing, so the picture shows.
70const WIDGET = /__(show_widget|screenshot|zoom)$/
71// The app's own plumbing (task list, next-step box): no row of its own.
72const PLUMBING = /^mcp__ccd_turn__/
73
74function isDrawn(tool: string): boolean {
75 if (tool.startsWith('mcp__')) return !NATIVE_MCP.test(tool)
76 return DRAWN.has(tool)
77}
78
79// ---- plain-data helpers -------------------------------------------------
80
81function str(o: unknown, k: string): string | undefined {
82 if (o === null || typeof o !== 'object') return undefined
83 const v = (o as Record<string, unknown>)[k]
84 return typeof v === 'string' ? v : undefined
85}
86
87function num(o: unknown, k: string): number | undefined {
88 if (o === null || typeof o !== 'object') return undefined
89 const v = (o as Record<string, unknown>)[k]
90 return typeof v === 'number' ? v : undefined
91}
92
93function arr(o: unknown, k: string): unknown[] | undefined {
94 if (o === null || typeof o !== 'object') return undefined
95 const v = (o as Record<string, unknown>)[k]
96 return Array.isArray(v) ? v : undefined
97}
98
99// Code and Text take tab and newline as their only control characters.
100function clean(s: string): string {
101 return s
102 .replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '')
103 .replace(/\r\n?/g, '\n')
104 .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
105}
106
107function lines(s: string): string[] {
108 const text = clean(s).replace(/\s+$/, '')
109 return text === '' ? [] : text.split('\n')
110}
111
112function oneLine(s: string): string {
113 return clean(s).replace(/\s+/g, ' ').trim()
114}
115
116function basename(path: string): string {
117 return path.split(/[\\/]/).filter(Boolean).pop() ?? path
118}
119
120function xml(s: string): string {
121 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
122}
123
124function blockText(block: unknown): string {
125 if (typeof block === 'string') return block
126 if (str(block, 'type') === 'image') return '[image]'
127 return str(block, 'text') ?? ''
128}
129
130function outputText(output: unknown): string {
131 if (output === undefined || output === null) return ''
132 if (typeof output === 'string') return output
133 if (Array.isArray(output)) return output.map(blockText).filter(Boolean).join('\n')
134 if (typeof output === 'object') {
135 if ((output as Record<string, unknown>).isImage === true) return '[image]'
136 const stdout = str(output, 'stdout')
137 const stderr = str(output, 'stderr')
138 if (stdout !== undefined || stderr !== undefined) {
139 const background = str(output, 'backgroundTaskId')
140 if (background && !stdout && !stderr) return `Running in the background (${background})`
141 return [stdout, stderr].filter(s => s).join('\n')
142 }
143 const content = arr(output, 'content')
144 if (content) return outputText(content)
145 const text = str(output, 'text') ?? str(output, 'result') ?? str(output, 'message')
146 if (text !== undefined) return text
147 }
148 try {
149 return JSON.stringify(output, null, 2) ?? ''
150 } catch {
151 return String(output)
152 }
153}
154
155function countLinks(o: unknown, depth = 0): number {
156 if (depth > 6 || o === null || typeof o !== 'object') return 0
157 if (Array.isArray(o)) return o.reduce<number>((n, x) => n + countLinks(x, depth + 1), 0)
158 if (typeof (o as Record<string, unknown>).url === 'string') return 1
159 return Object.values(o).reduce<number>((n, x) => n + countLinks(x, depth + 1), 0)
160}
161
162function counted(n: number, one: string, many: string): string {
163 return `${n} ${n === 1 ? one : many}`
164}
165
166// What a search found, from its record or, where the row holds the text the
167// model read, from that text ("Found 3 files", or one result per line).
168function countOf(p: { tool: string; input: unknown; output?: unknown }): string {
169 const o = p.output
170 if (o === undefined) return ''
171 if (p.tool === 'WebSearch') {
172 const n = countLinks(o)
173 return n > 0 ? counted(n, 'result', 'results') : ''
174 }
175 if (p.tool !== 'Grep' && p.tool !== 'Glob') return ''
176
177 const mode = p.tool === 'Glob' ? 'files_with_matches' : str(o, 'mode') ?? str(p.input, 'output_mode') ?? 'files_with_matches'
178 const [one, many] = mode === 'content' ? ['line', 'lines'] : mode === 'count' ? ['match', 'matches'] : ['file', 'files']
179
180 if (typeof o === 'string' || Array.isArray(o)) {
181 const rows = lines(outputText(o)).filter(l => l.trim() !== '')
182 const found = rows[0]?.match(/^Found (\d+)/)
183 if (found) return counted(Number(found[1]), one, many)
184 if (rows.length === 1 && /^No (files|matches) found/.test(rows[0] ?? '')) return counted(0, one, many)
185 return counted(rows.length, one, many)
186 }
187
188 const n = mode === 'content' ? num(o, 'numLines') : mode === 'count' ? num(o, 'numMatches') : num(o, 'numFiles')
189 return n === undefined ? '' : counted(n, one, many)
190}
191
192type Hunk = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[] }
193
194function isHunk(h: unknown): h is Hunk {
195 return num(h, 'oldStart') !== undefined && num(h, 'newStart') !== undefined && Array.isArray(arr(h, 'lines'))
196}
197
198// Keeps the first `max` diff lines and rewrites the cut hunk's header, so the
199// shortened source still parses as a unified diff.
200function hunkSource(hunks: Hunk[], max: number): { source: string; hidden: number } {
201 const out: string[] = []
202 let shown = 0
203 let total = 0
204 for (const h of hunks) {
205 const body = h.lines.map(l => clean(l))
206 total += body.length
207 if (shown >= max) continue
208 const kept = body.slice(0, max - shown)
209 shown += kept.length
210 const oldCount = kept.filter(l => !l.startsWith('+')).length
211 const newCount = kept.filter(l => !l.startsWith('-')).length
212 out.push(`@@ -${h.oldStart},${oldCount} +${h.newStart},${newCount} @@`, ...kept)
213 }
214 return { source: out.join('\n'), hidden: total - shown }
215}
216
217function previewHunks(oldText: string, newText: string): Hunk[] {
218 const removed = lines(oldText)
219 const added = lines(newText)
220 return [{
221 oldStart: 1,
222 oldLines: removed.length,
223 newStart: 1,
224 newLines: added.length,
225 lines: [...removed.map(l => `-${l}`), ...added.map(l => `+${l}`)],
226 }]
227}
228
229function toneOf(p: { isRunning: boolean; isErrored: boolean; isInterrupted: boolean }): Tone {
230 if (p.isErrored || p.isInterrupted) return 'failed'
231 return p.isRunning ? 'running' : 'done'
232}
233
234function titleOf(tool: string): string {
235 if (tool.startsWith('mcp__')) return tool.split('__').slice(2).join('__') || tool
236 return TITLES[tool] ?? tool
237}
238
239function summaryOf(tool: string, input: unknown): string {
240 switch (tool) {
241 case 'Bash':
242 case 'PowerShell':
243 return str(input, 'description') ?? lines(str(input, 'command') ?? '')[0] ?? ''
244 case 'Read': {
245 const offset = num(input, 'offset')
246 const limit = num(input, 'limit')
247 const name = basename(str(input, 'file_path') ?? '')
248 if (offset === undefined && limit === undefined) return name
249 const from = offset ?? 1
250 return limit === undefined ? `${name} lines ${from}+` : `${name} lines ${from}–${from + limit - 1}`
251 }
252 case 'Edit':
253 case 'Write':
254 return basename(str(input, 'file_path') ?? '')
255 case 'NotebookEdit':
256 return basename(str(input, 'notebook_path') ?? '')
257 case 'Grep':
258 case 'Glob': {
259 const pattern = str(input, 'pattern') ?? ''
260 const path = str(input, 'path')
261 const glob = str(input, 'glob')
262 return `${tool === 'Grep' ? `"${pattern}"` : pattern}${path ? ` in ${basename(path)}` : ''}${glob ? ` (${glob})` : ''}`
263 }
264 case 'WebFetch':
265 return str(input, 'url') ?? ''
266 case 'WebSearch':
267 return str(input, 'query') ?? ''
268 case 'Agent':
269 return str(input, 'description') ?? ''
270 case 'Skill':
271 return str(input, 'skill') ?? ''
272 case 'ToolSearch':
273 return str(input, 'query') ?? ''
274 }
275 for (const k of ['description', 'title', 'query', 'prompt', 'url', 'file_path', 'path', 'pattern', 'command', 'name']) {
276 const v = str(input, k)
277 if (v) return v
278 }
279 return ''
280}
281
282function inputSource(tool: string, input: unknown, isOpen: boolean): string {
283 if (SHELLS[tool]) return clean(str(input, 'command') ?? '')
284 if (tool === 'Agent') return clean(str(input, 'prompt') ?? '')
285 if (tool === 'WebFetch') return clean(str(input, 'prompt') ?? '')
286 try {
287 return clean(isOpen ? JSON.stringify(input, null, 2) : JSON.stringify(input))
288 } catch {
289 return ''
290 }
291}
292
293function cardWidth(viewport: RenderViewport | undefined): number {
294 const columns = viewport?.columns ?? 110
295 return Math.max(320, Math.min(1200, Math.round((columns - 4) * CELL_PX) - 2))
296}
297
298// ---- the SVG pieces -----------------------------------------------------
299
300// The dot, with the timeline drawn through it: below always, above when the
301// row continues one drawn just before it in the same tree.
302function dotSource(tone: Tone, joinTop: boolean): string {
303 const pulse = tone === 'running'
304 ? '<animate attributeName="opacity" values="1;0.25;1" dur="1.2s" repeatCount="indefinite"/>'
305 : ''
306 const above = joinTop ? '<line x1="5" y1="0" x2="5" y2="6" stroke="#808080" stroke-opacity=".35"/>' : ''
307
308 return `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="20" viewBox="0 0 10 20">${above}<line x1="5" y1="14" x2="5" y2="20" stroke="#808080" stroke-opacity=".35"/><circle cx="5" cy="10" r="3.5" fill="${DOT[tone]}">${pulse}</circle></svg>`
309}
310
311type Section = { label: string; lines: string[]; note?: string; isError?: boolean; fades?: boolean }
312
313// One IN/OUT card as VS Code draws it: a rounded box, small grey labels on
314// the left, monospace text clipped at the right edge, a hairline between the
315// sections and the last lines of a cut output fading out.
316function cardSource(width: number, sections: Section[]): string {
317 const defs: string[] = []
318 const body: string[] = []
319 let y = 0
320
321 sections.forEach((s, i) => {
322 if (i > 0) body.push(`<line class="d" x1="1" y1="${y + 0.5}" x2="${width - 1}" y2="${y + 0.5}"/>`)
323 const rows = Math.max(1, s.lines.length)
324 const h = PAD_Y * 2 + rows * LINE_H
325 const base = y + PAD_Y + 13
326 body.push(`<text class="l${s.isError ? ' le' : ''}" x="${LABEL_X}" y="${base}">${xml(s.label)}</text>`)
327
328 let mask = ''
329 if (s.fades) {
330 const from = y + h - PAD_Y - 2 * LINE_H
331 defs.push(
332 `<linearGradient id="g${i}" gradientUnits="userSpaceOnUse" x1="0" y1="${from}" x2="0" y2="${y + h - PAD_Y}"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>`,
333 `<mask id="m${i}" maskUnits="userSpaceOnUse"><rect x="0" y="0" width="${width}" height="${from}" fill="#fff"/><rect x="0" y="${from}" width="${width}" height="${y + h - from}" fill="url(#g${i})"/></mask>`,
334 )
335 mask = ` mask="url(#m${i})"`
336 }
337
338 const spans = s.lines.length === 0
339 ? `<tspan class="n" x="${TEXT_X}" y="${base}">${xml(s.note ?? '')}</tspan>`
340 : s.lines.map((l, j) => `<tspan x="${TEXT_X}" y="${base + j * LINE_H}">${xml(l.slice(0, MAX_LINE_CHARS)) || ' '}</tspan>`).join('')
341 body.push(`<text class="t${s.isError ? ' e' : ''}" clip-path="url(#c)"${mask} xml:space="preserve">${spans}</text>`)
342 y += h
343 })
344
345 const height = y + 1
346 const style = [
347 `text{font-family:${MONO};font-size:12.5px;white-space:pre}`,
348 '.t{fill:#cccccc}.e{fill:#f48771}.n{fill:#8b8b8b;font-style:italic}',
349 `.l{font-family:${SANS};font-size:11px;fill:#8b8b8b;letter-spacing:.06em}.l.le{fill:#f14c4c}`,
350 '.b{fill:#ffffff08;stroke:#ffffff24}.d{stroke:#ffffff1c}',
351 '@media (prefers-color-scheme:light){.t{fill:#333333}.e{fill:#c72e0f}.n,.l{fill:#767676}.l.le{fill:#d32f2f}.b{fill:#00000005;stroke:#00000026}.d{stroke:#0000001c}}',
352 ].join('')
353
354 return [
355 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
356 `<style>${style}</style>`,
357 `<defs><clipPath id="c"><rect x="${TEXT_X}" y="0" width="${width - TEXT_X - 10}" height="${height}"/></clipPath>${defs.join('')}</defs>`,
358 `<rect class="b" x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="6"/>`,
359 ...body,
360 '</svg>',
361 ].join('')
362}
363
364// ---- drawing ------------------------------------------------------------
365
366function row(ui: UI, tone: Tone, joinTop: boolean, body: RenderChildren): RenderElement {
367 const { Box, Svg } = ui
368 const pulse = tone === 'running' ? { isInteractive: true } : {}
369
370 return (
371 <Box flexDirection="row" width="100%" minWidth="0%">
372 <Box flexDirection="column" alignItems="center" width={2} flexShrink={0}>
373 <Svg source={dotSource(tone, joinTop)} alt={tone} width={10} height={20} {...pulse} />
374 <Box flexGrow={1} width="5%" alignSelf="center" backgroundColor={LINE} />
375 </Box>
376 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth="0%" overflow="hidden" paddingLeft={1} paddingBottom={1}>
377 {body}
378 </Box>
379 </Box>
380 )
381}
382
383function header(ui: UI, title: string, summary: string, extra?: RenderChildren, isPath = false): RenderElement {
384 const { Box, Text } = ui
385
386 return (
387 <Box flexDirection="row" gap={1} minWidth="0%">
388 <Box flexShrink={0}>
389 <Text bold>{oneLine(title)}</Text>
390 </Box>
391 {summary !== '' && (
392 <Box flexShrink={1} minWidth="0%" overflow="hidden">
393 {isPath
394 ? <Text color={PATH} wrap="truncate-start">{oneLine(summary)}</Text>
395 : <Text dimColor wrap="truncate-end">{oneLine(summary)}</Text>}
396 </Box>
397 )}
398 {extra}
399 </Box>
400 )
401}
402
403// A card that unfolds the way VS Code's does: hovering it shows a small
404// "Click to expand" chip in its bottom-right corner, and the chip toggles it.
405function expandable(ui: UI, id: string, isOpen: boolean, hidden: number, onPress: () => void, card: RenderChildren): RenderElement {
406 const { Box, Button } = ui
407 const canToggle = isOpen || hidden > 0
408
409 return (
410 <Box key={`card:${id}`} flexDirection="column" width="100%" minWidth="0%" overflow="hidden" marginTop={0.25}>
411 {card}
412 {canToggle && (
413 <Box position="absolute" bottom={0} right={0} paddingRight={1} paddingBottom={0.4} display="none" hover={{ display: 'flex' }}>
414 <Button key={`expand:${id}`} onPress={onPress}>
415 {isOpen ? 'Click to collapse' : 'Click to expand'}
416 </Button>
417 </Box>
418 )}
419 </Box>
420 )
421}
422
423function changeSummary(added: number, removed: number): string {
424 const plural = (n: number) => `${n} line${n === 1 ? '' : 's'}`
425 if (added > 0 && removed > 0) return `Added ${plural(added)}, removed ${plural(removed)}`
426 if (removed > 0) return `Removed ${plural(removed)}`
427 return `Added ${plural(added)}`
428}
429
430// One tool call as a timeline row, for a standalone row and inside a group.
431function drawCall(ui: UI, p: Call, isOpen: boolean, flip: () => void, width: number, joinTop: boolean): RenderElement {
432 const { Box, Code, Svg, Text } = ui
433 const tone = toneOf(p)
434 const title = titleOf(p.tool)
435 const summary = summaryOf(p.tool, p.input)
436 const isFailed = p.isErrored || p.isInterrupted
437 const errorText = isFailed ? outputText(p.output) : ''
438
439 const errorLines = (max: number) => {
440 if (p.isInterrupted) return <Text color={DOT.failed}>Interrupted</Text>
441 if (!p.isErrored) return null
442 return <Text color={DOT.failed}>{lines(errorText).slice(0, max).join('\n') || 'Failed'}</Text>
443 }
444
445 // Edit and Write, as VS Code heads them: the full path, a dim line saying
446 // what changed, and the desktop's own code block as the card.
447 if (p.tool === 'Edit' || p.tool === 'Write') {
448 const path = str(p.input, 'file_path') ?? ''
449 if (isFailed) return row(ui, tone, joinTop, [header(ui, title, path, undefined, true), errorLines(3)])
450
451 if (p.tool === 'Write') {
452 const content = lines(str(p.input, 'content') ?? '')
453 const max = isOpen ? EXPANDED_LINES : WRITE_LINES
454
455 return row(ui, tone, joinTop, [
456 header(ui, title, path, undefined, true),
457 <Text dimColor>{changeSummary(content.length, 0)}</Text>,
458 content.length > 0 && expandable(ui, p.id, isOpen, content.length - max, flip,
459 <Code source={content.slice(0, max).join('\n')} path={path} wrap="truncate-end" />),
460 ])
461 }
462
463 const patch = arr(p.output, 'structuredPatch')?.filter(isHunk)
464 const hunks = patch && patch.length > 0
465 ? patch
466 : previewHunks(str(p.input, 'old_string') ?? '', str(p.input, 'new_string') ?? '')
467 const all = hunks.flatMap(h => h.lines)
468 const added = all.filter(l => l.startsWith('+')).length
469 const removed = all.filter(l => l.startsWith('-')).length
470 const { source, hidden } = hunkSource(hunks, isOpen ? EXPANDED_LINES : DIFF_LINES)
471
472 return row(ui, tone, joinTop, [
473 header(ui, title, path, undefined, true),
474 <Text dimColor>{changeSummary(added, removed)}</Text>,
475 source !== '' && expandable(ui, p.id, isOpen, hidden, flip,
476 <Code source={source} format="diff" wrap="truncate-end" />),
477 ])
478 }
479
480 // Read: the full path, and the lines it read when it read only some.
481 if (p.tool === 'Read') {
482 const path = str(p.input, 'file_path') ?? ''
483 const range = summary.includes(' lines ') ? summary.slice(summary.indexOf(' lines ') + 2) : ''
484 const extra = range !== '' ? <Box flexShrink={0}><Text dimColor>{range}</Text></Box> : undefined
485
486 return row(ui, tone, joinTop, [header(ui, title, path, extra, true), errorLines(3)])
487 }
488
489 // The todo list: one line per item.
490 if (p.tool === 'TodoWrite') {
491 const todos = arr(p.input, 'todos') ?? []
492
493 return row(ui, tone, joinTop, [
494 header(ui, title, ''),
495 ...todos.map(todo => {
496 const status = str(todo, 'status')
497 const text = oneLine(str(todo, 'content') ?? '')
498 if (status === 'completed') return <Text dimColor strikethrough>{`☑ ${text}`}</Text>
499 if (status === 'in_progress') return <Text bold>{`◐ ${text}`}</Text>
500 return <Text>{`☐ ${text}`}</Text>
501 }),
502 ])
503 }
504
505 // Shells, agents, fetches and MCP tools: the IN/OUT card.
506 if (CARDED.has(p.tool) || (p.tool.startsWith('mcp__') && !NATIVE_MCP.test(p.tool))) {
507 const inLines = lines(inputSource(p.tool, p.input, isOpen))
508 const inShown = inLines.slice(0, isOpen ? EXPANDED_LINES : IN_LINES)
509 const outLines = lines(isFailed ? errorText : outputText(p.output))
510 const outShown = outLines.slice(0, isOpen ? EXPANDED_LINES : OUT_LINES)
511 const hidden = outLines.length - outShown.length + inLines.length - inShown.length
512
513 const sections: Section[] = []
514 if (inShown.length > 0) sections.push({ label: 'IN', lines: inShown })
515 sections.push(
516 p.isRunning
517 ? { label: 'OUT', lines: [], note: 'Running…' }
518 : p.isInterrupted
519 ? { label: 'OUT', lines: [], note: 'Interrupted', isError: true }
520 : outShown.length === 0
521 ? { label: 'OUT', lines: [], note: '(no output)', isError: p.isErrored }
522 : { label: 'OUT', lines: outShown, isError: p.isErrored, fades: outLines.length > outShown.length },
523 )
524 const alt = `IN: ${oneLine(inShown.join(' '))}. OUT: ${oneLine(outShown.slice(0, 2).join(' ')) || 'none'}`
525
526 return row(ui, tone, joinTop, [
527 header(ui, title, summary),
528 expandable(ui, p.id, isOpen, hidden, flip,
529 <Svg source={cardSource(width, sections)} alt={alt.slice(0, 500)} />),
530 ])
531 }
532
533 // Searches, listings and everything else: one line, a dim count.
534 const count = COMPACT.has(p.tool) && !p.isRunning && !p.isErrored ? countOf(p) : ''
535 const extra = count !== '' ? <Text dimColor>{`· ${count}`}</Text> : undefined
536
537 return row(ui, tone, joinTop, [header(ui, title, summary, extra), errorLines(3)])
538}
539
540export const register: Register = on => {
541 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
542 if (e.surface !== 'desktop' || !isDrawn(e.props.tool)) return next(e)
543
544 const ui = $.ui.resolve(e)
545 const open = memberOf(expanded, e)
546 const isOpen = await read($, open)
547 const call: Call = { ...e.props, id: e.props.tool_use_id }
548
549 return drawCall(ui, call, isOpen, () => void update($, open, v => !v), cardWidth(e.viewport), false)
550 })
551
552 // The row above draws the result itself, so the app's own result block
553 // under it is left out.
554 on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
555 if (e.surface !== 'desktop' || !isDrawn(e.props.tool)) return next(e)
556 const { Box } = $.ui.resolve(e)
557
558 return <Box />
559 })
560
561 // VS Code lists every call on its own line of the timeline; the app folds a
562 // run of calls into a summary or a framed group, so the group is drawn here.
563 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
564 if (e.surface !== 'desktop' || e.props.calls.some(c => WIDGET.test(c.tool))) return next(e)
565
566 const ui = $.ui.resolve(e)
567 const { Box } = ui
568 const width = cardWidth(e.viewport)
569 const rows: RenderElement[] = []
570
571 for (const [i, c] of e.props.calls.entries()) {
572 if (PLUMBING.test(c.tool)) continue
573 const id = c.tool_use_id ?? `${e.requestId}:${i}`
574 const open = atom({ plugin: 'vscode-timeline', key: 'expanded', id }, false)
575 const isOpen = await read($, open)
576 const call: Call = { ...c, id }
577 rows.push(drawCall(ui, call, isOpen, () => void update($, open, v => !v), width, rows.length > 0))
578 }
579
580 if (rows.length === 0) return next(e)
581
582 return <Box flexDirection="column" width="100%" minWidth="0%" overflow="hidden">{rows}</Box>
583 })
584
585 on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
586 if (e.surface !== 'desktop') return next(e)
587 const ui = $.ui.resolve(e)
588 const { Markdown } = ui
589
590 return row(ui, 'neutral', false, <Markdown text={e.props.text} dimColor={e.props.isSummary === true} />)
591 })
592
593 on('ui.render', { component: 'Spinner' }, ($, e, next) => {
594 if (e.surface !== 'desktop') return next(e)
595 const ui = $.ui.resolve(e)
596 const { Box, Text } = ui
597 const said = oneLine(e.props.message ?? e.props.word)
598 const label = /(…|\.\.\.)$/.test(said) ? said : `${said}${e.props.suffix}`
599
600 // One line: the app keeps the row one line tall and draws its own token
601 // count and state beside it.
602 return (
603 <Box flexDirection="row" gap={1}>
604 <Text color={ORANGE} bold>✻</Text>
605 <Text bold wrap="truncate-end">{label}</Text>
606 </Box>
607 )
608 })
609}
610types/index.d.ts 12 lines1/** Whether a tool row's card shows its whole output. */
2export type Expanded = boolean
3
4declare module 'claude-code' {
5 interface PluginState {
6 'vscode-timeline': {
7 /** One per tool row, by its tool_use_id. */
8 expanded: StateFamily<Expanded>
9 }
10 }
11}
12