Readable Bash, Read and Edit tool calls: syntax-highlighted commands on a dark block, output in its own tinted block

Redraws Bash, Read and Edit tool calls in the transcript so it is clear what is the call and what is its output.
cat, head, tail, bat or sed -n. Stderr is red. Long output is cut at 40 lines.Groups made only of these tools (Ran 3 shell commands, Read 2 files) are drawn expanded. Groups with other tools are left to Claude Code.
Requires Claude Code with function-hook plugin support. Validated and tested with Claude Code 2.1.296.
Run in Claude Code:
/plugin install toolcall-prettify --marketplace mlopian/claude-plugins
For installation from a local clone, see the repository README. Start a new Claude Code session after installing.
Terminals draw cell backgrounds opaque even when the window is translucent. In Ghostty, background-opacity-cells = true applies background-opacity to them too.
hooks/register.tsx 130 lines1import type { Color, EngineInterface, Register, RenderChildren, RenderElement } from 'claude-code'
2
3import { clip, outputLanguage } from './format'
4
5type BashInput = { command?: string; description?: string }
6type BashOutput = { stdout?: string; stderr?: string; interrupted?: boolean }
7type ReadInput = { file_path?: string; offset?: number; limit?: number }
8type ReadOutput = { file?: { filePath: string; content: string; startLine: number; totalLines: number } }
9type EditInput = { file_path?: string; replace_all?: boolean }
10type Elements = ReturnType<EngineInterface['ui']['resolve']>
11type Call = { tool: string; input: unknown; isRunning: boolean; isErrored: boolean }
12
13const TINT = '#1c1c22'
14const TOOLS = new Set(['Bash', 'Read', 'Edit'])
15
16function readRange(input: ReadInput): string {
17 if (!input.offset && !input.limit) return ''
18 const from = input.offset ?? 1
19
20 return input.limit ? `:${from}-${from + input.limit - 1}` : `:${from}-`
21}
22
23function drawCall({ Box, Text, Code }: Elements, call: Call, width: number | undefined): RenderElement {
24 const status: Color | undefined = call.isErrored ? 'error' : call.isRunning ? 'warning' : undefined
25 const card = (color: Color, icon: string, title: string, detail: string, body?: RenderChildren) => (
26 <Box flexDirection="column" width={width} backgroundColor="#000000" paddingX={1}>
27 <Box flexDirection="row" columnGap={1} marginBottom={body ? 1 : 0}>
28 <Text bold color={status ?? color}>
29 {icon}
30 </Text>
31 <Text bold>{title}</Text>
32 <Text dimColor>{detail}</Text>
33 </Box>
34 {body}
35 </Box>
36 )
37
38 if (call.tool === 'Read') {
39 const input = call.input as ReadInput
40 return card('ide', '▤', 'Read', `${input.file_path ?? ''}${readRange(input)}`)
41 }
42 if (call.tool === 'Edit') {
43 const input = call.input as EditInput
44 return card('permission', '✎', 'Edit', `${input.file_path ?? ''}${input.replace_all ? ' (all)' : ''}`)
45 }
46 const input = call.input as BashInput
47
48 return card('bashBorder', call.isRunning ? '◌' : '❯', '$', input.description ?? '', <Code source={input.command ?? ''} language="bash" />)
49}
50
51function drawOutput({ Box, Text, Code }: Elements, tool: string, output: unknown, command: string, width: number | undefined): RenderElement | null {
52 const panel = (label: string, body: RenderChildren) => (
53 <Box flexDirection="column" width={width && width - 2} backgroundColor={TINT} paddingX={1} marginLeft={2} marginTop={1}>
54 <Box marginBottom={1}>
55 <Text dimColor>{label}</Text>
56 </Box>
57 {body}
58 </Box>
59 )
60 const more = (hidden: number, what: string) => (hidden > 0 ? <Text dimColor>{`… +${hidden} ${what}`}</Text> : null)
61
62 if (tool === 'Read') {
63 const file = (output as ReadOutput | undefined)?.file
64 if (!file) return null
65 const shown = clip(file.content)
66
67 return panel(
68 `${file.totalLines} lines`,
69 <Box flexDirection="column">
70 <Code source={shown.text} path={file.filePath} startLine={file.startLine} />
71 {more(shown.hidden, 'lines')}
72 </Box>,
73 )
74 }
75 if (tool !== 'Bash') return null
76 const result = (output ?? {}) as BashOutput
77 const stdout = clip(result.stdout ?? '')
78 const stderr = clip(result.stderr ?? '')
79 if (!stdout.text && !stderr.text) return null
80
81 return panel(
82 result.interrupted ? 'output (interrupted)' : 'output',
83 <Box flexDirection="column">
84 {stdout.text ? <Code source={stdout.text} {...outputLanguage(command, stdout.text)} /> : null}
85 {more(stdout.hidden, 'lines')}
86 {stderr.text ? <Text color="error">{stderr.text}</Text> : null}
87 {more(stderr.hidden, 'stderr lines')}
88 </Box>,
89 )
90}
91
92function rowWidth(viewport: { columns: number } | undefined): number | undefined {
93 return viewport && viewport.columns - 4
94}
95
96export const register: Register = on => {
97 const commands = new Map<string, string>()
98
99 on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
100 if (!TOOLS.has(e.props.tool)) return next(e)
101 if (e.props.tool === 'Bash') commands.set(e.props.tool_use_id, (e.props.input as BashInput).command ?? '')
102
103 return drawCall($.ui.resolve(e), e.props, rowWidth(e.viewport))
104 })
105
106 on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
107 if (e.props.isErrored) return next(e)
108
109 return drawOutput($.ui.resolve(e), e.props.tool, e.props.output, commands.get(e.props.tool_use_id) ?? '', rowWidth(e.viewport)) ?? next(e)
110 })
111
112 on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
113 if (!e.props.calls.every(call => TOOLS.has(call.tool))) return next(e)
114 const elements = $.ui.resolve(e)
115 const width = rowWidth(e.viewport)
116 const { Box } = elements
117
118 return (
119 <Box flexDirection="column" rowGap={1}>
120 {e.props.calls.map((call, index) => (
121 <Box key={call.tool_use_id ?? String(index)} flexDirection="column">
122 {drawCall(elements, call, width)}
123 {call.isErrored ? null : drawOutput(elements, call.tool, call.output, (call.input as BashInput).command ?? '', width)}
124 </Box>
125 ))}
126 </Box>
127 )
128 })
129}
130hooks/format.ts 22 lines1const MAX_OUTPUT_LINES = 40
2
3export function clip(text: string): { text: string; hidden: number } {
4 const lines = text.replace(/\s+$/, '').split('\n')
5
6 return { text: lines.slice(0, MAX_OUTPUT_LINES).join('\n'), hidden: Math.max(0, lines.length - MAX_OUTPUT_LINES) }
7}
8
9export function outputLanguage(command: string, output: string): { language?: string; path?: string } {
10 const trimmed = output.trim()
11 if (/^[[{]/.test(trimmed)) {
12 try {
13 JSON.parse(trimmed)
14 return { language: 'json' }
15 } catch {}
16 }
17 if (/^(diff --git|--- |@@ )/m.test(trimmed)) return { language: 'diff' }
18 const shown = command.match(/\b(?:cat|head|tail|bat|sed -n \S+)\s+(?:-\S+\s+)*([^\s|;&]+\.\w+)/)
19
20 return shown ? { path: shown[1] } : {}
21}
22