Focused transcript: hide tool noise, keep diffs, failures, progress and the answer

A focused Claude Code view for developers: see what matters, skip the noise.
When Claude works, the transcript fills up with file reads, searches, shell commands, diffs and running commentary. You review changes in your editor or lazygit anyway, so most of that is noise. zen hides it and leaves four things: the answer, what went wrong, what's running, and whether the tests passed.
Without zen:
❯ fix the failing test
Read 1 file, ran 2 shell commands
● Let me look at the sum implementation.
● Update(src/sum.ts)
⎿ Added 1 line, removed 1 line
2 - return values.reduce((total, value) => total + Math.abs(value), 0)
2 + return values.reduce((total, value) => total + value, 0)
● Now let me run the tests to confirm.
Ran 1 shell command
● Fixed: sum() applied Math.abs to each value. All tests pass.
✻ Baked for 18s
With zen:
❯ fix the failing test
● Fixed: sum() applied Math.abs to each value. All tests pass.
✻ Baked for 18s
◇ tests ✓
| Hidden | Why |
|---|---|
| File reads, searches, web fetches, tool searches | Inputs Claude gathers on its way to the answer, not results |
| Shell commands, MCP calls and any other tool that succeeds | You only need to know when one fails |
| Edits and their diffs | You review changes in your editor or git tool |
| Claude's in-between narration ("Let me check…") | The final answer covers it. Text written with a question or a plan to approve stays: no answer follows it |
| Skill, agent and workflow launch rows | The live line above the prompt shows what's running |
| Finished-agent notices | Claude relays the result in its next message |
| Errors from reads and searches | Self-corrections (a missing file, a bad pattern) Claude recovers from |
| Answered-question rows | You just picked the answer |
▰▰▱ 2/3 · Updating routes · ◇ Reading · 4 tools · ◇ 2 agents running · …◇ tests ✓ or ◇ tests ✗. zen reads failure markers in the output, so a piped pnpm test | tail that failed still shows ✗zen · brief label in the prompt footer. Click zen or brief to toggle itEverything above is display only: Claude does the same work and writes the same words, and zen chooses what to draw. Brevity is the one setting that changes what Claude writes. It adds a short rule to Claude's system prompt:
View (/zen on/off) | Brevity (/zen brevity on/off) | |
|---|---|---|
| Changes | what's drawn | what Claude writes |
| Tokens | none | ~150 in the system prompt, cached after the first request |
| Default | on | on |
| Footer label (click to toggle) | zen / zen off | brief / brief off |
Brevity usually pays for itself, since shorter answers mean fewer output tokens, but that isn't guaranteed. Turn it off if you want zen to add nothing to the prompt. In an organization-managed Claude Code, plugins can't change the system prompt, so brevity has no effect there; the view still works.
At the prompt of a terminal session:
/plugin install zen --marketplace iraklisg/claude-code-zen
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
From a shell:
claude plugin install zen --marketplace iraklisg/claude-code-zen
Terminal only. Requires a Claude Code build with mod support (written against 2.1.303). Update with claude plugin update zen@iraklisg.
| Command | Effect |
|---|---|
/zen | toggle the view |
/zen on / /zen off | set the view |
/zen brevity on / off | add or drop the brevity rule |
/zen status | show both settings |
Settings persist across sessions. Nothing is lost: /zen off redraws the whole transcript in the default view. ctrl+o doesn't reveal rows zen hides.
No extra model calls. Brevity's ~150 tokens are the only prompt cost.
See DESIGN.md for the decisions behind it.
claude --plugin-dir . # run a session with the working copy
claude plugin validate .
claude plugin test .
scripts/run.sh B 'fix the failing test' # live scenario in an isolated tmuxhooks/register.tsx 382 lines1import { atom, read, update } from 'claude-code'
2import type { BuiltinToolResults, Register } from 'claude-code'
3
4import type { Agent, Band, Todo } from '../types'
5
6const isOn = atom({ plugin: 'zen', key: 'isOn' } as const, true)
7const isBrief = atom({ plugin: 'zen', key: 'isBrief' } as const, true)
8const IDLE: Band = { activity: 'Working', tools: 0, todo: null }
9const band = atom({ plugin: 'zen', key: 'band' } as const, IDLE)
10const facts = atom({ plugin: 'zen', key: 'facts' } as const, [])
11const agents = atom({ plugin: 'zen', key: 'agents' } as const, [])
12// Text Claude wrote before a tool call this session; the final answer and the
13// context of a question never land here.
14const chatter = atom({ plugin: 'zen', key: 'chatter' } as const, [])
15
16// Rows that always show; every other tool's row is hidden unless it errored.
17// ExitPlanMode carries the plan to approve; AskUserQuestion shows only while waiting.
18const SHOWN = new Set(['ExitPlanMode', 'EndConversation'])
19
20// The turn stops at these to wait for the user, so no final answer follows to
21// cover the text written with them: it is the question's context, not chatter.
22const ASKS = new Set(['AskUserQuestion', 'ExitPlanMode'])
23
24const VERBS: Record<string, string> = {
25 Read: 'Reading',
26 Grep: 'Searching',
27 Glob: 'Searching',
28 LS: 'Reading',
29 Edit: 'Editing',
30 Write: 'Editing',
31 Bash: 'Running',
32 WebFetch: 'Researching',
33 WebSearch: 'Researching',
34 Agent: 'Delegating',
35}
36
37const TEST_RUNNER =
38 /\b(pytest|vitest|jest|go test|cargo test|(npm|pnpm|yarn|bun)( run)? test)\b/
39// A pipe or `;` hides the runner's exit code, so its summary decides too.
40const TEST_FAILED = /\b[1-9]\d* (failed|failing)\b|(^|\s)FAIL(ED)?\b|--- FAIL/m
41
42const BREVITY = [
43 '# Brevity (zen)',
44 'The user sees a condensed transcript: tool calls and diffs are hidden; failures, your final message, and text sent with a question are shown.',
45 '- No preamble and no narration of steps between tool calls.',
46 '- The final message leads with the outcome in one or two sentences unless the user asked for an explanation. Add detail only when the user needs it to act.',
47 '- This applies to chat only: code, comments, commits and files you write keep their normal style.',
48 '- Before AskUserQuestion, answer any question the user asked in visible text in the same response; thinking is hidden and options are not an answer.',
49].join('\n')
50
51type Turn = {
52 testsPassed: boolean | null
53 tasks: Map<string, { subject: string; activeForm?: string; status: string }>
54}
55
56const freshTurn = (): Turn => ({
57 testsPassed: null,
58 tasks: new Map(),
59})
60
61// Their errors are self-corrections (a missing file, a bad pattern) Claude recovers from.
62const READ_ONLY = new Set([
63 'Read',
64 'Grep',
65 'Glob',
66 'LS',
67 'WebFetch',
68 'WebSearch',
69 'ToolSearch',
70 'ListMcpResourcesTool',
71 'ReadMcpResourceTool',
72 'ReadMcpResourceDirTool',
73 'ListAgents',
74])
75
76const isQuiet = (tool: string, isErrored: boolean, isInterrupted: boolean, isRunning: boolean) =>
77 READ_ONLY.has(tool) ||
78 (!isErrored &&
79 !isInterrupted &&
80 !SHOWN.has(tool) &&
81 !(tool === 'AskUserQuestion' && isRunning))
82
83// `/zen status` and usage replies stay; the footer label already shows a switch.
84const SWITCHES = new Set(['', 'on', 'off', 'brevity on', 'brevity off'])
85
86const todoOf = (
87 items: ReadonlyArray<{ status: string; activeForm?: string; subject?: string; content?: string }>,
88): Todo | null => {
89 const live = items.filter(item => item.status !== 'deleted')
90 if (live.length === 0) return null
91 const doing = live.find(item => item.status === 'in_progress')
92
93 return {
94 done: live.filter(item => item.status === 'completed').length,
95 total: live.length,
96 current: doing?.activeForm ?? doing?.subject ?? doing?.content ?? '',
97 }
98}
99
100const squash = (text: string) => text.replace(/\s+/g, ' ').trim()
101
102const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
103
104export const factsLine = (turn: Turn) =>
105 turn.testsPassed === null ? '' : `◇ tests ${turn.testsPassed ? '✓' : '✗'}`
106
107export const agentsLine = (running: readonly Agent[]) =>
108 `◇ ${plural(running.length, 'agent')} running · ${running.map(one => one.description).join(', ')}`
109
110export const bandLine = (state: Band, running: readonly Agent[]) => {
111 const agents = running.length > 0 ? ` · ${plural(running.length, 'agent')}` : ''
112 if (state.todo !== null) {
113 const { done, total, current } = state.todo
114 const bar = '▰'.repeat(done) + '▱'.repeat(Math.max(0, total - done))
115
116 return `${bar} ${done}/${total}${current ? ` · ${current}` : ''}${agents}`
117 }
118
119 return `◇ ${state.activity} · ${plural(state.tools, 'tool')}${agents}`
120}
121
122export const register: Register = on => {
123 let turn = freshTurn()
124
125 on('session.start', async ($, e, next) => {
126 const stored = await $.store.get('isOn')
127 const brevity = await $.store.get('brevity')
128 await update($, isOn, () => stored !== false)
129 await update($, isBrief, () => brevity !== false)
130 await $.command.register({
131 name: 'zen',
132 description: 'Toggle the zen view (on, off, brevity on, brevity off, status)',
133 })
134
135 return next(e)
136 }).catch(($, e, next) => next(e))
137
138 on('command.run', { command: 'zen' }, async ($, e) => {
139 const args = e.args.trim().toLowerCase()
140 const view = await read($, isOn)
141 const brevity = (await $.store.get('brevity')) !== false
142
143 if (args === 'brevity on' || args === 'brevity off') {
144 await $.store.set('brevity', args === 'brevity on')
145 await update($, isBrief, () => args === 'brevity on')
146
147 return { text: `brevity rule ${args.slice(8)} (from the next request)` }
148 }
149 if (args === 'status') {
150 return { text: `view ${view ? 'on' : 'off'} · brevity ${brevity ? 'on' : 'off'}` }
151 }
152 if (args !== '' && args !== 'on' && args !== 'off') {
153 return { text: 'use /zen [on|off|status|brevity on|brevity off]' }
154 }
155
156 const next = args === '' ? !view : args === 'on'
157 await $.store.set('isOn', next)
158 await update($, isOn, () => next)
159
160 return { text: `view ${next ? 'on' : 'off'}` }
161 })
162
163 on('turn.start', async ($, e, next) => {
164 if (e.agentId === undefined) {
165 turn = freshTurn()
166 await update($, band, () => IDLE)
167 // turn.complete drops an agent as it ends; this catches one killed instead.
168 const ended = new Set(
169 (await $.agent.list())
170 .filter(one => ['completed', 'failed', 'killed'].includes(one.status))
171 .map(one => one.id),
172 )
173 await update($, agents, list => list.filter(one => !ended.has(one.id)))
174 }
175
176 return next(e)
177 }).catch(($, e, next) => next(e))
178
179 on('agent.spawn', async ($, e, next) => {
180 const spawned = await next(e)
181 const id = spawned.agentId
182 if (id !== undefined) {
183 await update($, agents, list => [...list, { id, description: e.description }])
184 }
185
186 return spawned
187 }).catch(($, e, next) => next(e))
188
189 on('tool.call', async ($, e, next) => {
190 if (e.agentId !== undefined) return next(e)
191
192 await update($, band, state => ({
193 ...state,
194 activity: VERBS[e.tool] ?? 'Working',
195 tools: state.tools + 1,
196 }))
197 const ran = await next(e)
198 if (ran.deny !== undefined) return ran
199
200 if (e.tool === 'Bash' && TEST_RUNNER.test(e.command)) {
201 turn.testsPassed = ran.isError !== true && !TEST_FAILED.test(ran.text ?? '')
202 } else if (e.tool === 'TodoWrite') {
203 const todo = todoOf(e.todos)
204 await update($, band, state => ({ ...state, todo }))
205 } else if (e.tool === 'TaskCreate' && ran.isError !== true) {
206 const created = ran.result as BuiltinToolResults['TaskCreate']
207 turn.tasks.set(created.task.id, {
208 subject: e.subject,
209 activeForm: e.activeForm,
210 status: 'pending',
211 })
212 const todo = todoOf([...turn.tasks.values()])
213 await update($, band, state => ({ ...state, todo }))
214 } else if (e.tool === 'TaskUpdate' && ran.isError !== true) {
215 const prior = turn.tasks.get(e.taskId) ?? { subject: e.subject ?? '', status: 'pending' }
216 turn.tasks.set(e.taskId, {
217 subject: e.subject ?? prior.subject,
218 activeForm: e.activeForm ?? prior.activeForm,
219 status: e.status ?? prior.status,
220 })
221 const todo = todoOf([...turn.tasks.values()])
222 await update($, band, state => ({ ...state, todo }))
223 }
224
225 return ran
226 }).catch(($, e, next) => next(e))
227
228 on('turn.step', async function* ($, e, next) {
229 const step = yield* next(e)
230 const text = squash(step.answer)
231 const asks = step.toolUses.some(use => ASKS.has(use.name))
232 if (e.agentId === undefined && step.toolUses.length > 0 && !asks && text !== '') {
233 await update($, chatter, list => [...list, text].slice(-200))
234 }
235
236 return step
237 })
238
239 on('turn.complete', async ($, e, next) => {
240 const done = await next(e)
241 const agentId = e.agentId
242 if (agentId !== undefined) {
243 await update($, agents, list => list.filter(one => one.id !== agentId))
244
245 return done
246 }
247
248 await update($, band, () => IDLE)
249 const line = e.reason === 'answer' ? factsLine(turn) : ''
250 if (line !== '') {
251 await update($, facts, list => [...list, { durationMs: e.durationMs, line }].slice(-50))
252 }
253
254 return done
255 }).catch(($, e, next) => next(e))
256
257 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
258 const ended = await next(e)
259 if (!(await read($, isOn))) return ended
260 const match = (await read($, facts)).find(
261 one => Math.abs(one.durationMs - e.props.durationMs) < 1000,
262 )
263 if (match === undefined) return ended
264 const { Box, Text } = $.ui.resolve(e)
265
266 return (
267 <Box flexDirection="column">
268 {ended}
269 <Text dimColor>{match.line}</Text>
270 </Box>
271 )
272 })
273
274 on('prompt.compose', async ($, e, next) => {
275 const composed = await next(e)
276 if ((await $.store.get('brevity')) === false) return composed
277
278 return {
279 sections: [...composed.sections, { id: 'zen:brevity', text: BREVITY, scope: 'session' }],
280 }
281 }).catch(($, e, next) => next(e))
282
283 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
284 if (!(await read($, isOn))) return next(e)
285 const text = squash(e.props.text)
286 const isChatter =
287 e.props.isSummary === true ||
288 (text !== '' && (await read($, chatter)).some(one => one.includes(text)))
289 if (!isChatter) return next(e)
290 const { Box } = $.ui.resolve(e)
291
292 return <Box display="none" />
293 })
294
295 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
296 const { tool, isErrored, isInterrupted, isRunning } = e.props
297 if (!(await read($, isOn)) || !isQuiet(tool, isErrored, isInterrupted, isRunning)) {
298 return next(e)
299 }
300 const { Box } = $.ui.resolve(e)
301
302 return <Box display="none" />
303 })
304
305 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
306 const { tool, isErrored } = e.props
307 if (!(await read($, isOn)) || !isQuiet(tool, isErrored, false, false)) return next(e)
308 const { Box } = $.ui.resolve(e)
309
310 return <Box display="none" />
311 })
312
313 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
314 const hasTrouble = e.props.calls.some(
315 call => !isQuiet(call.tool, call.isErrored, call.isInterrupted, call.isRunning),
316 )
317 if (e.props.isExpanded || hasTrouble || !(await read($, isOn))) return next(e)
318 const { Box } = $.ui.resolve(e)
319
320 return <Box display="none" />
321 })
322
323 // Clicking `zen` toggles the view, `brief` the brevity rule. Both stay drawn
324 // when off (`zen off`, `brief off`) so a click can bring them back.
325 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
326 const view = await read($, isOn)
327 const brevity = await read($, isBrief)
328 const { Box, Text, Button } = $.ui.resolve(e)
329 const toggleView = async () => {
330 await $.store.set('isOn', !view)
331 await update($, isOn, () => !view)
332 }
333 const toggleBrevity = async () => {
334 await $.store.set('brevity', !brevity)
335 await update($, isBrief, () => !brevity)
336 }
337 const hasModes = e.props.modes.length > 0
338
339 return (
340 <Box flexDirection="row">
341 {hasModes ? await next(e) : null}
342 {hasModes ? <Text dimColor> & </Text> : null}
343 <Button key="zen" plain dimColor onPress={toggleView}>
344 {view ? 'zen' : 'zen off'}
345 </Button>
346 <Text dimColor> · </Text>
347 <Button key="brief" plain dimColor onPress={toggleBrevity}>
348 {brevity ? 'brief' : 'brief off'}
349 </Button>
350 </Box>
351 )
352 })
353
354 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
355 const { origin, task, from, isExpanded } = e.props
356 const isTaskDone =
357 origin.kind === 'task-notification' && task?.status !== 'failed' && task?.status !== 'killed'
358 const isAgentMessage = from !== undefined && origin.kind === 'unclassified'
359 if (isExpanded || !(isTaskDone || isAgentMessage) || !(await read($, isOn))) return next(e)
360 const { Box } = $.ui.resolve(e)
361
362 return <Box display="none" />
363 })
364
365 on('ui.render', { component: 'CommandOutput', props: { command: 'zen' } }, ($, e, next) => {
366 if (e.props.isErrored || !SWITCHES.has(e.props.args.trim().toLowerCase())) return next(e)
367 const { Box } = $.ui.resolve(e)
368
369 return <Box display="none" />
370 })
371
372 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
373 if (e.props.hasSurvey || !(await read($, isOn))) return next(e)
374 const running = await read($, agents)
375 if (!e.props.isWorking && running.length === 0) return next(e)
376 const line = e.props.isWorking ? bandLine(await read($, band), running) : agentsLine(running)
377 const { Text } = $.ui.resolve(e)
378
379 return <Text dimColor wrap="truncate">{line}</Text>
380 })
381}
382types/index.d.ts 18 lines1export type Todo = { done: number; total: number; current: string }
2
3export type Agent = { id: string; description: string }
4
5export type Band = {
6 activity: string
7 tools: number
8 todo: Todo | null
9}
10
11export type Facts = { durationMs: number; line: string }
12
13declare module 'claude-code' {
14 interface PluginState {
15 zen: { isOn: boolean; isBrief: boolean; band: Band; facts: Facts[]; chatter: string[]; agents: Agent[] }
16 }
17}
18