SLOPSHOPPER

zen

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

newbandspinnerrowsguardcommand
v0.3.1no licenseupdated 2026-10-23iraklisg/claude-code-zen
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · zen
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 › /zen ⎿ zen: view off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ & zen off · brief
README

zen

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.

Before / after

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 ✓

What zen hides, and why

HiddenWhy
File reads, searches, web fetches, tool searchesInputs Claude gathers on its way to the answer, not results
Shell commands, MCP calls and any other tool that succeedsYou only need to know when one fails
Edits and their diffsYou 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 rowsThe live line above the prompt shows what's running
Finished-agent noticesClaude relays the result in its next message
Errors from reads and searchesSelf-corrections (a missing file, a bad pattern) Claude recovers from
Answered-question rowsYou just picked the answer

What zen always shows

  • Claude's final answer, untouched
  • Failures: any tool that errored, except reads and searches; a task that failed or was killed
  • Anything that needs you: permission prompts, Claude's questions and plans to approve
  • Progress while Claude works, in one line above the prompt: ▰▰▱ 2/3 · Updating routes · ◇ Reading · 4 tools · ◇ 2 agents running · …
  • The test result when tests ran: ◇ tests ✓ or ◇ tests ✗. zen reads failure markers in the output, so a piped pnpm test | tail that failed still shows ✗
  • The mode: a dim zen · brief label in the prompt footer. Click zen or brief to toggle it

Brevity: shorter answers, not just a cleaner screen

Everything 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:

  • no preamble and no narration between steps
  • the final answer leads with the outcome in one or two sentences (unless you asked for an explanation), with detail only when you need it to act
  • chat only: code, comments, commits and files keep their normal style
  • answer your question in visible text before asking one back (thinking is hidden, and options aren't an answer)
View (/zen on/off)Brevity (/zen brevity on/off)
Changeswhat's drawnwhat Claude writes
Tokensnone~150 in the system prompt, cached after the first request
Defaultonon
Footer label (click to toggle)zen / zen offbrief / 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.

Install

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.

Use

CommandEffect
/zentoggle the view
/zen on / /zen offset the view
/zen brevity on / offadd or drop the brevity rule
/zen statusshow 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.

Cost

No extra model calls. Brevity's ~150 tokens are the only prompt cost.

See DESIGN.md for the decisions behind it.

Develop

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 tmux
Source 2 files
hooks/register.tsx 382 lines
1import { 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}
382
types/index.d.ts 18 lines
1export 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