SLOPSHOPPER

clean-view

A calm view of Claude Code for non-technical users — tool calls, diffs and command output are hidden while one checklist above the prompt shows the plan, the…

newbandrowsguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clean-view
› fix the failing auth test and add an audit log call ╭───────────────────╮ │ clean-view │ ⏺ Read(src/auth.ts) │ Clean View is off │ ⎿ 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 › /simple ⎿ clean-view: Clean View is off. [ ○ Clean View: OFF ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ ○ Clean View: OFF ]
README

Clean View

A mod (a hooks plugin, not a skill) that makes Claude Code feel calm for people who aren't technical. While Claude works, tool calls, file diffs and command output are hidden, and one checklist above the prompt shows the plan, what is happening now and how far along it is.

Build my landing page · 1m 12s                     [ ● Clean View: ON ]
✓ Read your brand notes            ██████████  Done
▶ Build the pricing section        ██████░░░░  60%
○ Add the contact form             ░░░░░░░░░░  Next
○ Polish the footer                ░░░░░░░░░░  Up next

Features

  • One checklist — job name and time running, each step with a 10-cell meter; a sweep animation until a percent is reported
  • Plain states — Needs you (permission prompts and questions), Stuck (denied permission, repeated failures, API errors in one calm sentence), Stopped (Esc), All done (shrinks to one line after 5 seconds)
  • Hidden technical rows — tool calls, results, groups and the background-run hint; Claude's written replies stay visible
  • Two tools for Claude — plan_steps lays out the steps up front, report_progress fills the meter; TodoWrite and TaskCreate lists also feed the checklist
  • On/off anywhere — the button above the prompt or /simple on|off; remembered across restarts; starts on

Installation

/plugin install clean-view@ai-marketplace

Choose the user scope to have it in every session. It is a terminal (/plugin) install.

Usage

  • Turn it off: click the button or type /simple off. Every hidden row comes back and only the button stays.
  • Turn it on: click the button or type /simple on. /simple alone flips it.

Things to know

  • It changes how Claude behaves while on. It adds a section to the system prompt, and until Claude has called plan_steps (or TodoWrite / TaskCreate) every other tool call is denied, so each request costs one extra tool call. After 3 denials in one job the gate lets calls through, so it cannot deadlock. Subagents are never gated.
  • A small model call per job. A Haiku call at the start of each job names it in 2 to 6 words.
  • Escape hatch: /simple off removes the gate, the prompt section and the hiding.
  • It is a mod: it needs a Claude Code build that supports mods.

Development

claude plugin validate plugins/clean-view
claude plugin test plugins/clean-view
Source 3 files
hooks/register.tsx 8 lines
1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5export const register: Register = on => {
6  registerCleanView(on)
7}
8
hooks/clean-view.tsx 466 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Checklist, CleanPhase, CleanTask } from '../types'
5
6type On = Parameters<Register>[0]
7
8const enabled = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
9const tick = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
10
11export const IDLE: Checklist = {
12  title: '',
13  phase: 'idle',
14  tasks: [],
15  needsYouReason: null,
16  stuckReason: null,
17  startedAt: 0,
18  finishedAt: null,
19  isCollapsed: false,
20}
21const checklist = atom({ plugin: 'clean-view', key: 'checklist' } as const, IDLE)
22
23const PLAN = 'mcp__clean-view__plan_steps'
24const REPORT = 'mcp__clean-view__report_progress'
25const ALWAYS_ALLOWED = new Set([
26  'ToolSearch', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'AskUserQuestion', PLAN, REPORT,
27])
28const MAX_NAME = 40
29const METER = 10
30
31// ---------- pure helpers (exported for tests) ----------
32
33const CODE_EXT = /\.(tsx?|jsx?|mjs|cjs|json|ya?ml|toml|md|mdx|py|rb|go|rs|java|kt|cs|php|css|scss|html?|sh|sql|lock|env)\b/i
34
35export const cleanName = (raw: string): string => {
36  const words = raw
37    .replace(/`[^`]*`/g, ' ')
38    .split(/\s+/)
39    .filter(w => w !== '' && !w.includes('/') && !w.includes('\\') && !CODE_EXT.test(w))
40  let s = words.join(' ').trim()
41  if (s === '') return 'Working on it'
42  s = s.charAt(0).toUpperCase() + s.slice(1)
43  if (s.length > MAX_NAME) {
44    const cut = s.slice(0, MAX_NAME - 1)
45    const at = cut.lastIndexOf(' ')
46    s = `${(at > 8 ? cut.slice(0, at) : cut).trimEnd()}…`
47  }
48  return s
49}
50
51const task = (id: string, name: string): CleanTask => ({
52  id, name, status: 'upcoming', percent: 0, hasReported: false,
53})
54
55export const normalize = (tasks: CleanTask[]): CleanTask[] => {
56  let seen = false
57  return tasks.map(t => {
58    if (t.status === 'done') return { ...t, percent: 100, hasReported: true }
59    if (!seen) {
60      seen = true
61      return { ...t, status: 'active' as const }
62    }
63    return { ...t, status: 'upcoming' as const, percent: 0, hasReported: false }
64  })
65}
66
67export const placeholders = (): CleanTask[] =>
68  normalize([task('ph-1', 'Understand your request'), task('ph-2', 'Plan the steps')])
69
70export const hasRealPlan = (tasks: readonly CleanTask[]): boolean =>
71  tasks.some(t => !t.id.startsWith('ph-'))
72
73export const planSteps = (names: readonly string[]): CleanTask[] =>
74  normalize(names.slice(0, 8).map((n, i) => task(`s-${i + 1}`, cleanName(n))))
75
76export const reportProgress = (
77  tasks: readonly CleanTask[],
78  name: string,
79  rawPercent: number,
80): CleanTask[] => {
81  const percent = Math.min(100, Math.max(0, Math.round(Number.isFinite(rawPercent) ? rawPercent : 0)))
82  const clean = cleanName(name)
83  const key = clean.toLowerCase()
84  let list = tasks.filter(t => !t.id.startsWith('ph-')).map(t => ({ ...t }))
85  let i = list.findIndex(t => t.name.toLowerCase() === key)
86  if (i < 0) i = list.findIndex(t => t.name.toLowerCase().includes(key) || key.includes(t.name.toLowerCase()))
87  if (i < 0) {
88    list = [...list, task(`s-${list.length + 1}`, clean)]
89    i = list.length - 1
90  }
91  list = list.map((t, j) => (j < i ? { ...t, status: 'done' as const } : t))
92  list[i] =
93    percent >= 100
94      ? { ...list[i], status: 'done', percent: 100, hasReported: true }
95      : { ...list[i], status: 'active', percent, hasReported: true }
96  return normalize(list)
97}
98
99export const fromTodos = (
100  todos: ReadonlyArray<{ content: string; status: string }>,
101): CleanTask[] =>
102  normalize(
103    todos.map((t, i) => ({
104      ...task(`t-${i + 1}`, cleanName(t.content)),
105      status: t.status === 'completed' ? ('done' as const) : ('upcoming' as const),
106    })),
107  )
108
109export const errorSentence = (text: string): string => {
110  const s = text.toLowerCase()
111  if (/rate.?limit|usage limit|429|quota/.test(s)) return 'you hit your usage limit, try again a little later'
112  if (/overload|529|busy|unavailable|503/.test(s)) return "Claude's servers are busy, try again in a minute"
113  if (/context|too long|too many tokens|prompt is too long/.test(s)) return 'type /compact and try again'
114  if (/network|econn|enotfound|fetch failed|connection|timed? ?out|offline/.test(s)) return 'the internet connection dropped'
115  if (/auth|login|api key|401|credential|token expired/.test(s)) return 'type /login'
116  return 'something went wrong, try again'
117}
118
119export const formatElapsed = (ms: number): string => {
120  const s = Math.max(0, Math.floor(ms / 1000))
121  const m = Math.floor(s / 60)
122  return m > 0 ? `${m}m ${s % 60}s` : `${s}s`
123}
124
125export const meter = (t: CleanTask, frame: number, isWaiting: boolean): string => {
126  if (t.status === 'done') return '█'.repeat(METER)
127  if (t.status === 'upcoming') return '░'.repeat(METER)
128  if (!t.hasReported && !isWaiting) {
129    const pos = (frame % (METER + 3)) - 3
130    return Array.from({ length: METER }, (_, i) => (i >= pos && i < pos + 3 ? '█' : '░')).join('')
131  }
132  const filled = Math.round((t.percent / 100) * METER)
133  return '█'.repeat(filled) + '░'.repeat(METER - filled)
134}
135
136const pad = (s: string, width: number): string => {
137  const cut = s.length > width ? `${s.slice(0, Math.max(1, width - 1))}…` : s
138  return cut.padEnd(width)
139}
140
141// ---------- the mod ----------
142
143let timer: { cancel: () => void } | undefined
144let failures = 0
145let denials = 0
146
147
148function syncTimer($: any, phase: CleanPhase): void {
149  const isLive = phase === 'working' || phase === 'needs-you'
150  if (isLive && timer === undefined) {
151    timer = $.clock.every(250, () => {
152      void update($, tick, n => n + 1)
153    })
154  } else if (!isLive && timer !== undefined) {
155    timer.cancel()
156    timer = undefined
157  }
158}
159
160async function patch($: any, fn: (c: Checklist) => Checklist): Promise<Checklist> {
161  const next = await update($, checklist, fn)
162  const c = (next ?? (await read($, checklist))) as Checklist
163  syncTimer($, c.phase)
164  return c
165}
166
167async function setEnabled($: any, value: boolean): Promise<void> {
168  await update($, enabled, () => value)
169  await $.store.set('cleanViewEnabled', value)
170  $.ui.toast(`Clean View is ${value ? 'on' : 'off'}`)
171}
172
173async function finish($: any, phase: CleanPhase, extra: Partial<Checklist>): Promise<void> {
174  const now = await $.clock.now()
175  await patch($, c => ({ ...c, phase, finishedAt: now, ...extra }))
176  if (phase === 'done') {
177    $.clock.after(5000, () => {
178      void update($, checklist, c => (c.phase === 'done' ? { ...c, isCollapsed: true } : c))
179    })
180  }
181}
182
183
184export const registerCleanView = (on: On): void => {
185  on('session.start', async ($, e, next) => {
186    const saved = await $.store.get('cleanViewEnabled')
187    if (typeof saved === 'boolean') await update($, enabled, () => saved)
188    await $.command.register({
189      name: 'simple',
190      description: 'Turn Clean View on or off',
191      argumentHint: 'on|off',
192    })
193    await $.tool.register({
194      name: 'plan_steps',
195      description:
196        'Lay out every step of the job up front, 2 to 8 short plain-English names in order. The first step starts right away.',
197      inputSchema: {
198        type: 'object',
199        properties: { steps: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8 } },
200        required: ['steps'],
201      },
202      isDeferred: false,
203    })
204    await $.tool.register({
205      name: 'report_progress',
206      description:
207        'Report progress on a step of the plan. percent is 0 to 100; send 100 the moment the step finishes.',
208      inputSchema: {
209        type: 'object',
210        properties: { task: { type: 'string' }, percent: { type: 'number' } },
211        required: ['task', 'percent'],
212      },
213      isDeferred: false,
214    })
215    return next(e)
216  })
217
218  on('command.run', { command: 'simple' }, async ($, e) => {
219    const arg = String((e as { args?: string }).args ?? '').trim().toLowerCase()
220    const value = arg === 'on' ? true : arg === 'off' ? false : !(await read($, enabled))
221    await setEnabled($, value)
222    return { text: `Clean View is ${value ? 'on' : 'off'}.` }
223  })
224
225  on('prompt.compose', async ($, e, next) => {
226    const out = await next(e)
227    if (!(await read($, enabled))) return out
228    const text = [
229      'Clean View is on: the person sees only a checklist of your plan, not your tool calls.',
230      '- Write every step name in plain English a non-technical person understands, under 40 characters, starting with a verb, like "Build the pricing section".',
231      '- Never put file paths, file names, commands, code or tool names in a step name.',
232      `- For every request, even a quick question, call ${PLAN} first (load it with ToolSearch if it is deferred). Then call ${REPORT} as real progress happens, and with percent 100 the moment a step finishes.`,
233      '- If TodoWrite or TaskCreate is available, its to-do list can serve as the plan instead.',
234    ].join('\n')
235    return { ...out, sections: [...out.sections, { id: 'clean-view:rules', text, scope: 'session' as const }] }
236  })
237
238  on('turn.start', async ($, e, next) => {
239    if (e.text.trim().startsWith('/') || !(await read($, enabled))) return next(e)
240    const c = await read($, checklist)
241    if (c.phase === 'working' || c.phase === 'needs-you') {
242      await patch($, cur => ({ ...cur, phase: 'working', needsYouReason: null }))
243      return next(e)
244    }
245    const now = await $.clock.now()
246    failures = 0
247    denials = 0
248    await patch($, () => ({
249      ...IDLE,
250      title: 'Working on it',
251      phase: 'working',
252      tasks: placeholders(),
253      startedAt: now,
254    }))
255    void $.model
256      .complete({
257        model: 'haiku',
258        effort: 'low',
259        maxTokens: 30,
260        prompt: `Name this job in 2 to 6 plain words, starting with a verb. Reply with the name only.\n\nRequest: ${e.text.slice(0, 500)}`,
261      })
262      .then(async (r: any) => {
263        if (!r.isAnswered) return
264        const name = cleanName(String(r.text).split('\n')[0])
265        await update($, checklist, cur => (cur.startedAt === now ? { ...cur, title: name } : cur))
266      })
267      .catch(() => undefined)
268    return next(e)
269  })
270
271  on('tool.call', async ($, e, next) => {
272    if (e.agentId !== undefined || !(await read($, enabled))) return next(e)
273    const c = await read($, checklist)
274    const isJob = c.phase === 'working' || c.phase === 'needs-you' || c.phase === 'stuck'
275    if (isJob && !hasRealPlan(c.tasks) && !ALWAYS_ALLOWED.has(e.tool) && denials < 3) {
276      denials += 1
277      return {
278        deny: `Clean View needs a plan first. Call ${PLAN} with 2 to 8 short plain-English steps, then continue.`,
279      }
280    }
281    if (c.phase === 'needs-you' || (c.phase === 'working' && c.needsYouReason !== null)) {
282      await patch($, cur => ({ ...cur, phase: 'working', needsYouReason: null }))
283    }
284    if (e.tool === 'AskUserQuestion') {
285      await patch($, cur => ({ ...cur, phase: 'needs-you', needsYouReason: 'Claude has a question for you' }))
286    } else if (e.tool === 'TodoWrite') {
287      const todos = (e as { todos?: Array<{ content: string; status: string }> }).todos ?? []
288      await patch($, cur => ({ ...cur, tasks: fromTodos(todos) }))
289    } else if (e.tool === 'TaskCreate') {
290      const subject = String((e as { subject?: string }).subject ?? '')
291      await patch($, cur => {
292        const real = cur.tasks.filter(t => !t.id.startsWith('ph-'))
293        return { ...cur, tasks: normalize([...real, task(`n-${real.length + 1}`, cleanName(subject))]) }
294      })
295    } else if (e.tool === 'TaskUpdate') {
296      const u = e as { taskId?: string; status?: string; subject?: string }
297      await patch($, cur => {
298        const list = cur.tasks
299          .filter(t => !(u.status === 'deleted' && t.id === `n-${u.taskId}`))
300          .map(t => {
301            if (t.id !== `n-${u.taskId}`) return t
302            const name = u.subject !== undefined ? cleanName(u.subject) : t.name
303            if (u.status === 'completed') return { ...t, name, status: 'done' as const }
304            return { ...t, name }
305          })
306        return { ...cur, tasks: normalize(list) }
307      })
308    }
309    return next(e)
310  }).catch(($, e, next) => next(e))
311
312  on('tool.call', { tool: PLAN }, async ($, e) => {
313    const steps = ((e as { steps?: unknown }).steps ?? []) as unknown
314    const names = Array.isArray(steps) ? steps.map(String) : []
315    if (names.length === 0) return { deny: 'Give at least one step.' }
316    const tasks = planSteps(names)
317    await patch($, c => ({ ...c, tasks }))
318    return { result: `Planned ${tasks.length} steps. The first one has started.` }
319  })
320
321  on('tool.call', { tool: REPORT }, async ($, e) => {
322    const r = e as { task?: string; percent?: number }
323    const percent = Math.min(100, Math.max(0, Number(r.percent ?? 0)))
324    await patch($, c => ({ ...c, tasks: reportProgress(c.tasks, String(r.task ?? ''), percent) }))
325    return { result: `Progress noted: ${Math.round(percent)}%.` }
326  })
327
328  on('classic.Notification', async ($, e, next) => {
329    const c = await read($, checklist)
330    if ((c.phase === 'working' || c.phase === 'needs-you') && (await read($, enabled))) {
331      await patch($, cur => ({ ...cur, phase: 'needs-you', needsYouReason: 'Claude needs your OK to continue' }))
332    }
333    return next(e)
334  })
335
336  on('classic.PermissionDenied', async ($, e, next) => {
337    await patch($, c => ({
338      ...c, phase: 'stuck', stuckReason: 'you said no to a step, so Claude paused',
339    }))
340    return next(e)
341  })
342
343  on('classic.PostToolUseFailure', async ($, e, next) => {
344    if (e.is_interrupt === true) return next(e)
345    failures += 1
346    if (failures >= 3) {
347      await patch($, c => ({
348        ...c, phase: 'stuck', stuckReason: 'a step keeps failing, Claude is trying another way',
349      }))
350    }
351    return next(e)
352  })
353
354  on('classic.PostToolUse', async ($, e, next) => {
355    failures = 0
356    const c = await read($, checklist)
357    if (c.phase === 'stuck') await patch($, cur => ({ ...cur, phase: 'working', stuckReason: null }))
358    return next(e)
359  })
360
361  on('turn.complete', async ($, e, next) => {
362    if (e.agentId !== undefined || !(await read($, enabled))) return next(e)
363    const c = await read($, checklist)
364    if (c.phase === 'idle') return next(e)
365    if (e.reason === 'error') {
366      await finish($, 'stuck', { stuckReason: errorSentence(e.answer) })
367    } else if (e.reason === 'refusal') {
368      await finish($, 'stuck', { stuckReason: "Claude couldn't help with that request" })
369    } else if (e.reason === 'aborted') {
370      await finish($, 'stopped', {})
371    } else if (c.tasks.some(t => t.status !== 'done')) {
372      await patch($, cur => ({
373        ...cur, phase: 'needs-you', needsYouReason: 'Claude is waiting for your reply',
374      }))
375      syncTimer($, 'idle')
376    } else {
377      await finish($, 'done', {})
378    }
379    return next(e)
380  })
381
382  // Hide the technical rows.
383  for (const component of ['ToolUse', 'ToolResult', 'ToolGroup'] as const) {
384    on('ui.render', { component }, async ($, e, next) => {
385      if (!(await read($, enabled))) return next(e)
386      const { Box } = $.ui.resolve(e)
387      return <Box />
388    })
389  }
390
391  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
392    if (!(await read($, enabled))) return next(e)
393    return next({ ...e, props: { ...e.props, hint: '' } })
394  })
395
396  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
397    if (e.props.hasSurvey) return next(e)
398    const { Box, Text, Button } = $.ui.resolve(e)
399    const isOn = await read($, enabled)
400    const c = await read($, checklist)
401    const frame = await read($, tick)
402    const now = await $.clock.now()
403
404    const button = (
405      <Button
406        key="toggle"
407        label={isOn ? '● Clean View: ON' : '○ Clean View: OFF'}
408        onPress={() => setEnabled($, !isOn)}
409      />
410    )
411
412    let left: any = null
413    if (isOn && c.phase !== 'idle') {
414      const took = formatElapsed((c.finishedAt ?? now) - c.startedAt)
415      if (c.phase === 'working') {
416        left = <Text bold>{c.title} · {took}</Text>
417      } else if (c.phase === 'needs-you') {
418        left = (
419          <Text>
420            <Text inverse bold> Needs you </Text> {c.needsYouReason}
421          </Text>
422        )
423      } else if (c.phase === 'stuck') {
424        left = <Text color="red">⚠ Stuck: {c.stuckReason}</Text>
425      } else if (c.phase === 'stopped') {
426        left = <Text dimColor>■ Stopped · {c.title} · you pressed Esc</Text>
427      } else {
428        left = <Text color="green">✓ All done · {c.title} · took {took}</Text>
429      }
430    }
431
432    const isShort = c.phase === 'done' && c.isCollapsed
433    const showRows = isOn && c.phase !== 'idle' && !isShort && c.tasks.length > 0
434    const isWaiting = c.phase === 'needs-you'
435    const nameWidth = Math.max(8, e.props.bodyColumns - 2 - METER - 2 - 9)
436
437    return (
438      <Box flexDirection="column">
439        <Box justifyContent="space-between">
440          <Box flexGrow={1}>{left}</Box>
441          {button}
442        </Box>
443        {showRows &&
444          c.tasks.map(t => {
445            const icon = t.status === 'done' ? '✓' : t.status === 'active' ? (isWaiting ? '‖' : '▶') : '○'
446            const first = c.tasks.find(x => x.status === 'upcoming')
447            const label =
448              t.status === 'done'
449                ? 'Done'
450                : t.status === 'active'
451                  ? t.hasReported ? `${t.percent}%` : 'Working'
452                  : t === first ? 'Next' : 'Up next'
453            const dim = t.status !== 'active'
454            return (
455              <Text key={t.id} dimColor={t.status === 'upcoming' || t.status === 'done'}>
456                <Text color={t.status === 'done' ? 'green' : undefined}>{icon} </Text>
457                <Text bold={t.status === 'active'} dimColor={dim}>{pad(t.name, nameWidth)}</Text>
458                {' '}{meter(t, frame, isWaiting)}  {label}
459              </Text>
460            )
461          })}
462      </Box>
463    )
464  })
465}
466
types/index.d.ts 27 lines
1export type CleanTask = {
2  id: string
3  name: string
4  status: 'done' | 'active' | 'upcoming'
5  percent: number
6  hasReported: boolean
7}
8
9export type CleanPhase = 'idle' | 'working' | 'needs-you' | 'stuck' | 'stopped' | 'done'
10
11export type Checklist = {
12  title: string
13  phase: CleanPhase
14  tasks: CleanTask[]
15  needsYouReason: string | null
16  stuckReason: string | null
17  startedAt: number
18  finishedAt: number | null
19  isCollapsed: boolean
20}
21
22declare module 'claude-code' {
23  interface PluginState {
24    'clean-view': { cleanViewEnabled: boolean; checklist: Checklist; tick: number }
25  }
26}
27