SLOPSHOPPER

toolcall-prettify

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

newrows
v0.1.0no licenseupdated 2026-10-09mlopian/claude-plugins/toolcall-prettify
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · toolcall-prettify
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ✎ Edit /work/app/src/auth.ts ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Tool row
✎ Edit /work/app/src/auth.ts
README

Toolcall prettify

Redraws Bash, Read and Edit tool calls in the transcript so it is clear what is the call and what is its output.

  • Bash: the description, then the command with bash syntax colouring on a dark block.
  • Read: the file path and line range, then the file content with syntax colouring and line numbers.
  • Edit: the file path; the diff stays as Claude Code draws it.
  • Output sits in its own tinted block below the call. JSON and diffs are coloured, and so is a file printed with 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.

Install

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.

Transparent terminals

Terminals draw cell backgrounds opaque even when the window is translucent. In Ghostty, background-opacity-cells = true applies background-opacity to them too.

Source 2 files
hooks/register.tsx 130 lines
1import 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}
130
hooks/format.ts 22 lines
1const 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