Folds failed tool calls to one line in the transcript; /errors lists them in full.

Folds failed tool calls to one line.
A failed tool call prints its whole error into the transcript:
errfold records each failure when the call completes, folds its transcript row to one line, and keeps the full list one command away.
Run in the prompt of a terminal session:
/plugin install errfold --marketplace summerz/errfold
Answer y to add the marketplace, then pick a scope.
claude plugin install errfold --marketplace summerz/errfold/plugin does not run. Click + next to the prompt box, then Plugins, then Add marketplace with summerz/errfold, then Add plugin./errors opens a pane that lists the errors, newest first. [clear] (key c) empties the list.status on, the status line shows ✗ n tool errors.lines sets how many lines of each error the pane shows.| Setting | Default | Does |
|---|---|---|
lines | 8 | lines of each error shown in the /errors pane |
status | true | show ✗ n tool errors in the status line |
Change them in the plugin's rows of the config menu, or in ~/.claude/settings.json:
{ "pluginConfigs": { "errfold": { "lines": "12", "status": false } } }
MIT
hooks/register.tsx 112 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import { cut, firstLine, fullText, shortInput, stripAnsi } from './fold'
4
5const PANE = 'errors'
6const MAX = 100
7const WIDTH = 100
8
9type Err = { at: number; tool: string; input: string; text: string; tool_use_id: string }
10// ponytail: module array, a reload drops the log, which is fine
11let log: Err[] = []
12const rev = atom({ plugin: 'errfold', key: 'rev' } as const, 0)
13
14const hhmm = (at: number) => new Date(at).toTimeString().slice(0, 5)
15
16const status = async ($: EngineInterface, show: boolean) => {
17 if (!show) return
18 const n = log.length
19 await $.ui.status(n > 0 ? `✗ ${n} tool error${n === 1 ? '' : 's'} /errors` : undefined)
20}
21
22export const register: Register = (on, options) => {
23 const lines = Math.max(1, Number.parseInt(String(options.lines ?? '8'), 10) || 8)
24 const showStatus = options.status !== false
25
26 on('session.start', async ($, e, next) => {
27 await $.command.register({ name: 'errors', description: 'Toggle the tool errors pane' })
28 return next(e)
29 })
30
31 on('command.run', { command: 'errors' }, async $ => {
32 if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
33 await $.ui.close({ id: PANE })
34 return { text: 'Errors pane closed.' }
35 }
36 await $.ui.open({ id: PANE, title: 'Tool errors', rows: 20, columns: 100, focus: true, closeOnEscape: true })
37 return { text: 'Errors pane opened.' }
38 })
39
40 // tool.call sees every finished call, even in collapsed groups that never render ToolResult rows.
41 on('tool.call', async ($, e, next) => {
42 const r = await next(e)
43 if (r.isError && !log.some(x => x.tool_use_id === e.tool_use_id)) {
44 const { tool, tool_use_id, ...args } = e as unknown as Record<string, unknown> & { tool: string; tool_use_id: string }
45 log = [{ at: Date.now(), tool, input: shortInput(tool, args), text: stripAnsi(r.text ?? '').slice(0, 4000), tool_use_id }, ...log].slice(0, MAX)
46 await update($, rev, n => n + 1)
47 await status($, showStatus)
48 }
49 return r
50 })
51
52 // ToolResult props carry no input, so the ToolUse row (drawn first) leaves it here.
53 const inputs = new Map<string, unknown>()
54 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
55 if (!inputs.has(e.props.tool_use_id)) {
56 inputs.set(e.props.tool_use_id, e.props.input)
57 if (inputs.size > 300) inputs.delete(inputs.keys().next().value as string)
58 }
59 return next(e) // the call row is already one line
60 })
61
62 // Failed results become one line; the person expands (ctrl+o) for the full text.
63 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
64 const p = e.props
65 if (!p.isErrored) return next(e)
66 const input = shortInput(p.tool, inputs.get(p.tool_use_id))
67 const full = stripAnsi(fullText(p.output))
68 if (!log.some(x => x.tool_use_id === p.tool_use_id)) {
69 log = [{ at: Date.now(), tool: p.tool, input, text: full.slice(0, 4000), tool_use_id: p.tool_use_id }, ...log].slice(0, MAX)
70 await update($, rev, n => n + 1)
71 await status($, showStatus)
72 }
73 if (p.isExpanded === true) return next(e)
74 const { Text } = $.ui.resolve(e)
75 const head = `✗ ${p.tool}${input ? ' ' + input : ''}`
76 const msg = firstLine(p.output)
77 const room = Math.max(0, WIDTH - head.length - 3)
78 return (
79 <Text color="error" wrap="truncate-end">
80 <Text bold>✗ {p.tool}</Text>
81 {input ? ' ' + input : ''}
82 {msg ? ' — ' + cut(msg, room) : ''}
83 </Text>
84 )
85 })
86
87 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
88 const { Box, Text, Button } = $.ui.resolve(e)
89 await read($, rev)
90 const clear = async () => {
91 log = []
92 await update($, rev, n => n + 1)
93 await status($, showStatus)
94 }
95 return (
96 <Box flexDirection="column">
97 {log.length === 0 && <Text dimColor>No tool errors this session.</Text>}
98 {log.map(err => {
99 const body = err.text.split('\n').slice(0, lines)
100 return (
101 <Box flexDirection="column" marginBottom={1}>
102 <Text color="error">{hhmm(err.at)} ✗ {err.tool} {err.input}</Text>
103 {body.map(line => <Text dimColor>{line}</Text>)}
104 </Box>
105 )
106 })}
107 {log.length > 0 && <Button key="clear" plain hotkey="c" label="c: [clear]" onPress={clear} />}
108 </Box>
109 )
110 })
111}
112hooks/fold.ts 30 lines1const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
2
3const base = (p: string) => p.split('/').filter(Boolean).pop() ?? p
4
5export const shortInput = (tool: string, input: unknown): string => {
6 if (!input || typeof input !== 'object') return ''
7 const rec = input as Record<string, unknown>
8 if (tool === 'Bash' && typeof rec.command === 'string') return rec.command.slice(0, 60).replace(/\r?\n/g, '⏎')
9 if (['Read', 'Edit', 'Write'].includes(tool) && typeof rec.file_path === 'string') return base(rec.file_path)
10 const first = Object.values(rec).find((v): v is string => typeof v === 'string')
11 return first ? first.slice(0, 60).replace(/\r?\n/g, '⏎') : ''
12}
13
14export const fullText = (output: unknown): string => {
15 if (typeof output === 'string') return output
16 if (Array.isArray(output)) return output.map(fullText).filter(Boolean).join('\n')
17 if (output && typeof output === 'object') {
18 const t = (output as { text?: unknown }).text
19 if (typeof t === 'string') return t
20 }
21 return ''
22}
23
24export const stripAnsi = (s: string): string => s.replace(ANSI, '')
25
26export const firstLine = (output: unknown): string =>
27 stripAnsi(fullText(output)).split('\n').map(l => l.trim()).find(Boolean) ?? ''
28
29export const cut = (s: string, width: number): string => (s.length > width ? s.slice(0, Math.max(0, width - 1)) + '…' : s)
30types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 errfold: { rev: number }
4 }
5}
6