SLOPSHOPPER

Simple Mode

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…

newpanerowsguardcommandprompt
★ 2v0.1.1MITupdated 2026-10-08Ankitrai97/rai-claude-mods/plugins/simple-mode
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · simple-mode
│ ┃ Simple Mode ✕ › fix the failing auth test and add an audit log call │ ┃ READY 0 of 0 steps done │ ┃ ⏺ Read(src/auth.ts) │ ┃ Waiting for a task. Ask Claude to do ⎿ Read 6 lines │ ┃ something. ✓ Saving changes to the auth code ▸ detail │ ┃ ⏺ Bash(bun test) │ ┃ Click a ▸ row in the chat to see its full ⎿ 3 pass, 1 fail │ ┃ detail. /simple turns this off. │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /simple │ ⎿ simple-mode: Simple Mode is on. The checklist is in the side pan │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Simple Mode
READY 0 of 0 steps done Waiting for a task. Ask Claude to do something. Click a ▸ row in the chat to see its full detail. /simple turns this off.
Tool row
✓ Saving changes to the auth code ▸ detail
README

Simple Mode

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:

  • A checklist pane shows the current task, each step in plain English and marked working or done: ```text WORKING 3 of 4 steps done

✓ 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).
Source 3 files
hooks/register.tsx 261 lines
1import { 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}
261
hooks/steps.ts 178 lines
1/** 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')
178
types/index.d.ts 18 lines
1export 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