Simple Mode: a plain-English checklist of what Claude is doing, one-line tool rows you can expand, and a two-line summary at the end of every reply. /simple…

The short version of what Claude is doing. Made for people who want the result, not the play-by-play, and great for showing Claude to someone who has never seen an agent work.
With Simple Mode on:
✓ Reading the monday kickoff notes · done ✓ Reading the wednesday check in notes · done ✓ Reading the friday wrap notes · done ◌ Writing the weekly update notes · working
- **Every tool call in the chat is one line**: `✓ Reading the budget notes ▸ detail`. Click it to see the full row and its output; `▾ hide detail` folds it back.
- **Every reply ends with a two-line summary:**
> **Done:** I read the three project notes and wrote a short weekly update.
> **Result:** It's saved as weekly-update.md, ready to send.
## Use it
| Type | What happens |
| --- | --- |
| `/simple` | Turns it on, or off if it is on |
| `/simple on` · `/simple off` | Sets it outright |
The setting is remembered across chats (in `~/.claude/simple-mode.json`) until you change it. Turn it off and the normal view comes straight back.
**Try it:** turn it on, then ask *"Read the notes in demos/simple-mode-notes and write a short weekly update."*
## What it reaches
| Network | Runs processes | Files | Calls a model | Sends data anywhere |
| --- | --- | --- | --- | --- |
| No | No | Only `~/.claude/simple-mode.json` (its on/off setting) | No. The summary is one extra instruction in the system prompt while it is on | No |
`claude plugin validate plugins/simple-mode` lists every event it hooks and every call it makes.
## Notes
- The checklist clears each time you send a new message, so it always shows the current task.
- Folded runs of reads and searches show their first step plus `(+N more)`; expanding the run shows each call, each one line again.
- The pane opens by itself only where there is room for a side pane (a wide terminal or the desktop app); otherwise `/simple` opens it.
Based on [agent-narrator](https://github.com/OneWave-AI/claude-code-mods/tree/main/agent-narrator) by OneWave AI, MIT licensed (see `LICENSE`). What changed is in [CHANGES.md](CHANGES.md).
hooks/register.tsx 261 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Step } from '../types'
5import { narrate, SUMMARY_RULE, testNote } from './steps'
6
7const PANE = 'simple-mode'
8const TITLE = 'Simple Mode'
9const KEEP = 60
10/** The $.store key that carries on/off across chats. */
11const STORE_KEY = 'isOn'
12
13const isOn = atom({ plugin: 'simple-mode', key: 'isOn' } as const, false)
14const isRunning = atom({ plugin: 'simple-mode', key: 'isRunning' } as const, false)
15const steps = atom({ plugin: 'simple-mode', key: 'steps' } as const, [] as Step[])
16const isExpanded = atom({ plugin: 'simple-mode', key: 'isExpanded' } as const, false)
17
18const BLUE = '#61A5FA'
19const GOLD = '#c2a87e'
20
21const MARK: Record<Step['status'], string> = { working: '◌', done: '✓', failed: '×' }
22
23const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: TITLE })
24
25/**
26 * The on/off setting lives in one file, <home>/.claude/simple-mode.json. The plugin
27 * store is kept per way the plugin was loaded (installed, --plugin-dir, the desktop
28 * app's own copy), so a chat that loads it another way would read another store.
29 */
30const settingsPath = async ($: EngineInterface) => {
31 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
32 const home = ((isWindows ? await $.env.get('USERPROFILE') : await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
33 return home ? `${home}/.claude/simple-mode.json` : null
34}
35
36const loadOn = async ($: EngineInterface) => {
37 try {
38 const path = await settingsPath($)
39 if (path && (await $.fs.exists(path))) {
40 const saved = JSON.parse(String(await $.fs.read(path))) as { isOn?: unknown }
41 if (typeof saved.isOn === 'boolean') return saved.isOn
42 }
43 } catch {
44 // An unreadable file falls back to the store.
45 }
46 return (await $.store.get(STORE_KEY)) === true
47}
48
49const saveOn = async ($: EngineInterface, value: boolean) => {
50 await $.store.set(STORE_KEY, value)
51 try {
52 const path = await settingsPath($)
53 if (path) await $.fs.write(path, `${JSON.stringify({ isOn: value })}\n`)
54 } catch {
55 // The store still holds it for this way of loading.
56 }
57}
58
59const setOn = async ($: EngineInterface, value: boolean) => {
60 await update($, isOn, () => value)
61 await saveOn($, value)
62 if (value) await open($)
63 else await $.ui.close({ id: PANE })
64}
65
66const begin = async ($: EngineInterface, id: string, tool: string, input: Record<string, unknown>) => {
67 const step: Step = { id, text: narrate(tool, input), note: null, status: 'working' }
68 await update($, steps, list => [...list, step].slice(-KEEP))
69 return step
70}
71
72const settle = async ($: EngineInterface, id: string, isFailed: boolean, stdout: string) => {
73 const note = testNote(stdout) ?? (isFailed ? "didn't work, trying another way" : null)
74 const status: Step['status'] = isFailed ? 'failed' : 'done'
75 await update($, steps, list => list.map(s => (s.id === id ? { ...s, status, note } : s)))
76}
77
78/** One-line label for a tool row: its mark and the plain-English step. */
79const rowLabel = (tool: string, input: unknown, isRunningNow: boolean, isErrored: boolean) => {
80 const mark = isRunningNow ? MARK.working : isErrored ? MARK.failed : MARK.done
81 return `${mark} ${narrate(tool, (input ?? {}) as Record<string, unknown>)}`
82}
83
84export const register: Register = on => {
85 on('session.start', async ($, e, next) => {
86 await $.command.register({
87 name: 'simple',
88 description: 'Simple Mode: a plain-English checklist, one-line tool rows, a two-line summary. /simple [on|off]',
89 })
90 const saved = await loadOn($)
91 await update($, isOn, () => saved)
92 if (saved) void open($).catch(() => undefined)
93 return next(e)
94 })
95
96 on('command.run', { command: 'simple' }, async ($, e) => {
97 const arg = e.args.trim().toLowerCase()
98 const current = await read($, isOn)
99 const value = arg === 'on' ? true : arg === 'off' ? false : !current
100 await setOn($, value)
101 return {
102 text: value
103 ? 'Simple Mode is on. The checklist is in the side pane, tool rows are one line (click one to see its detail), and every reply ends with a two-line summary.'
104 : 'Simple Mode is off. The normal view is back.',
105 }
106 })
107
108 on('prompt.compose', async ($, e, next) => {
109 const composed = await next(e)
110 if (!(await read($, isOn))) return composed
111 return { sections: [...composed.sections, { id: 'simple-mode:summary', text: SUMMARY_RULE, scope: 'session' as const }] }
112 }).catch(($, e, next) => next(e))
113
114 on('prompt.submit', async ($, e, next) => {
115 if ((await read($, isOn)) && !e.text.trimStart().startsWith('/')) {
116 await update($, steps, () => [])
117 await update($, isRunning, () => true)
118 }
119 return next(e)
120 }).catch(($, e, next) => next(e))
121
122 on('tool.call', async ($, e, next) => {
123 if (!(await read($, isOn))) return next(e)
124 const step = await begin($, e.tool_use_id, String(e.tool), e as unknown as Record<string, unknown>)
125 await update($, isRunning, () => true)
126 const ran = await next(e)
127 const out = ran.result as { stdout?: unknown; stderr?: unknown } | undefined
128 const stdout = typeof out?.stdout === 'string' ? `${out.stdout}\n${String(out.stderr ?? '')}` : ''
129 await settle($, step.id, ran.deny !== undefined || ran.isError === true, stdout)
130 return ran
131 }).catch(($, e, next) => next(e))
132
133 on('turn.complete', async ($, e, next) => {
134 await update($, isRunning, () => false)
135 // A step still marked working when the turn ends was cut short.
136 await update($, steps, list => list.map(s => (s.status === 'working' ? { ...s, status: 'done' as const } : s)))
137 return next(e)
138 })
139
140 // A tool call's row: one line while collapsed, the engine's full row once expanded.
141 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
142 if (!(await read($, isOn))) return next(e)
143 const { Box, Button } = $.ui.resolve(e)
144 const expanded = memberOf(isExpanded, e)
145 const toggle = () => void update($, expanded, v => !v).catch(() => undefined)
146 if (await read($, expanded)) {
147 const full = await next(e)
148 return (
149 <Box flexDirection="column">
150 <Button key="simple-collapse" plain dimColor onPress={toggle}>
151 ▾ hide detail
152 </Button>
153 {full}
154 </Box>
155 )
156 }
157 const label = `${rowLabel(e.props.tool, e.props.input, e.props.isRunning, e.props.isErrored)} ▸ detail`
158 return (
159 <Button key="simple-expand" plain dimColor={!e.props.isRunning} onPress={toggle}>
160 {label}
161 </Button>
162 )
163 })
164
165 // The result block under a standalone tool row: hidden until that row is expanded.
166 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
167 if (!(await read($, isOn))) return next(e)
168 if (await read($, memberOf(isExpanded, e))) return next(e)
169 const { Box } = $.ui.resolve(e)
170 return <Box />
171 })
172
173 // A folded run of reads and searches: one line, expandable into its calls.
174 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
175 if (!(await read($, isOn))) return next(e)
176 const { Box, Button } = $.ui.resolve(e)
177 const expanded = memberOf(isExpanded, e)
178 const toggle = () => void update($, expanded, v => !v).catch(() => undefined)
179 if (await read($, expanded)) {
180 const full = await next({ ...e, props: { ...e.props, isExpanded: true } })
181 return (
182 <Box flexDirection="column">
183 <Button key="simple-collapse" plain dimColor onPress={toggle}>
184 ▾ hide detail
185 </Button>
186 {full}
187 </Box>
188 )
189 }
190 const calls = e.props.calls
191 const first = calls[0]
192 if (!first) return next(e)
193 const isLive = calls.some(c => c.isRunning)
194 const isErrored = calls.some(c => c.isErrored)
195 const more = calls.length > 1 ? ` (+${calls.length - 1} more)` : ''
196 const label = `${rowLabel(first.tool, first.input, isLive, isErrored)}${more} ▸ detail`
197 return (
198 <Button key="simple-expand" plain dimColor={!isLive} onPress={toggle}>
199 {label}
200 </Button>
201 )
202 })
203
204 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
205 const { Box, Text } = $.ui.resolve(e)
206 const on_ = await read($, isOn)
207 const running = await read($, isRunning)
208 const list = await read($, steps)
209 const room = Math.max(3, (e.viewport?.rows ?? 30) - 8)
210 const doneCount = list.filter(s => s.status !== 'working').length
211
212 const row = (s: Step) => {
213 const isLive = s.status === 'working'
214 const color = isLive ? BLUE : s.status === 'failed' ? GOLD : undefined
215 const word = isLive ? 'working' : s.status === 'failed' ? 'retrying' : 'done'
216 return (
217 <Box key={s.id}>
218 <Box width={3} flexShrink={0}>
219 <Text color={color} bold={isLive}>{MARK[s.status]}</Text>
220 </Box>
221 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
222 <Text bold={isLive} dimColor={s.status === 'done'} wrap="wrap">
223 {s.text}
224 <Text color={isLive ? BLUE : undefined} dimColor={!isLive}>{` · ${word}`}</Text>
225 {s.note && <Text color={s.status === 'failed' ? GOLD : BLUE}>{` · ${s.note}`}</Text>}
226 </Text>
227 </Box>
228 </Box>
229 )
230 }
231
232 if (!on_) {
233 return (
234 <Box flexDirection="column" paddingX={1}>
235 <Text dimColor>Simple Mode is off. Type /simple to turn it on.</Text>
236 </Box>
237 )
238 }
239
240 return (
241 <Box flexDirection="column" paddingX={1}>
242 <Text>
243 {running ? (
244 <Text color={BLUE} bold>WORKING</Text>
245 ) : (
246 <Text color={GOLD} bold>{list.length ? 'DONE' : 'READY'}</Text>
247 )}
248 <Text dimColor>{` ${doneCount} of ${list.length} steps done`}</Text>
249 </Text>
250 <Box marginTop={1} flexDirection="column">
251 {list.length === 0 && <Text dimColor>Waiting for a task. Ask Claude to do something.</Text>}
252 {list.slice(-room).map(row)}
253 </Box>
254 <Box marginTop={1}>
255 <Text dimColor>Click a ▸ row in the chat to see its full detail. /simple turns this off.</Text>
256 </Box>
257 </Box>
258 )
259 })
260}
261hooks/steps.ts 178 lines1/** Pure narration: a tool call in, one plain-English checklist line out. */
2
3const KIND: Record<string, string> = {
4 tsx: 'screen',
5 jsx: 'screen',
6 vue: 'screen',
7 svelte: 'screen',
8 html: 'page',
9 css: 'styles',
10 scss: 'styles',
11 ts: 'code',
12 js: 'code',
13 mjs: 'code',
14 py: 'code',
15 go: 'code',
16 rb: 'code',
17 rs: 'code',
18 swift: 'code',
19 sql: 'database script',
20 json: 'settings',
21 yaml: 'settings',
22 yml: 'settings',
23 toml: 'settings',
24 env: 'settings',
25 md: 'notes',
26 txt: 'notes',
27 csv: 'data',
28 xlsx: 'spreadsheet',
29 docx: 'document',
30 pdf: 'document',
31 png: 'image',
32 jpg: 'image',
33 svg: 'graphic',
34}
35
36const GENERIC = new Set(['index', 'main', 'page', 'route', 'layout', 'mod', 'app', 'default'])
37
38/** "src/components/PricingCard.tsx" -> "pricing card" */
39export const wordsOf = (name: string) =>
40 name
41 .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
42 .replace(/[-_.]+/g, ' ')
43 .trim()
44 .toLowerCase()
45
46/** "app/pricing/page.tsx" -> "the pricing page"; "auth.test.ts" -> "the auth tests". */
47export const fileLabel = (path: string) => {
48 const parts = path.split(/[\\/]/).filter(Boolean)
49 const file = parts[parts.length - 1] ?? path
50 const dot = file.lastIndexOf('.')
51 const ext = dot > 0 ? file.slice(dot + 1).toLowerCase() : ''
52 let stem = dot > 0 ? file.slice(0, dot) : file
53 const isTest = /\.(test|spec)$/i.test(stem) || /(^|[\\/])(__tests__|tests?)[\\/]/i.test(path)
54 stem = stem.replace(/\.(test|spec)$/i, '')
55 const isGeneric = GENERIC.has(stem.toLowerCase())
56 const parent = parts[parts.length - 2]
57 const base = isGeneric && parent && !parent.startsWith('(') && !parent.startsWith('[') ? parent : stem
58 const name = wordsOf(base) || 'project'
59 if (isTest) return `the ${name} tests`
60 if (file.startsWith('.env')) return 'the private settings'
61 const kind = KIND[ext] ?? 'file'
62 if (isGeneric && ext === 'tsx' && parent) return `the ${name} page`
63 return `the ${name} ${kind}`
64}
65
66const quote = (text: string, max = 40) => {
67 const clean = text.replace(/\s+/g, ' ').trim()
68 return `"${clean.length > max ? `${clean.slice(0, max - 1)}…` : clean}"`
69}
70
71const hostOf = (url: string) => {
72 const m = /^[a-z]+:\/\/([^/?#]+)/i.exec(url)
73 return m ? m[1]!.replace(/^www\./, '') : 'a web page'
74}
75
76/** A shell command (Bash or PowerShell), explained. */
77export const narrateCommand = (command: string): string => {
78 const c = command.trim()
79 const rules: [RegExp, string][] = [
80 [/\b(test|jest|vitest|pytest|mocha|go test|cargo test|bun test)\b/i, 'Running the tests'],
81 [/\bgit\s+commit\b/, 'Saving a checkpoint of the work'],
82 [/\bgit\s+push\b/, 'Publishing the changes'],
83 [/\bgit\s+(status|diff|log|show)\b/, 'Reviewing what has changed so far'],
84 [/\bgit\s+(checkout|switch|branch)\b/, 'Setting up a separate workspace for this change'],
85 [/\b(npm|pnpm|yarn|bun)\s+(i|install|add)\b|\bpip\s+install\b|\bbrew\s+install\b/, 'Installing the building blocks it needs'],
86 [/\bplugin\s+validate\b/i, 'Checking the mod for problems'],
87 [/\b(run\s+)?build\b|\btsc\b/, 'Building the app to make sure it all fits together'],
88 [/\b(run\s+)?(dev|start|serve)\b/, 'Starting the app to try it out'],
89 [/\b(lint|eslint|prettier|ruff|black)\b/, 'Tidying up and checking code style'],
90 [/\b(vercel|deploy|fly deploy|netlify)\b/, 'Deploying the update live'],
91 [/\b(curl|wget|http|Invoke-WebRequest|Invoke-RestMethod)\b/i, 'Checking a live web address'],
92 [/\b(psql|supabase|prisma|migrate)\b/, 'Working with the database'],
93 [/^(ls|cat|head|tail|find|tree|wc|pwd|grep|rg|sed -n)\b|\b(Get-ChildItem|Get-Content|Select-String|Test-Path)\b/i, 'Looking around the folder'],
94 [/^(mkdir|cp|mv|touch)\b|\b(New-Item|Copy-Item|Move-Item|Rename-Item)\b/i, 'Organizing files'],
95 ]
96 for (const [re, text] of rules) if (re.test(c)) return text
97 return 'Running a quick command'
98}
99
100const field = (input: Record<string, unknown>, key: string) => {
101 const v = input[key]
102 return typeof v === 'string' ? v : ''
103}
104
105/** One tool call, explained as a checklist line. */
106export const narrate = (tool: string, input: Record<string, unknown>): string => {
107 const path = field(input, 'file_path') || field(input, 'notebook_path') || field(input, 'path')
108 switch (tool) {
109 case 'Read':
110 return `Reading ${fileLabel(path)}`
111 case 'Edit':
112 case 'MultiEdit':
113 case 'NotebookEdit':
114 return `Saving changes to ${fileLabel(path)}`
115 case 'Write':
116 return `Writing ${fileLabel(path)}`
117 case 'Bash':
118 case 'PowerShell':
119 return narrateCommand(field(input, 'command'))
120 case 'Grep':
121 return `Searching for ${quote(field(input, 'pattern'))}`
122 case 'Glob':
123 return 'Finding the files involved'
124 case 'WebFetch':
125 return `Reading ${hostOf(field(input, 'url'))}`
126 case 'WebSearch':
127 return `Researching ${quote(field(input, 'query'))} on the web`
128 case 'Agent':
129 case 'Task': {
130 const what = field(input, 'description')
131 return what ? `Bringing in a helper to ${what.charAt(0).toLowerCase()}${what.slice(1)}` : 'Bringing in a helper'
132 }
133 case 'TodoWrite':
134 return 'Updating the plan'
135 case 'AskUserQuestion':
136 return 'Checking a decision with you'
137 case 'Skill': {
138 const name = field(input, 'skill').replace(/^.*:/, '')
139 return name ? `Loading the ${wordsOf(name)} instructions` : 'Loading a set of instructions'
140 }
141 case 'ToolSearch':
142 return 'Getting the right tool ready'
143 }
144 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
145 if (mcp) {
146 const server = wordsOf(mcp[1]!.replace(/^claude_ai_/, '').replace(/^plugin_[^_]+_/, ''))
147 .split(' ')
148 .map(w => (w ? w[0]!.toUpperCase() + w.slice(1) : w))
149 .join(' ')
150 const action = wordsOf(mcp[2]!.replace(/^[a-z]+_(?=[a-z]+_)/, ''))
151 return `Using ${server} to ${action}`
152 }
153 return `Using ${wordsOf(tool)}`
154}
155
156/** Counts from a test run's output, as a short note: "42 passed" / "40 passed, 2 failed". */
157export const testNote = (output: string) => {
158 const top = (re: RegExp) => {
159 let n: number | null = null
160 for (const m of output.matchAll(re)) n = Math.max(n ?? 0, Number(m[1]))
161 return n
162 }
163 const passed = top(/(\d+)\s+(?:tests?\s+)?pass(?:ed|ing)?\b/gi)
164 const failed = top(/(\d+)\s+(?:tests?\s+)?fail(?:ed|ing|ures?)?\b/gi)
165 if (passed === null && failed === null) return null
166 return failed ? `${passed ?? 0} passed, ${failed} failed` : `${passed} passed`
167}
168
169/** The instruction Simple Mode adds to the system prompt while it is on. */
170export const SUMMARY_RULE = [
171 'Simple Mode is on. The person wants the short version.',
172 'Keep the text you write between tool calls to a minimum: a checklist pane already shows each step.',
173 'End every reply with a two-line summary in plain English, no jargon, no code, no file paths, after a blank line, exactly in this form:',
174 '**Done:** <one sentence: what you did>',
175 '**Result:** <one sentence: what came of it, where it is, or what you need from them>',
176 'Nothing comes after those two lines.',
177].join('\n')
178types/index.d.ts 18 lines1export type Step = {
2 id: string
3 text: string
4 note: string | null
5 status: 'working' | 'done' | 'failed'
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'simple-mode': {
11 isOn: boolean
12 isRunning: boolean
13 steps: Step[]
14 isExpanded: StateFamily<boolean>
15 }
16 }
17}
18