SLOPSHOPPER

vscode-timeline

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.

newspinnerrows
A shopper browsing a rack in a slop shop
README

Claude Code Mods

Small mods for the Claude desktop app.

TypeScript

vscode-timeline

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:

  • Every step gets a colored dot: green when it worked, red when it failed, orange while it runs.
  • Commands like Bash or PowerShell get a small box with the input and the first lines of output. You can open the box to see everything.
  • Short steps like reading a file stay one simple line.
  • The app's own widgets, like the task list or screenshots, stay the way they are.

Install

Add this folder as a plugin marketplace in Claude Code and install vscode-timeline from it.

License

MIT, see LICENSE.

Source 2 files
hooks/register.tsx 610 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
610
types/index.d.ts 12 lines
1/** 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