Draws successful tool calls as one dim line in the terminal and hides their output; a call a hook refused is one red line; failed calls stay in full…

A long turn reads and searches dozens of times, and every call draws its own block with its output under it. quiet-tools draws each successful call as one dim line and hides its output:
✓ Read · README.md
✓ Bash · Run the unit tests
✓ Read 3 files, searched 2 patterns
A call still running shows … in place of ✓.
A call that a PreToolUse hook refused never ran, so there is nothing to read but the reason; it becomes one red line:
✗ Bash · git commit -m "…" — blocked by hook: ⚠️ VERIFICATION GATE: run the tests first
Calls that ran and failed, and interrupted ones, keep the full row, so errors stay where you will see them. SendUserFile cards also stay full: the file they put in front of you is the point of the call. ctrl+o (or --verbose) still expands everything as usual.
/quiet-tools turns it off and on for the current session.
language: en (default) or zh-TW, from /config or settings:
"pluginConfigs": { "quiet-tools@claude-code-mods": { "options": { "language": "zh-TW" } } }hooks/register.tsx 166 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3import { stringsFor } from './strings'
4import type { Strings } from './strings'
5
6// ponytail: on/off lives in session state and resets to on each session; persist it in $.store if that gets annoying
7const isOn = atom({ plugin: 'quiet-tools', key: 'isOn' } as const, true)
8
9const FIELDS = ['description', 'command', 'file_path', 'pattern', 'url', 'query', 'prompt'] as const
10
11// The card itself is the deliverable (a file put in front of the owner), so folding it hides the thing asked for.
12const ALWAYS_FULL = new Set(['SendUserFile'])
13
14export function summarize(input: unknown): string {
15 if (typeof input !== 'object' || input === null) return ''
16 const fields = input as Record<string, unknown>
17 for (const name of FIELDS) {
18 const value = fields[name]
19 if (typeof value !== 'string' || value.trim() === '') continue
20 const text = name === 'file_path' ? (value.split('/').pop() ?? value) : value
21 return text.replace(/\s+/g, ' ').trim()
22 }
23 return ''
24}
25
26const GROUP_WORDS: Record<string, [string, string]> = {
27 Read: ['read', 'file'],
28 Grep: ['searched', 'pattern'],
29 Glob: ['searched', 'pattern'],
30 Bash: ['ran', 'command'],
31}
32
33export function summarizeGroup(tools: readonly string[]): string {
34 const counts = new Map<string, number>()
35 for (const tool of tools) counts.set(tool, (counts.get(tool) ?? 0) + 1)
36 const parts = [...counts].map(([tool, n]) => {
37 const words = GROUP_WORDS[tool]
38 if (words === undefined) return `${tool} ×${n}`
39 return `${words[0]} ${n} ${words[1]}${n === 1 ? '' : 's'}`
40 })
41 const text = parts.join(', ')
42 return text.charAt(0).toUpperCase() + text.slice(1)
43}
44
45// A PreToolUse hook refused the call before it ran: there is no output to read, and
46// the reason fits on one line. A command that ran and failed is not this.
47const HOOK_DENIED = /^(?:Error:\s*)?(?:<tool_use_error>\s*)?PreToolUse:(\S+) hook error:\s*/
48
49/**
50 * The first line of the refusing hook's reason when a PreToolUse hook refused this
51 * call to `tool`; null otherwise, and null when the hook gave no reason (the full
52 * row then says what little there is).
53 */
54export function hookDenial(output: unknown, tool: string): string | null {
55 const text = typeof output === 'string'
56 ? output
57 : Array.isArray(output)
58 ? output.map(block => (block && typeof block === 'object' && typeof block.text === 'string' ? block.text : '')).join('\n')
59 : ''
60 const trimmed = text.trimStart()
61 const match = HOOK_DENIED.exec(trimmed)
62 if (match === null || match[1] !== tool) return null
63 const reason = trimmed.slice(match[0].length).replace(/<\/tool_use_error>\s*$/, '')
64 .split(/\r?\n/).map(line => line.trim()).find(Boolean)
65 return reason || null
66}
67
68function deniedLine(tool: string, detail: string, reason: string, s: Strings): string {
69 return `✗ ${tool}${detail === '' ? '' : ` · ${detail}`} — ${s.blocked}${reason}`
70}
71
72export const register: Register = (on, options) => {
73 const s = stringsFor(options?.language)
74
75 on('session.start', async ($, e, next) => {
76 await $.command.register({
77 name: 'quiet-tools',
78 description: 'Toggle one-line tool rows: successful calls draw as one dim line, their output hidden',
79 })
80
81 return next(e)
82 })
83
84 on('command.run', { command: 'quiet-tools' }, async $ => {
85 const now = await update($, isOn, value => !value)
86
87 return { text: now ? s.on : s.off }
88 })
89
90 // Failed and interrupted calls keep the engine's full row, so failures stay visible;
91 // a call a hook refused is one red line naming the reason.
92 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
93 if (e.surface !== 'terminal' || e.props.isInterrupted || ALWAYS_FULL.has(e.props.tool)) return next(e)
94 const denial = e.props.isErrored ? hookDenial(e.props.output, e.props.tool) : null
95 if (e.props.isErrored && denial === null) return next(e)
96 if (!(await read($, isOn))) return next(e)
97
98 const { Box, Text } = $.ui.resolve(e)
99 const detail = summarize(e.props.input)
100
101 if (denial !== null) {
102 return (
103 <Box key="quiet-denied">
104 <Text color="error" wrap="truncate-end">
105 {deniedLine(e.props.tool, detail, denial, s)}
106 </Text>
107 </Box>
108 )
109 }
110
111 return (
112 <Box key="quiet-row">
113 <Text dimColor wrap="truncate-end">
114 {e.props.isRunning ? '… ' : '✓ '}
115 {e.props.tool}
116 {detail === '' ? '' : ` · ${detail}`}
117 </Text>
118 </Box>
119 )
120 })
121
122 // isExpanded is ctrl+o or --verbose: the engine's expanded rows stay reachable there.
123 // A refused call in the group gets its own red line; any other failure unfolds it.
124 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
125 if (e.surface !== 'terminal' || e.props.isExpanded) return next(e)
126 const calls = e.props.calls.map(call => ({ call, denial: call.isErrored ? hookDenial(call.output, call.tool) : null }))
127 if (calls.some(({ call, denial }) => (call.isErrored && denial === null) || call.isInterrupted || ALWAYS_FULL.has(call.tool))) return next(e)
128 if (!(await read($, isOn))) return next(e)
129
130 const { Box, Text } = $.ui.resolve(e)
131 const ran = calls.filter(({ denial }) => denial === null).map(({ call }) => call)
132 const isRunning = ran.some(call => call.isRunning)
133
134 return (
135 <Box key="quiet-group" flexDirection="column">
136 {ran.length > 0 && (
137 <Box key="quiet-group-ran">
138 <Text dimColor wrap="truncate-end">
139 {isRunning ? '… ' : '✓ '}
140 {summarizeGroup(ran.map(call => call.tool))}
141 </Text>
142 </Box>
143 )}
144 {calls.flatMap(({ call, denial }, i) => denial === null ? [] : [
145 <Box key={`quiet-group-denied-${i}`}>
146 <Text color="error" wrap="truncate-end">
147 {deniedLine(call.tool, summarize(call.input), denial, s)}
148 </Text>
149 </Box>,
150 ])}
151 </Box>
152 )
153 })
154
155 // The refused call's row already carries the reason; its error block would repeat it.
156 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
157 if (e.surface !== 'terminal' || ALWAYS_FULL.has(e.props.tool)) return next(e)
158 if (e.props.isErrored && hookDenial(e.props.output, e.props.tool) === null) return next(e)
159 if (!(await read($, isOn))) return next(e)
160
161 const { Box } = $.ui.resolve(e)
162
163 return <Box key="quiet-result" />
164 })
165}
166hooks/strings.ts 23 lines1export type Strings = {
2 on: string
3 off: string
4 blocked: string
5}
6
7export const STRINGS: Record<'en' | 'zh-TW', Strings> = {
8 en: {
9 on: 'quiet-tools on: successful tool calls draw as one line.',
10 off: 'quiet-tools off: tool rows draw in full.',
11 blocked: 'blocked by hook: ',
12 },
13 'zh-TW': {
14 on: 'quiet-tools 已開啟:成功的工具呼叫收成一行。',
15 off: 'quiet-tools 已關閉:工具呼叫完整顯示。',
16 blocked: 'hook 擋下:',
17 },
18}
19
20export function stringsFor(language: unknown): Strings {
21 return language === 'zh-TW' ? STRINGS['zh-TW'] : STRINGS.en
22}
23types/index.d.ts 8 lines1export type QuietToolsMode = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'quiet-tools': { isOn: QuietToolsMode }
6 }
7}
8