Replaces tool-use rows with diagnostic cards that show status, timing, results, and details

Tool Cards replaces Claude Code's standard tool-use row with a clean, spacious card that makes the action, current state, tool, timing, and result easy to scan. Each card uses a tool-specific icon, a human-readable action title, muted metadata, and a right-aligned Details control when output is available. Tool output stays collapsed by default, including structured results from MCP tools.
VS Code uses one full-width header row with light padding and no icon tile. Output opens beneath it. The extension adds its own plugin attribution and outer frame; the mod does not control them. Desktop uses the full padded card treatment. The terminal uses a compact two-line row with no icon tile, outer margin, or vertical card padding so repeated tool calls do not take over the transcript.

Add the marketplace once, then install the mod:
claude plugin marketplace add bradtraversy/traversy-claude-mods
claude plugin install tool-cards@traversy-mods
Restart the session or enter /reload-plugins after installation. Run /plugin to confirm it is enabled.
RunningCompletedFailedStoppedEach tool family has its own compact marker, so Bash, file operations, searches, agents, and MCP tools are easy to distinguish without relying on color alone. Running tools show live elapsed time. Completed tools keep their final duration when Tool Cards observed the call from its start.
The icon set uses >_ for Bash, ▤ for reads, ✎ for edits, + for writes, ⌕ for searches, ↗ for web calls, ◎ for agents, and ◆ for MCP tools.
Claude Code normally collapses consecutive reads, searches, and listings into a summary line. Tool Cards keeps those groups expanded so completed cards remain visible in the transcript.
Tool Cards adds a concise result line when the tool returns structured information:
Bash output and edit patches have a right-aligned Details control. It changes to Hide details while the output is open. Failed calls open their details automatically so the error is visible immediately. Large output is capped to keep the transcript responsive, and oversized diffs report that they cannot be displayed instead of rendering a partial hunk.
For file tools, the card displays the file path. For Bash, it displays the command. It also handles tools that expose a path or pattern field.
tool-cards.Running to Completed and retain its elapsed time.Details and confirm that the highlighted diff appears inside the card.Failed card.Stopped uses the muted inactive color rather than the green success color.The green Completed label is part of this mod. Disable Tool Cards from /plugin to return to Claude Code's normal tool rows.
claude plugin uninstall tool-cards@traversy-mods
hooks/register.tsx 598 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3const MAX_DETAIL_CHARS = 6_000
4const MAX_TRACKED_TOOLS = 200
5
6type ToolState = 'running' | 'done' | 'failed' | 'stopped'
7
8type ToolTiming = {
9 startedAt: number
10 endedAt?: number
11}
12
13type ToolDetail = {
14 source: string
15 format: 'source' | 'diff'
16 path?: string
17}
18
19const timings = new Map<string, ToolTiming>()
20const expansionOverrides = new Map<string, boolean>()
21let currentTime = 0
22let activeTools = 0
23let sessionGeneration = 0
24let stopTicker: Timer | null = null
25
26function isRecord(value: unknown): value is Record<string, unknown> {
27 return typeof value === 'object' && value !== null
28}
29
30function asNumber(value: unknown): number | undefined {
31 return typeof value === 'number' && Number.isFinite(value) ? value : undefined
32}
33
34function asString(value: unknown): string | undefined {
35 return typeof value === 'string' ? value : undefined
36}
37
38function truncate(value: string, maxLength: number): string {
39 if (value.length <= maxLength) return value
40 return `${value.slice(0, Math.max(0, maxLength - 3))}...`
41}
42
43function clearSessionState(): void {
44 sessionGeneration += 1
45 timings.clear()
46 expansionOverrides.clear()
47 currentTime = 0
48 activeTools = 0
49}
50
51function stopClockTicker(): void {
52 stopTicker?.cancel()
53 stopTicker = null
54}
55
56function rememberTiming(toolUseId: string, timing: ToolTiming): void {
57 timings.delete(toolUseId)
58 timings.set(toolUseId, timing)
59
60 if (timings.size > MAX_TRACKED_TOOLS) {
61 const oldest = timings.keys().next().value
62 if (oldest !== undefined) {
63 timings.delete(oldest)
64 expansionOverrides.delete(oldest)
65 }
66 }
67}
68
69async function readClock($: EngineInterface): Promise<number | undefined> {
70 try {
71 return await $.clock.now()
72 } catch {
73 return undefined
74 }
75}
76
77function startClockTicker($: EngineInterface): void {
78 stopClockTicker()
79 stopTicker = $.clock.every(1_000, () => {
80 if (activeTools === 0) return
81
82 void readClock($).then(now => {
83 if (now === undefined || activeTools === 0) return
84 currentTime = now
85 $.ui.invalidate('ui.render')
86 })
87 })
88}
89
90function compactPath(value: string): string {
91 const parts = value.replaceAll('\\', '/').split('/').filter(Boolean)
92 return parts.length > 1 ? parts.slice(-2).join('/') : value
93}
94
95function quoted(value: string): string {
96 return `"${truncate(value, 56)}"`
97}
98
99function actionTitle(tool: string, input: unknown): string {
100 if (!isRecord(input)) return `${toolLabel(tool)} call`
101
102 const description = asString(input.description)
103 if (description) return truncate(description, 78)
104
105 const subject = asString(input.subject)
106 if (tool.endsWith('__task_create') && subject) return truncate(subject, 78)
107 if (tool.endsWith('__task_update')) return 'Update Work Queue task'
108 if (tool.endsWith('__task_list')) return 'Review Work Queue'
109 if (tool.endsWith('__task_clear')) return 'Clear Work Queue'
110
111 const filePath = asString(input.file_path) ?? asString(input.path)
112 const target = filePath ? compactPath(filePath) : undefined
113
114 if (tool === 'Bash') return 'Run terminal command'
115 if (tool === 'Read') return target ? `Read ${target}` : 'Read file'
116 if (tool === 'Edit' || tool === 'NotebookEdit') return target ? `Edit ${target}` : 'Edit file'
117 if (tool === 'Write') return target ? `Write ${target}` : 'Write file'
118
119 const pattern = asString(input.pattern)
120 if (tool === 'Glob') return pattern ? `Find files matching ${quoted(pattern)}` : 'Find files'
121 if (tool === 'Grep') return pattern ? `Search for ${quoted(pattern)}` : 'Search project'
122
123 const query = asString(input.query)
124 if (tool === 'WebSearch') return query ? `Search the web for ${quoted(query)}` : 'Search the web'
125
126 const url = asString(input.url)
127 if (tool === 'WebFetch') return url ? `Open ${truncate(url, 64)}` : 'Open web page'
128
129 if (tool === 'Agent') return subject ? truncate(subject, 78) : 'Run agent task'
130
131 return `${toolLabel(tool)} call`
132}
133
134function status(isRunning: boolean, isErrored: boolean, isInterrupted: boolean): ToolState {
135 if (isInterrupted) return 'stopped'
136 if (isErrored) return 'failed'
137 if (isRunning) return 'running'
138 return 'done'
139}
140
141function statusColor(state: ToolState): 'success' | 'warning' | 'error' | 'inactive' {
142 if (state === 'failed') return 'error'
143 if (state === 'running') return 'warning'
144 if (state === 'stopped') return 'inactive'
145 return 'success'
146}
147
148function statusLabel(state: ToolState): string {
149 if (state === 'running') return 'Running'
150 if (state === 'done') return 'Completed'
151 if (state === 'failed') return 'Failed'
152 return 'Stopped'
153}
154
155function toolGlyph(tool: string): string {
156 if (tool === 'Bash') return '>_'
157 if (tool === 'Read') return '▤'
158 if (tool === 'Edit' || tool === 'NotebookEdit') return '✎'
159 if (tool === 'Write') return '+'
160 if (tool === 'Glob' || tool === 'Grep') return '⌕'
161 if (tool === 'WebSearch' || tool === 'WebFetch') return '↗'
162 if (tool === 'Agent') return '◎'
163 if (tool.startsWith('mcp__')) return '◆'
164 return '•'
165}
166
167function toolColor(tool: string): string {
168 if (tool === 'Bash') return 'claude'
169 if (tool === 'Read') return 'ide'
170 if (tool === 'Edit' || tool === 'NotebookEdit') return 'diffAdded'
171 if (tool === 'Write') return 'success'
172 if (tool === 'Glob' || tool === 'Grep') return 'warning'
173 if (tool === 'WebSearch' || tool === 'WebFetch') return 'ide'
174 if (tool === 'Agent') return 'remember'
175 if (tool.startsWith('mcp__')) return 'permission'
176 return 'subtle'
177}
178
179function toolLabel(tool: string): string {
180 if (!tool.startsWith('mcp__')) return truncate(tool, 24)
181
182 const [, server = 'mcp', ...nameParts] = tool.split('__')
183 const name = nameParts.join(' ').replaceAll('_', ' ')
184 return truncate(`${server}: ${name || 'tool'}`, 24)
185}
186
187function lineCount(value: string): number {
188 const normalized = value.trimEnd()
189 if (!normalized) return 0
190 return normalized.split('\n').length
191}
192
193function formatCount(value: number): string {
194 if (value < 1_000) return String(value)
195 if (value < 1_000_000) return `${Math.round(value / 100) / 10}k`
196 return `${Math.round(value / 100_000) / 10}m`
197}
198
199function formatDuration(durationMs: number): string {
200 if (durationMs < 1_000) return '<1s'
201 if (durationMs < 10_000) {
202 const seconds = Math.round(durationMs / 100) / 10
203 return `${seconds}s`
204 }
205 if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`
206
207 const minutes = Math.floor(durationMs / 60_000)
208 const seconds = Math.round((durationMs % 60_000) / 1_000)
209 return `${minutes}m ${String(seconds).padStart(2, '0')}s`
210}
211
212function timingLabel(toolUseId: string, output: unknown): string {
213 const timing = timings.get(toolUseId)
214 if (timing) {
215 const endedAt = timing.endedAt ?? currentTime
216 return formatDuration(Math.max(0, endedAt - timing.startedAt))
217 }
218
219 if (isRecord(output)) {
220 const duration = asNumber(output.totalDurationMs) ?? asNumber(output.durationMs)
221 if (duration !== undefined) return formatDuration(duration)
222 }
223
224 return ''
225}
226
227function diffSummary(output: Record<string, unknown>): string {
228 const gitDiff = isRecord(output.gitDiff) ? output.gitDiff : undefined
229 const additions = gitDiff ? asNumber(gitDiff.additions) : undefined
230 const deletions = gitDiff ? asNumber(gitDiff.deletions) : undefined
231
232 if (additions !== undefined && deletions !== undefined) {
233 return `+${additions} -${deletions}`
234 }
235
236 return ''
237}
238
239function bashFileCount(output: Record<string, unknown>): number {
240 const editDiff = isRecord(output.bashEditDiff) ? output.bashEditDiff : undefined
241 const files = editDiff && Array.isArray(editDiff.files) ? editDiff.files.length : 0
242 const moreFiles = editDiff ? asNumber(editDiff.moreFiles) ?? 0 : 0
243 return files + moreFiles
244}
245
246function resultSummary(tool: string, output: unknown, state: ToolState): string {
247 if (state === 'running') return ''
248 if (state === 'stopped') return 'Interrupted'
249 if (!isRecord(output)) return state === 'failed' ? 'Open details for the error' : ''
250
251 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') {
252 if (output.staged === true) return 'Staged for review'
253 const summary = diffSummary(output)
254 if (summary) return summary
255 if (tool === 'Write' && (output.type === 'create' || output.type === 'update')) {
256 return output.type === 'create' ? 'File created' : 'File updated'
257 }
258 }
259
260 if (tool === 'Read') {
261 const file = isRecord(output.file) ? output.file : undefined
262 const lines = file ? asNumber(file.numLines) : undefined
263 const total = file ? asNumber(file.totalLines) : undefined
264 if (lines !== undefined && total !== undefined) return `${lines} of ${total} lines`
265
266 const dimensions = file && isRecord(file.dimensions) ? file.dimensions : undefined
267 const width = dimensions ? asNumber(dimensions.originalWidth) : undefined
268 const height = dimensions ? asNumber(dimensions.originalHeight) : undefined
269 if (width !== undefined && height !== undefined) return `${width} x ${height}`
270 }
271
272 if (tool === 'Bash') {
273 const backgroundTaskId = asString(output.backgroundTaskId)
274 if (backgroundTaskId) return `Background task ${backgroundTaskId}`
275
276 const changedFiles = bashFileCount(output)
277 if (changedFiles > 0) return `${changedFiles} file${changedFiles === 1 ? '' : 's'} changed`
278
279 const stdout = asString(output.stdout) ?? ''
280 const stderr = asString(output.stderr) ?? ''
281 const outputLines = lineCount(stdout) + lineCount(stderr)
282 if (outputLines > 0) return `${outputLines} output line${outputLines === 1 ? '' : 's'}`
283 if (output.noOutputExpected === true) return 'No output expected'
284 }
285
286 if (tool === 'Agent' && output.status === 'completed') {
287 const parts: string[] = []
288 const tokens = asNumber(output.totalTokens)
289 const tools = asNumber(output.totalToolUseCount)
290 if (tokens !== undefined) parts.push(`${formatCount(tokens)} tokens`)
291 if (tools !== undefined) parts.push(`${tools} tool${tools === 1 ? '' : 's'}`)
292 return parts.join(' | ')
293 }
294
295 return state === 'failed' ? 'Open details for the error' : ''
296}
297
298function outputDetail(tool: string, output: unknown): ToolDetail | null {
299 if (typeof output === 'string' && output) {
300 try {
301 const parsed = JSON.parse(output)
302 if (isRecord(parsed) || Array.isArray(parsed)) {
303 return {
304 source: truncate(JSON.stringify(parsed, null, 2), MAX_DETAIL_CHARS),
305 format: 'source',
306 }
307 }
308 } catch {
309 // Plain-text output stays plain text.
310 }
311
312 return {
313 source: truncate(output, MAX_DETAIL_CHARS),
314 format: 'source',
315 }
316 }
317
318 if (!isRecord(output)) return null
319
320 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') {
321 const gitDiff = isRecord(output.gitDiff) ? output.gitDiff : undefined
322 const patch = gitDiff ? asString(gitDiff.patch) : undefined
323 if (patch) {
324 if (patch.length > MAX_DETAIL_CHARS) {
325 return {
326 source: `Diff is too large to display (${Math.round(patch.length / 1_000)}k characters).`,
327 format: 'source',
328 }
329 }
330
331 return {
332 source: patch,
333 format: 'diff',
334 path: gitDiff ? asString(gitDiff.filename) : undefined,
335 }
336 }
337 }
338
339 if (tool === 'Bash') {
340 const stdout = asString(output.stdout) ?? ''
341 const stderr = asString(output.stderr) ?? ''
342 const sections = [
343 stdout ? stdout.trimEnd() : '',
344 stderr ? `stderr:\n${stderr.trimEnd()}` : '',
345 ].filter(Boolean)
346
347 if (sections.length > 0) {
348 return {
349 source: truncate(sections.join('\n\n'), MAX_DETAIL_CHARS),
350 format: 'source',
351 }
352 }
353 }
354
355 if (typeof output.text === 'string' && output.text) {
356 return {
357 source: truncate(output.text, MAX_DETAIL_CHARS),
358 format: 'source',
359 }
360 }
361
362 const serialized = JSON.stringify(output, null, 2)
363 if (serialized) {
364 return {
365 source: truncate(serialized, MAX_DETAIL_CHARS),
366 format: 'source',
367 }
368 }
369
370 return null
371}
372
373export const register: Register = on => {
374 on('session.start', async ($, event, next) => {
375 stopClockTicker()
376 clearSessionState()
377 currentTime = await readClock($) ?? 0
378 startClockTicker($)
379 return next(event)
380 })
381
382 on('session.end', ($, event, next) => {
383 clearSessionState()
384 if (event.reason !== 'clear' && event.reason !== 'resume') stopClockTicker()
385 $.ui.invalidate('ui.render')
386 return next(event)
387 })
388
389 on('tool.call', async ($, event, next) => {
390 const generation = sessionGeneration
391 const startedAt = await readClock($)
392
393 if (startedAt !== undefined && generation === sessionGeneration) {
394 currentTime = startedAt
395 rememberTiming(event.tool_use_id, { startedAt })
396 activeTools += 1
397 $.ui.invalidate('ui.render')
398 }
399
400 try {
401 return await next(event)
402 } finally {
403 if (generation === sessionGeneration && startedAt !== undefined) {
404 const endedAt = await readClock($)
405 if (generation === sessionGeneration) {
406 activeTools = Math.max(0, activeTools - 1)
407 if (endedAt !== undefined) {
408 currentTime = endedAt
409 rememberTiming(event.tool_use_id, { startedAt, endedAt })
410 $.ui.invalidate('ui.render')
411 } else {
412 timings.delete(event.tool_use_id)
413 }
414 }
415 }
416 }
417 })
418
419 on('ui.render', { component: 'ToolGroup' }, ($, event, next) => {
420 if (event.props.isExpanded) return next(event)
421
422 return next({
423 ...event,
424 props: {
425 ...event.props,
426 isExpanded: true,
427 },
428 })
429 })
430
431 on('ui.render', { component: 'ToolUse' }, ($, event) => {
432 const { Box, Button, Code, Text } = $.ui.resolve(event)
433 const state = status(event.props.isRunning, event.props.isErrored, event.props.isInterrupted)
434 const color = statusColor(state)
435 const summary = resultSummary(event.props.tool, event.props.output, state)
436 const duration = timingLabel(event.props.tool_use_id, event.props.output)
437 const detail = outputDetail(event.props.tool, event.props.output)
438 const expanded = expansionOverrides.get(event.props.tool_use_id) ?? event.props.isErrored
439 const toggleDetails = (): void => {
440 expansionOverrides.set(event.props.tool_use_id, !expanded)
441 $.ui.invalidate('ui.render')
442 }
443
444 if (event.surface === 'vscode') {
445 return (
446 <Box flexDirection="column" minWidth={0} overflow="hidden" paddingX={0.5} paddingY={0.25}>
447 <Box alignItems="center" gap={1} overflow="hidden">
448 <Text color={toolColor(event.props.tool)} bold>{toolGlyph(event.props.tool)}</Text>
449 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
450 <Text bold wrap="truncate-middle">{actionTitle(event.props.tool, event.props.input)}</Text>
451 </Box>
452 <Text color={color}>{statusLabel(state)}</Text>
453 <Text dimColor>{toolLabel(event.props.tool)}</Text>
454 {duration && <Text dimColor>{duration}</Text>}
455 {summary && <Text dimColor wrap="truncate-middle">{summary}</Text>}
456 {detail && (
457 <Box flexShrink={0}>
458 <Button
459 key={`tool-card-details:${event.props.tool_use_id}`}
460 label={expanded ? 'Hide details' : 'Details'}
461 variant="secondary"
462 onPress={toggleDetails}
463 />
464 </Box>
465 )}
466 </Box>
467 {detail && expanded && (
468 <Code source={detail.source} format={detail.format} path={detail.path} wrap="truncate-end" />
469 )}
470 </Box>
471 )
472 }
473
474 if (event.surface === 'terminal') {
475 return (
476 <Box flexDirection="column" overflow="hidden">
477 <Box justifyContent="space-between" alignItems="center" gap={1} overflow="hidden">
478 <Box gap={1} flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
479 <Text color={toolColor(event.props.tool)} bold>{toolGlyph(event.props.tool)}</Text>
480 <Text bold wrap="truncate-middle">
481 {actionTitle(event.props.tool, event.props.input)}
482 </Text>
483 </Box>
484 {detail && (
485 <Box flexShrink={0}>
486 <Button
487 key={`tool-card-details:${event.props.tool_use_id}`}
488 label={expanded ? 'Hide details' : 'Details'}
489 variant="secondary"
490 onPress={toggleDetails}
491 />
492 </Box>
493 )}
494 </Box>
495 <Box marginLeft={3} gap={1} alignItems="center" flexWrap="wrap" overflow="hidden">
496 <Text color={color} bold>{statusLabel(state)}</Text>
497 <Text dimColor>·</Text>
498 <Text dimColor>{toolLabel(event.props.tool)}</Text>
499 {duration && <Text dimColor>·</Text>}
500 {duration && <Text dimColor>{duration}</Text>}
501 {summary && <Text dimColor>·</Text>}
502 {summary && <Text dimColor wrap="truncate-middle">{summary}</Text>}
503 </Box>
504 {detail && expanded && (
505 <Box marginLeft={3} marginTop={1}>
506 <Code
507 source={detail.source}
508 format={detail.format}
509 path={detail.path}
510 wrap="truncate-end"
511 />
512 </Box>
513 )}
514 </Box>
515 )
516 }
517
518 return (
519 <Box
520 borderStyle="round"
521 borderDimColor
522 paddingX={2}
523 paddingY={1}
524 marginY={1}
525 flexDirection="column"
526 overflow="hidden"
527 gap={1}
528 >
529 <Box justifyContent="space-between" alignItems="center" gap={2} overflow="hidden">
530 <Box
531 gap={2}
532 alignItems="center"
533 flexGrow={1}
534 flexShrink={1}
535 minWidth={0}
536 overflow="hidden"
537 >
538 <Box
539 borderStyle="round"
540 borderColor={toolColor(event.props.tool)}
541 backgroundColor={toolColor(event.props.tool)}
542 paddingX={1}
543 flexShrink={0}
544 >
545 <Text color="inverseText" bold>{toolGlyph(event.props.tool)}</Text>
546 </Box>
547 <Box
548 flexDirection="column"
549 flexGrow={1}
550 flexShrink={1}
551 minWidth={0}
552 overflow="hidden"
553 >
554 <Text bold wrap="truncate-middle">
555 {actionTitle(event.props.tool, event.props.input)}
556 </Text>
557 <Box gap={1} alignItems="center" flexWrap="wrap" overflow="hidden">
558 <Text color={color} bold>{statusLabel(state)}</Text>
559 <Text dimColor>·</Text>
560 <Text dimColor>{toolLabel(event.props.tool)}</Text>
561 {duration && <Text dimColor>·</Text>}
562 {duration && <Text dimColor>{duration}</Text>}
563 {summary && <Text dimColor>·</Text>}
564 {summary && <Text dimColor wrap="truncate-middle">{summary}</Text>}
565 </Box>
566 </Box>
567 </Box>
568 {detail && (
569 <Box flexShrink={0}>
570 <Button
571 key={`tool-card-details:${event.props.tool_use_id}`}
572 label={expanded ? 'Hide details' : 'Details'}
573 variant="secondary"
574 onPress={toggleDetails}
575 />
576 </Box>
577 )}
578 </Box>
579 {detail && expanded && (
580 <Box marginLeft={7} paddingX={1} paddingY={1} borderStyle="round" borderDimColor>
581 <Code
582 source={detail.source}
583 format={detail.format}
584 path={detail.path}
585 wrap="truncate-end"
586 />
587 </Box>
588 )}
589 </Box>
590 )
591 })
592
593 on('ui.render', { component: 'ToolResult' }, ($, event) => {
594 const { Box } = $.ui.resolve(event)
595 return <Box />
596 })
597}
598