Quiets everything in the transcript except Claude's answers: narration, tool calls and command output draw in a faint tinted italic.

A Claude Code mod that turns down everything in the transcript except Claude's answers.

The text Claude writes before a tool call, the tool calls themselves, grouped reads and searches, and slash-command output are drawn in a faint, tinted italic. The answer that ends each turn keeps its normal look, older ones further up included. Added and removed line counts stay green and red, and a failed call still says "failed" in red.
Each quieted tool line has a details button that brings back the app's own card with the full output and diff. hide details folds it away again.
It works in the Code tab of the Claude desktop app and in the terminal.
At the prompt of a terminal session:
/plugin install quiet-transcript --marketplace kevlar10x/quiet-transcript
Answer y to add the marketplace, then pick the user scope.
From a shell:
claude plugin install quiet-transcript --marketplace kevlar10x/quiet-transcript
In the desktop app, where /plugin doesn't run: click + next to the prompt box, then Plugins, Add marketplace with kevlar10x/quiet-transcript, then Add plugin.
| Part of the transcript | How it's drawn |
|---|---|
| Claude's answer | As before |
| Text written before a tool call | Faint tinted italic |
| A tool call | One faint line, such as Edited src/app.ts +2 -1, with a details button |
| A group of reads and searches | One faint line, such as Read 2 files, searched 1 pattern |
| Slash-command output | Dimmed. Errors are left as they are. |
Your own messages, Claude's question cards, background-task notices and the "working" indicator are left alone.
claude plugin uninstall quiet-transcript
MIT
If you'd like to leave a tip: https://ko-fi.com/kevlar10x
hooks/register.tsx 255 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register, ToolGroupCall } from 'claude-code'
3
4// Quiets everything the transcript shows except Claude's answers. An answer is
5// the text of a model request that called no tool; text written before a tool
6// call is narration. Narration, tool rows, tool groups and command output draw
7// in one quiet style, while added and removed line counts and failures keep
8// their colors. Each quieted tool row has a "details" button that brings back
9// the app's own full card.
10
11const narration = atom({ plugin: 'quiet-transcript', key: 'narration' } as const, [])
12const isOpen = atom({ plugin: 'quiet-transcript', key: 'isOpen' } as const, false)
13
14// Italic, in the theme's suggestion tint, dimmed: set apart from the app's own grey rows.
15const QUIET = { color: 'suggestion', dimColor: true, italic: true } as const
16const KEEP_HASHES = 2000
17
18// FNV-1a over a paragraph with its whitespace collapsed.
19function hash(text: string) {
20 let h = 0x811c9dc5
21 for (let i = 0; i < text.length; i++) {
22 h ^= text.charCodeAt(i)
23 h = Math.imul(h, 0x01000193)
24 }
25 return (h >>> 0).toString(36)
26}
27
28function hashesOf(text: string) {
29 return text
30 .split(/\n\s*\n/)
31 .map(p => p.replace(/\s+/g, ' ').trim())
32 .filter(Boolean)
33 .map(hash)
34}
35
36// Markdown to plain text, for narration drawn as one quiet Text.
37function plain(text: string) {
38 return text
39 .replace(/```[^\n]*\n?/g, '')
40 .replace(/`([^`]+)`/g, '$1')
41 .replace(/\*\*([^*]+)\*\*|__([^_]+)__/g, '$1$2')
42 .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
43 .replace(/^#{1,6}\s+/gm, '')
44 .trim()
45}
46
47function short(text: string, room = 70) {
48 const line = text.replace(/\s+/g, ' ').trim()
49 return line.length > room ? `${line.slice(0, room - 1)}…` : line
50}
51
52function fileName(path: string) {
53 return path.split(/[\\/]/).filter(Boolean).slice(-2).join('/')
54}
55
56// One line saying what a tool call did.
57function describe(tool: string, input: unknown) {
58 const fields = (input ?? {}) as Record<string, unknown>
59 const field = (name: string) => (typeof fields[name] === 'string' ? (fields[name] as string) : '')
60 switch (tool) {
61 case 'Bash':
62 case 'PowerShell': {
63 // A description already reads as an action ("Run the tests"); a bare command gets "Ran".
64 const description = short(field('description'))
65 return description ? description.charAt(0).toUpperCase() + description.slice(1) : `Ran ${short(field('command'))}`
66 }
67 case 'Read':
68 return `Read ${fileName(field('file_path'))}`
69 case 'Edit':
70 case 'MultiEdit':
71 return `Edited ${fileName(field('file_path'))}`
72 case 'Write':
73 return `Wrote ${fileName(field('file_path'))}`
74 case 'NotebookEdit':
75 return `Edited ${fileName(field('notebook_path'))}`
76 case 'Glob':
77 return `Listed ${short(field('pattern'))}`
78 case 'Grep':
79 return `Searched for ${short(field('pattern'))}`
80 case 'WebFetch':
81 return `Fetched ${short(field('url'))}`
82 case 'WebSearch':
83 return `Searched the web for ${short(field('query'))}`
84 case 'Agent':
85 case 'Task':
86 return `Agent: ${short(field('description'))}`
87 case 'Skill':
88 return `Used skill ${field('skill')}`
89 }
90 if (tool.startsWith('mcp__')) {
91 const [, server = '', name = tool] = tool.split('__')
92 const where = server.length <= 24 ? ` (${server.replace(/_/g, ' ')})` : ''
93 return `${name.replace(/_/g, ' ')}${where}`
94 }
95 const first = Object.values(fields).find((v): v is string => typeof v === 'string')
96 return first ? `${tool} ${short(first, 50)}` : tool
97}
98
99// Lines in `after` that `before` lacks, and the reverse, counted as a multiset.
100function lineDiff(before: string, after: string) {
101 const left = new Map<string, number>()
102 for (const line of before.split('\n')) left.set(line, (left.get(line) ?? 0) + 1)
103 let added = 0
104 for (const line of after.split('\n')) {
105 const n = left.get(line) ?? 0
106 if (n > 0) left.set(line, n - 1)
107 else added++
108 }
109 let removed = 0
110 for (const n of left.values()) removed += n
111 return { added, removed }
112}
113
114// Added and removed lines of an edit or write: from its stored result where the
115// surface hands one over, else from the edit's own input (the desktop sends none).
116function lineCounts(tool: string, input: unknown, output: unknown) {
117 const result = (output ?? {}) as { structuredPatch?: { lines?: string[] }[]; type?: string; content?: string }
118 let added = 0
119 let removed = 0
120 for (const hunk of result.structuredPatch ?? []) {
121 for (const line of hunk.lines ?? []) {
122 if (line.startsWith('+')) added++
123 else if (line.startsWith('-')) removed++
124 }
125 }
126 if (added > 0 || removed > 0) return { added, removed }
127 if (tool === 'Write' && result.type === 'create' && typeof result.content === 'string') {
128 return { added: result.content.split('\n').length, removed: 0 }
129 }
130 const fields = (input ?? {}) as { old_string?: unknown; new_string?: unknown; edits?: unknown }
131 const edits = tool === 'MultiEdit' && Array.isArray(fields.edits) ? fields.edits : tool === 'Edit' ? [fields] : []
132 for (const edit of edits as { old_string?: unknown; new_string?: unknown }[]) {
133 if (typeof edit.old_string !== 'string' || typeof edit.new_string !== 'string') continue
134 const counts = lineDiff(edit.old_string, edit.new_string)
135 added += counts.added
136 removed += counts.removed
137 }
138 return { added, removed }
139}
140
141function groupLine(calls: ReadonlyArray<ToolGroupCall>) {
142 const counts = new Map<string, number>()
143 for (const call of calls) {
144 const kind =
145 call.tool === 'Read' ? 'read' : call.tool === 'Grep' || call.tool === 'Glob' ? 'search' : call.tool === 'Bash' || call.tool === 'PowerShell' ? 'command' : 'other'
146 counts.set(kind, (counts.get(kind) ?? 0) + 1)
147 }
148 const parts: string[] = []
149 const n = (kind: string) => counts.get(kind) ?? 0
150 if (n('read')) parts.push(`read ${n('read')} ${n('read') === 1 ? 'file' : 'files'}`)
151 if (n('search')) parts.push(`searched ${n('search')} ${n('search') === 1 ? 'pattern' : 'patterns'}`)
152 if (n('command')) parts.push(`ran ${n('command')} ${n('command') === 1 ? 'command' : 'commands'}`)
153 if (n('other')) parts.push(`${n('other')} other ${n('other') === 1 ? 'call' : 'calls'}`)
154 const line = parts.join(', ')
155 return line.charAt(0).toUpperCase() + line.slice(1)
156}
157
158async function remember($: Engine, text: string) {
159 const hashes = hashesOf(text)
160 if (hashes.length === 0) return
161 await update($, narration, list => [...new Set([...list, ...hashes])].slice(-KEEP_HASHES))
162}
163
164export const register: Register = on => {
165 // Narration already in the conversation (a resumed session) is quiet from the start.
166 on('session.start', async ($, e, next) => {
167 const started = await next(e)
168 const rows = await $.session.messages()
169 const earlier = rows.filter(row => row.role === 'assistant' && row.toolUses.length > 0 && row.text.trim())
170 for (const row of earlier) await remember($, row.text)
171 return started
172 })
173
174 on('turn.step', async function* ($, e, next) {
175 const result = yield* next(e)
176 if (e.agentId === undefined && result.toolUses.length > 0 && result.answer.trim()) await remember($, result.answer)
177 return result
178 })
179
180 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
181 const known = new Set(await read($, narration))
182 const hashes = hashesOf(e.props.text)
183 const isNarration = e.props.isSummary === true || (hashes.length > 0 && hashes.every(h => known.has(h)))
184 if (!isNarration) return next(e)
185 const { Text } = $.ui.resolve(e)
186 return <Text {...QUIET}>{plain(e.props.text)}</Text>
187 })
188
189 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
190 if (e.props.isExpanded) return next(e)
191 const { Box, Text, Button } = $.ui.resolve(e)
192 const open = memberOf(isOpen, e)
193 if (await read($, open)) {
194 return (
195 <Box flexDirection="column">
196 {await next(e)}
197 <Button key="hide" plain dimColor label="hide details" onPress={() => update($, open, () => false)} />
198 </Box>
199 )
200 }
201 const { added, removed } = lineCounts(e.props.tool, e.props.input, e.props.output)
202 return (
203 <Box flexDirection="row" gap={1}>
204 <Text {...QUIET}>
205 {describe(e.props.tool, e.props.input)}
206 {e.props.isRunning ? ' …' : ''}
207 </Text>
208 {added > 0 && <Text color="success">+{added}</Text>}
209 {removed > 0 && <Text color="error">-{removed}</Text>}
210 {e.props.isErrored && !e.props.isInterrupted && <Text color="error">failed</Text>}
211 {e.props.isInterrupted && <Text {...QUIET}>interrupted</Text>}
212 <Button key="details" plain dimColor label="details" onPress={() => update($, open, () => true)} />
213 </Box>
214 )
215 })
216
217 // A standalone result shows only while its row is opened.
218 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
219 if (e.props.isExpanded || (await read($, memberOf(isOpen, e)))) return next(e)
220 const { Box } = $.ui.resolve(e)
221 return <Box />
222 })
223
224 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
225 if (e.props.isExpanded) return next(e)
226 const { Box, Text, Button } = $.ui.resolve(e)
227 const open = memberOf(isOpen, e)
228 if (await read($, open)) {
229 return (
230 <Box flexDirection="column">
231 {await next({ ...e, props: { ...e.props, isExpanded: true } })}
232 <Button key="hide" plain dimColor label="hide details" onPress={() => update($, open, () => false)} />
233 </Box>
234 )
235 }
236 const failed = e.props.calls.filter(call => call.isErrored && !call.isInterrupted).length
237 return (
238 <Box flexDirection="row" gap={1}>
239 <Text {...QUIET}>
240 {groupLine(e.props.calls)}
241 {e.props.isActive ? ' …' : ''}
242 </Text>
243 {failed > 0 && <Text color="error">{failed} failed</Text>}
244 <Button key="details" plain dimColor label="details" onPress={() => update($, open, () => true)} />
245 </Box>
246 )
247 })
248
249 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
250 if (e.props.isErrored) return next(e)
251 const { Markdown } = $.ui.resolve(e)
252 return <Markdown text={e.props.text} dimColor />
253 })
254}
255types/index.d.ts 13 lines1// Short hashes of the paragraphs Claude wrote before a tool call.
2export type Narration = string[]
3
4declare module 'claude-code' {
5 interface PluginState {
6 'quiet-transcript': {
7 narration: Narration
8 // Whether a quieted tool row shows the app's own full card, per row.
9 isOpen: StateFamily<boolean>
10 }
11 }
12}
13