SLOPSHOPPER

clean-view

Hides tool calls, diffs and command output while Claude works, and shows one plain-English checklist above the prompt instead.

newbandspinnerrowsguardcommand
v0.1.6MITupdated 2026-10-25mugenracerman/clean-view
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: showing everything │ ⎿ Read 6 lines ╰───────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Read(/work/app/src/auth.ts) ⎿ Denied by clean-view: Clean View: lay out the plan first. Call mcp__clean-view__plan_steps with 2 to 8 pla ● 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. Everything is visible again. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts clean-view-toggle ⟨Claude Code's own drawing⟩
README

Clean View

A Claude Code mod that makes Claude Code calm and friendly for people who aren't technical.

While Claude works, tool calls, file diffs and command output are hidden. Instead, a progress box sits above the prompt so you can always see the plan, what's happening now and how far along it is:

The Clean View progress box in Claude Code: the job's name, "Step 3 of 4" with an overall bar at 50%, two finished steps, the current step working, and one step next

The same box, drawn in text:

╭──────────────────────────────────────────────────────────────╮
│ ✦ Build my landing page                               1m 12s │
│ Step 2 of 4 ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░   40% │
│ ✓ Read your brand notes          ━━━━━━━━━━  Done            │
│ ● Build the pricing section      ━━━━━━┄┄┄┄  60%             │
│ ○ Add the contact form           ┄┄┄┄┄┄┄┄┄┄  Next            │
│ ○ Polish the footer              ┄┄┄┄┄┄┄┄┄┄  Up next         │
╰──────────────────────────────────────────────────────────────╯

Claude's written replies stay visible as normal.

Reading the box

  • Top line: the job's name in a few plain words, with how long it has been running on the right.
  • Overall bar: which step Claude is on, a bar for the whole job, and the total percent.
  • Steps:
  • ✓ a finished step, with a full green meter
  • ● the step happening now, with its meter filling as Claude reports progress. Before the first report, a blue sweep moves across it and it says Working.
  • ○ steps still to come, marked Next and Up next

The frame's colour tells you how things are going:

FrameWhat it means
PinkClaude is working
AmberNeeds you: Claude is waiting for your OK or has a question (the current step shows ‖), or it's stuck and says why in one plain sentence
GreyStopped: you pressed Esc
GreenAll done, with how long it took. After 5 seconds the box shrinks to one line.

Install

Type this at the prompt of a Claude Code terminal session:

/plugin install clean-view --marketplace mugenracerman/clean-view

Answer y to add the marketplace, then press Enter to install it for your user.

Turn it on and off

  • Click the Clean View: On toggle in the footer under the prompt. It sits beside the other mods' toggles. Or:
  • type /simple on, /simple off, or just /simple to flip it (/clean-view on and off do the same, and are what the Damon's Tools toggle uses).

Your choice is remembered after a restart. When it's off, the box goes away and every hidden row comes back.

Update

claude plugin marketplace update clean-view
claude plugin update clean-view@clean-view

Then type /reload-plugins in Claude Code, or restart it.

Good to know

  • While it's on, Claude makes a short plain-English plan before every request, even a quick question.
  • Each new request makes one small call to Claude Haiku to give the job a short name.
  • Permission prompts still appear as normal, so you stay in control of risky actions.

Tests

claude plugin test .

License

MIT. See LICENSE.

Source 4 files
hooks/register.tsx 9 lines
1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5// One entry point; more mods can be registered here beside Clean View.
6export const register: Register = on => {
7  registerCleanView(on)
8}
9
hooks/clean-view.tsx 718 lines
1import { atom, read, update } from 'claude-code'
2import type { ModelCompleteRequest, ModelCompleteResult, On, Timer } from 'claude-code'
3
4import type { CleanViewChecklist, CleanViewTask } from '../types'
5import {
6  addTask,
7  clampPercent,
8  cleanName,
9  errorSentence,
10  finishAll,
11  fit,
12  formatDuration,
13  fromTodos,
14  meterCells,
15  overallPercent,
16  stepLabel,
17  titleColor,
18  newJob,
19  planSteps,
20  reportProgress,
21  updateTask,
22} from './checklist'
23
24type Checklist = CleanViewChecklist | null
25
26// Damon's Tools (it registers /damons-tools) shows every plugin's switch in its
27// own dashboard and keeps the footer to one button; while it is installed this
28// plugin leaves its footer toggle out. Looked up at most every few seconds.
29let dashboardSeen: { at: number; isThere: boolean } | undefined
30async function hasDashboard($: {
31  clock: { now: () => Promise<number> }
32  command: { list: () => Promise<readonly { name: string }[]> }
33}): Promise<boolean> {
34  // Where the lookup cannot run, keep the toggle.
35  try {
36    const now = await $.clock.now()
37    if (dashboardSeen && now - dashboardSeen.at < 5000) return dashboardSeen.isThere
38    const isThere = (await $.command.list()).some(c => c.name === 'damons-tools')
39    dashboardSeen = { at: now, isThere }
40    return isThere
41  } catch {
42    return false
43  }
44}
45
46export const PLAN_TOOL = 'mcp__clean-view__plan_steps'
47export const PROGRESS_TOOL = 'mcp__clean-view__report_progress'
48
49const STORE_KEY = 'cleanViewEnabled'
50const SWITCH_KEY = 'isOn'
51const TICK_MS = 250
52const COLLAPSE_MS = 5000
53const FAILS_BEFORE_STUCK = 3
54const LABEL_CELLS = 7
55
56// The footer toggle's colours, the same as Prompt Upgrade's and Auto Model's.
57const LINE = '#262B33'
58const MUTED = '#7D8590'
59const TEXT = '#E6EDF3'
60
61// The progress box's colours.
62const PINK = '#E85D9E'
63const GREEN = '#3DD68C'
64const AMBER = '#E3B341'
65const SWEEP = '#6C78D8'
66const FRAME: Record<CleanViewChecklist['phase'], string> = {
67  working: PINK,
68  'needs-you': AMBER,
69  stuck: AMBER,
70  stopped: MUTED,
71  done: GREEN,
72}
73
74const ALWAYS_ALLOWED = new Set([
75  'ToolSearch',
76  'TodoWrite',
77  'TaskCreate',
78  'TaskUpdate',
79  'AskUserQuestion',
80  PLAN_TOOL,
81  PROGRESS_TOOL,
82])
83
84const NEEDS_OK = 'Claude needs your OK to continue'
85const HAS_QUESTION = 'Claude has a question for you'
86const WAITING_FOR_REPLY = 'Claude is waiting for your reply'
87const SAID_NO = 'you said no to a step, so Claude paused'
88const KEEPS_FAILING = 'a step keeps failing, Claude is trying another way'
89const REFUSED = "Claude couldn't help with that request"
90
91const REJECTED = /doesn't want to proceed|was rejected|user (?:rejected|denied|declined)|permission (?:was )?denied/i
92
93const GATE_MESSAGE =
94  `Clean View: lay out the plan first. Call ${PLAN_TOOL} with 2 to 8 plain-English step names ` +
95  '(load it with ToolSearch if it is deferred), then try this again.'
96
97const GUIDE = [
98  '# Clean View',
99  'The person is using Clean View. They do not see tool calls, file changes or command output: only your written replies and a checklist of your plan above the prompt.',
100  `- For every request, even a quick question, call \`${PLAN_TOOL}\` first, before any other tool. Load it with ToolSearch if it is deferred. Other tools are refused until a plan exists.`,
101  '- Write every step name in plain English a non-technical person understands. Keep it under 40 characters and start it with a verb, like "Build the pricing section".',
102  '- Never put file paths, file names, commands, code or tool names in a step name.',
103  `- Call \`${PROGRESS_TOOL}\` with the step's name and a percent as real progress happens, and with 100 the moment a step finishes; the next step then starts by itself.`,
104  '- If this session has TodoWrite or TaskCreate, you may use your to-do list as the plan instead. The same naming rules apply.',
105].join('\n')
106
107const enabled = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
108const checklist = atom({ plugin: 'clean-view', key: 'checklist' } as const, null)
109const tick = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
110
111// The module's own variables start over on a hot reload; everything drawn lives in $.state.
112let ticker: Timer | undefined
113let failures = 0
114let lastApiError: { kind: string | null; details: string | null } | null = null
115
116const isLive = (list: Checklist) => list !== null && (list.phase === 'working' || list.phase === 'needs-you')
117
118/**
119 * What the shared helpers need from the engine, as closures each hook builds
120 * over its own $ (a hooks module never passes $ itself around).
121 */
122type IO = {
123  write: (fn: (list: Checklist) => Checklist) => Promise<unknown>
124  isLive: () => Promise<boolean>
125  every: (ms: number, fn: () => void) => Timer
126  bump: () => Promise<unknown>
127}
128
129/** The 250ms clock runs only while a job is working or waiting on the person. */
130async function keepTicking(io: IO): Promise<void> {
131  if (ticker !== undefined || !(await io.isLive())) return
132  ticker = io.every(TICK_MS, () => {
133    void (async () => {
134      if (!(await io.isLive())) {
135        ticker?.cancel()
136        ticker = undefined
137        return
138      }
139      await io.bump()
140    })()
141  })
142}
143
144async function change(io: IO, fn: (list: Checklist) => Checklist): Promise<void> {
145  await io.write(fn)
146  await keepTicking(io)
147}
148
149const whileOpen = (fn: (list: CleanViewChecklist) => CleanViewChecklist) => (list: Checklist) =>
150  list === null || list.finishedAt !== null ? list : fn(list)
151
152const backToWork = (list: CleanViewChecklist): CleanViewChecklist => ({
153  ...list,
154  phase: 'working',
155  needsYouReason: null,
156  stuckReason: null,
157})
158
159const toastFor = (value: boolean) =>
160  value ? 'Clean View is on: tool details are hidden' : 'Clean View is off: showing everything'
161
162/** Asks Haiku for a 2 to 6 word name, in the background; a newer job wins. */
163function nameJob(
164  complete: (request: ModelCompleteRequest) => Promise<ModelCompleteResult>,
165  write: IO['write'],
166  request: string,
167  startedAt: number,
168): void {
169  void (async () => {
170    const reply = await complete({
171      model: 'haiku',
172      effort: 'low',
173      maxTokens: 30,
174      timeoutMs: 20000,
175      system:
176        'You name tasks for a progress checklist read by non-technical people. Reply with the name only: 2 to 6 plain English words that start with a verb, like "Build my landing page". No file names, code, quotes or ending punctuation.',
177      prompt: `Name this request:\n\n${request.slice(0, 2000)}`,
178    })
179    if (!reply.isAnswered) return
180    const line = (reply.text.split('\n').find(l => l.trim() !== '') ?? '').replace(
181      /^[\s"'“”‘’*#-]+|[\s"'“”‘’*.!?:]+$/g,
182      '',
183    )
184    const title = cleanName(line.split(/\s+/).slice(0, 6).join(' '), '')
185    if (title === '') return
186    await write(list => (list !== null && list.startedAt === startedAt ? { ...list, title } : list))
187  })().catch(() => undefined)
188}
189
190export function registerCleanView(on: On): void {
191  on('session.start', async ($, e, next) => {
192    const io: IO = {
193      write: fn => update($, checklist, fn),
194      isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
195      every: (ms, fn) => $.clock.every(ms, fn),
196      bump: () => update($, tick, n => n + 1),
197    }
198    const stored = await $.store.get(STORE_KEY)
199    await $.state.set({ plugin: 'clean-view', key: 'cleanViewEnabled' }, stored !== false)
200    // `isOn` is the switch a dashboard reads; it mirrors Clean View's own.
201    await $.store.set(SWITCH_KEY, stored !== false)
202    await $.tool.register({
203      name: 'plan_steps',
204      description:
205        'Lay out every step of the job before you start, in order: 2 to 8 short names in plain English a non-technical person understands, each under 40 characters and starting with a verb ("Build the pricing section"). Never put file paths, file names, commands, code or tool names in a step name. The first step starts right away.',
206      inputSchema: {
207        type: 'object',
208        properties: {
209          steps: {
210            type: 'array',
211            items: { type: 'string' },
212            minItems: 2,
213            maxItems: 8,
214            description: 'The step names, in order.',
215          },
216        },
217        required: ['steps'],
218      },
219      isDeferred: false,
220    })
221    await $.tool.register({
222      name: 'report_progress',
223      description:
224        "Report progress on the current step: its name as planned and how far along it is, 0 to 100. Report 100 the moment a step finishes; the next one starts by itself. Reporting a later step checks off the ones before it, and a name that isn't in the plan becomes a new step.",
225      inputSchema: {
226        type: 'object',
227        properties: {
228          task: { type: 'string', description: 'The step name, as planned.' },
229          percent: { type: 'number', minimum: 0, maximum: 100, description: 'How far along the step is.' },
230        },
231        required: ['task', 'percent'],
232      },
233      isDeferred: false,
234    })
235    await $.command.register({
236      name: 'simple',
237      description: 'Turn Clean View on or off (with no argument, flip it)',
238      argumentHint: '[on|off]',
239      immediate: true,
240    })
241    // The switch under the plugin's own name, which Damon's Tools flips.
242    await $.command.register({
243      name: 'clean-view',
244      description: 'Turn Clean View on or off',
245      argumentHint: 'on | off',
246      immediate: true,
247    })
248    await keepTicking(io)
249    return next(e)
250  })
251
252  on('command.run', { command: 'simple' }, async ($, e) => {
253    const io: IO = {
254      write: fn => update($, checklist, fn),
255      isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
256      every: (ms, fn) => $.clock.every(ms, fn),
257      bump: () => update($, tick, n => n + 1),
258    }
259    const arg = e.args.trim().toLowerCase()
260    const current = await read($, enabled)
261    const want = arg === 'on' ? true : arg === 'off' ? false : arg === '' ? !current : null
262    if (want === null) return { text: 'Use /simple on, /simple off, or just /simple to flip it.' }
263    await $.state.set({ plugin: 'clean-view', key: 'cleanViewEnabled' }, want)
264    await $.store.set(STORE_KEY, want)
265    await $.store.set(SWITCH_KEY, want)
266    $.ui.toast(toastFor(want))
267    await keepTicking(io)
268    return { text: want ? 'Clean View is on.' : 'Clean View is off. Everything is visible again.' }
269  })
270
271  on('command.run', { command: 'clean-view' }, async ($, e) => {
272    const io: IO = {
273      write: fn => update($, checklist, fn),
274      isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
275      every: (ms, fn) => $.clock.every(ms, fn),
276      bump: () => update($, tick, n => n + 1),
277    }
278    const arg = e.args.trim().toLowerCase()
279    if (arg !== 'on' && arg !== 'off') {
280      return { text: `Clean View is ${(await read($, enabled)) ? 'on' : 'off'}. Use /clean-view on or off.` }
281    }
282    const want = arg === 'on'
283    await $.state.set({ plugin: 'clean-view', key: 'cleanViewEnabled' }, want)
284    await $.store.set(STORE_KEY, want)
285    await $.store.set(SWITCH_KEY, want)
286    $.ui.toast(toastFor(want))
287    await keepTicking(io)
288    return { text: want ? 'Clean View is on.' : 'Clean View is off. Everything is visible again.' }
289  })
290
291  // A toggle in the prompt footer, left of the other mods' toggles and the
292  // engine's mode labels (all drawn by `next`); a click flips Clean View.
293  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
294    if (await hasDashboard($)) return next(e)
295    const { Box, Button, Text } = $.ui.resolve(e)
296    const isOn = await read($, enabled)
297    const label = `Clean View: ${isOn ? 'On' : 'Off'}`
298    const flip = async () => {
299      const io: IO = {
300        write: fn => update($, checklist, fn),
301        isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
302        every: (ms, fn) => $.clock.every(ms, fn),
303        bump: () => update($, tick, n => n + 1),
304      }
305      const value = await update($, enabled, one => !one)
306      await $.store.set(STORE_KEY, value)
307      await $.store.set(SWITCH_KEY, value)
308      $.ui.toast(toastFor(value))
309      await keepTicking(io)
310    }
311    const theirs = await next(e).catch(() => null)
312    return (
313      <Box flexDirection="row" columnGap={1}>
314        {e.surface === 'terminal' ? (
315          <Button key="clean-view-toggle" plain onPress={flip}>
316            <Text backgroundColor={LINE} color={isOn ? TEXT : MUTED}>{` ${label} `}</Text>
317          </Button>
318        ) : (
319          <Button key="clean-view-toggle" label={label} plain onPress={flip} />
320        )}
321        {theirs}
322      </Box>
323    )
324  })
325
326  // While Clean View is on, every request gets a plain-English plan first.
327  on('prompt.compose', async ($, e, next) => {
328    const composed = await next(e)
329    if (!(await read($, enabled))) return composed
330    return {
331      ...composed,
332      sections: [...composed.sections, { id: 'clean-view:plain-steps', text: GUIDE, scope: 'session' as const }],
333    }
334  })
335
336  on('tool.call', { tool: PLAN_TOOL }, async ($, e) => {
337    const io: IO = {
338      write: fn => update($, checklist, fn),
339      isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
340      every: (ms, fn) => $.clock.every(ms, fn),
341      bump: () => update($, tick, n => n + 1),
342    }
343    if (e.agentId !== undefined) return { result: 'Only the main agent keeps the plan. Carry on.' }
344    const raw = (e as { steps?: unknown }).steps
345    const names = Array.isArray(raw) ? raw.slice(0, 8).map(step => cleanName(step)) : []
346    if (names.length === 0) {
347      return { deny: 'plan_steps needs `steps`: a list of 2 to 8 short plain-English step names.' }
348    }
349    const now = await $.clock.now()
350    failures = 0
351    await change(io, list => planSteps(list, names, now))
352    return { result: `Planned ${names.length} steps. The first one has started.` }
353  })
354
355  on('tool.call', { tool: PROGRESS_TOOL }, async ($, e) => {
356    const io: IO = {
357      write: fn => update($, checklist, fn),
358      isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
359      every: (ms, fn) => $.clock.every(ms, fn),
360      bump: () => update($, tick, n => n + 1),
361    }
362    if (e.agentId !== undefined) return { result: 'Only the main agent keeps the plan. Carry on.' }
363    const input = e as { task?: unknown; percent?: unknown }
364    const percent = clampPercent(input.percent)
365    const name = cleanName(input.task)
366    const now = await $.clock.now()
367    await change(io, list => reportProgress(list, name, percent, now))
368    return { result: `Progress noted: ${percent}%.` }
369  })
370
371  // The gate, "Needs you" for questions, and the checklist built from what Claude already does.
372  on('tool.call', async ($, e, next) => {
373    if (e.agentId !== undefined) return next(e)
374    const tool = String(e.tool)
375    if (tool === PLAN_TOOL || tool === PROGRESS_TOOL) return next(e)
376    const io: IO = {
377      write: fn => update($, checklist, fn),
378      isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
379      every: (ms, fn) => $.clock.every(ms, fn),
380      bump: () => update($, tick, n => n + 1),
381    }
382
383    const isOn = await read($, enabled)
384    const before = await read($, checklist)
385    if (isOn && !ALWAYS_ALLOWED.has(tool) && !(before?.isPlanned ?? false)) {
386      return { deny: GATE_MESSAGE }
387    }
388
389    if (before?.phase === 'needs-you') await change(io, whileOpen(backToWork))
390    if (tool === 'AskUserQuestion') {
391      await change(io, whileOpen(list => ({ ...list, phase: 'needs-you', needsYouReason: HAS_QUESTION })))
392    }
393
394    const ran = await next(e)
395
396    const now = await $.clock.now()
397    const after = await read($, checklist)
398    if (after?.phase === 'needs-you' && after.needsYouReason !== WAITING_FOR_REPLY) {
399      await change(io, whileOpen(backToWork))
400    }
401    if (ran.deny !== undefined) return ran
402
403    if (ran.isError === true) {
404      if (REJECTED.test(ran.text ?? '')) {
405        failures = 0
406        await change(io, whileOpen(list => ({ ...list, phase: 'stuck', stuckReason: SAID_NO, needsYouReason: null })))
407      } else {
408        failures += 1
409        if (failures >= FAILS_BEFORE_STUCK) {
410          await change(io, whileOpen(list => ({ ...list, phase: 'stuck', stuckReason: KEEPS_FAILING })))
411        }
412      }
413      return ran
414    }
415
416    failures = 0
417    if ((await read($, checklist))?.phase === 'stuck') await change(io, whileOpen(backToWork))
418
419    if (e.tool === 'TodoWrite') {
420      const todos = e.todos
421      await change(io, list => fromTodos(list, todos, now))
422    } else if (e.tool === 'TaskCreate') {
423      const made = ran.result as { task?: { id?: unknown } } | undefined
424      const id = typeof made?.task?.id === 'string' ? made.task.id : `task-${now}`
425      const name = cleanName(e.subject)
426      await change(io, list => addTask(list, id, name, now))
427    } else if (e.tool === 'TaskUpdate') {
428      const { taskId, status, subject } = e
429      const name = subject === undefined ? undefined : cleanName(subject)
430      await change(io, list => updateTask(list, taskId, { status, name }, now))
431    }
432    return ran
433  })
434
435  // A permission prompt or a question for the person.
436  on('classic.Notification', async ($, e, next) => {
437    const io: IO = {
438      write: fn => update($, checklist, fn),
439      isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
440      every: (ms, fn) => $.clock.every(ms, fn),
441      bump: () => update($, tick, n => n + 1),
442    }
443    if (e.notification_type === 'permission_prompt' || e.notification_type === 'elicitation_dialog') {
444      const reason = e.notification_type === 'permission_prompt' ? NEEDS_OK : HAS_QUESTION
445      await change(io, whileOpen(list => ({ ...list, phase: 'needs-you', needsYouReason: reason })))
446    }
447    return next(e)
448  })
449
450  // The kind of API error, which turn.complete does not carry.
451  on('classic.StopFailure', async ($, e, next) => {
452    lastApiError = { kind: e.error, details: e.error_details ?? null }
453    const list = await read($, checklist)
454    if (list?.phase === 'stuck' && list.finishedAt !== null) {
455      const stuckReason = errorSentence(e.error, e.error_details ?? null)
456      await update($, checklist, l => (l === null ? l : { ...l, stuckReason }))
457    }
458    return next(e)
459  })
460
461  on('turn.start', async ($, e, next) => {
462    const io: IO = {
463      write: fn => update($, checklist, fn),
464      isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
465      every: (ms, fn) => $.clock.every(ms, fn),
466      bump: () => update($, tick, n => n + 1),
467    }
468    const text = e.text.trim()
469    const list = await read($, checklist)
470    const isOn = await read($, enabled)
471    failures = 0
472    lastApiError = null
473    const isRealPrompt = text !== '' && !text.startsWith('/')
474
475    if (isRealPrompt && isOn && list?.phase !== 'needs-you') {
476      const now = await $.clock.now()
477      await change(io, () => newJob(now))
478      nameJob(request => $.model.complete(request), io.write, text, now)
479    } else if (list !== null && (list.phase === 'needs-you' || list.finishedAt === null)) {
480      // The person replied, or the job goes on.
481      await change(io, l => (l === null ? l : { ...backToWork(l), finishedAt: null, isCollapsed: false }))
482    }
483    return next(e)
484  })
485
486  on('turn.complete', async ($, e, next) => {
487    if (e.agentId !== undefined) return next(e)
488    const list = await read($, checklist)
489    if (list === null || list.finishedAt !== null) return next(e)
490    const io: IO = {
491      write: fn => update($, checklist, fn),
492      isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
493      every: (ms, fn) => $.clock.every(ms, fn),
494      bump: () => update($, tick, n => n + 1),
495    }
496
497    const now = await $.clock.now()
498    let outcome: CleanViewChecklist
499    if (e.reason === 'error') {
500      const reason = errorSentence(lastApiError?.kind ?? null, lastApiError?.details ?? null)
501      outcome = { ...list, phase: 'stuck', stuckReason: reason, needsYouReason: null, finishedAt: now }
502    } else if (e.reason === 'refusal') {
503      outcome = { ...list, phase: 'stuck', stuckReason: REFUSED, needsYouReason: null, finishedAt: now }
504    } else if (e.reason === 'aborted') {
505      outcome =
506        list.stuckReason === SAID_NO
507          ? { ...list, phase: 'stuck', needsYouReason: null, finishedAt: now }
508          : { ...list, phase: 'stopped', needsYouReason: null, stuckReason: null, finishedAt: now }
509    } else if (list.isPlanned && list.tasks.some(t => t.status !== 'done')) {
510      outcome = { ...list, phase: 'needs-you', needsYouReason: WAITING_FOR_REPLY, stuckReason: null }
511    } else {
512      outcome = {
513        ...list,
514        phase: 'done',
515        tasks: finishAll(list.tasks),
516        needsYouReason: null,
517        stuckReason: null,
518        finishedAt: now,
519      }
520      $.clock.after(COLLAPSE_MS, () => {
521        void update($, checklist, l =>
522          l !== null && l.phase === 'done' && l.finishedAt === now ? { ...l, isCollapsed: true } : l,
523        )
524      })
525    }
526    await change(io, () => outcome)
527    return next(e)
528  })
529
530  // Hide the technical rows while Clean View is on; Claude's replies stay.
531  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
532    if (!(await read($, enabled))) return next(e)
533    const { Box } = $.ui.resolve(e)
534    return <Box display="none" />
535  })
536
537  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
538    if (!(await read($, enabled))) return next(e)
539    const { Box } = $.ui.resolve(e)
540    return <Box display="none" />
541  })
542
543  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
544    if (!(await read($, enabled))) return next(e)
545    const { Box } = $.ui.resolve(e)
546    return <Box display="none" />
547  })
548
549  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
550    if (!(await read($, enabled))) return next(e)
551    return next({ ...e, props: { ...e.props, hint: '' } })
552  })
553
554  // The checklist band; the on/off toggle lives in the prompt footer.
555  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
556    if (e.props.hasSurvey) return next(e)
557
558    const isOn = await read($, enabled)
559    const list = isOn ? await read($, checklist) : null
560    // Nothing to show: leave the band to whatever else draws there.
561    if (list === null) return next(e)
562
563    const { Box, Text } = $.ui.resolve(e)
564    const frame = list.phase === 'working' ? await read($, tick) : 0
565    const now = await $.clock.now()
566    // Whatever else draws in the band (other mods) stays beneath ours. The engine
567    // refuses its own drawing under a Box with a width, so the outer Box has none.
568    const below = await next(e).catch(() => null)
569    const width = Math.max(24, e.props.bodyColumns)
570
571    // The box: a coloured frame, the job's name and time, the overall bar, then the steps.
572    const inner = Math.max(20, width - 4)
573    const elapsed = formatDuration((list.finishedAt ?? now) - list.startedAt)
574    const words = list.title.split(' ')
575
576    const title = (
577      <Text bold>
578        {words.map((word, i) => (
579          <Text color={titleColor(i, words.length)}>{(i === 0 ? '' : ' ') + word}</Text>
580        ))}
581      </Text>
582    )
583
584    const headline = (() => {
585      switch (list.phase) {
586        case 'working':
587          return (
588            <Text wrap="truncate-end">
589              <Text color={PINK}>✦ </Text>
590              {title}
591            </Text>
592          )
593        case 'needs-you':
594          return (
595            <Text wrap="truncate-end">
596              <Text inverse bold color={AMBER}>
597                {' Needs you '}
598              </Text>
599              <Text> {list.needsYouReason ?? NEEDS_OK}</Text>
600            </Text>
601          )
602        case 'stuck':
603          return (
604            <Text wrap="truncate-end" color={AMBER}>
605              ⚠ Stuck: {list.stuckReason ?? KEEPS_FAILING}
606            </Text>
607          )
608        case 'stopped':
609          return (
610            <Text wrap="truncate-end">
611              <Text bold>■ Stopped</Text>
612              <Text color={MUTED}> · {list.title} · you pressed Esc</Text>
613            </Text>
614          )
615        case 'done':
616          return (
617            <Text wrap="truncate-end">
618              <Text bold color={GREEN}>
619                ✓ All done
620              </Text>
621              <Text color={MUTED}> · {list.title}</Text>
622            </Text>
623          )
624      }
625    })()
626    const timeText = list.phase === 'done' ? `took ${elapsed}` : elapsed
627    const header = (
628      <Box flexDirection="row" justifyContent="space-between" width={inner}>
629        <Box flexShrink={1}>{headline}</Box>
630        <Text color={MUTED}>{' ' + timeText}</Text>
631      </Box>
632    )
633
634    // Finished and five seconds old: one line.
635    if (list.phase === 'done' && list.isCollapsed) {
636      return (
637        <Box flexDirection="column">
638          <Box paddingX={2} width={width}>
639            {header}
640          </Box>
641          {below}
642        </Box>
643      )
644    }
645
646    const accent = list.phase === 'done' ? GREEN : PINK
647    const percent = overallPercent(list.tasks)
648    const steps = stepLabel(list.tasks)
649    const barWidth = Math.max(4, inner - steps.length - 1 - 5)
650    const filled = Math.round((barWidth * percent) / 100)
651    const overall = (
652      <Text wrap="truncate-end">
653        <Text color={MUTED}>{steps + ' '}</Text>
654        <Text backgroundColor={accent}>{' '.repeat(filled)}</Text>
655        <Text backgroundColor={LINE}>{' '.repeat(barWidth - filled)}</Text>
656        <Text bold color={accent}>
657          {` ${percent}%`.padStart(5)}
658        </Text>
659      </Text>
660    )
661
662    const cells = (lit: boolean[], color: string) =>
663      // Thin bars, so the meters of neighbouring rows don't merge into one block.
664      lit.map(isLit => <Text color={isLit ? color : LINE}>━</Text>)
665
666    const nameWidth = Math.max(6, Math.min(44, inner - 2 - 2 - 10 - 2 - LABEL_CELLS))
667    const firstUpcoming = list.tasks.findIndex(t => t.status === 'upcoming')
668    const row = (t: CleanViewTask, i: number) => {
669      const name = fit(t.name, nameWidth)
670      if (t.status === 'done') {
671        return (
672          <Text wrap="truncate-end">
673            <Text color={GREEN}>✓ </Text>
674            <Text color={MUTED}>{name}</Text>
675            {'  '}
676            {cells(meterCells(100), GREEN)}
677            <Text color={MUTED}>{'  Done'}</Text>
678          </Text>
679        )
680      }
681      if (t.status === 'active') {
682        const isWaiting = list.phase === 'needs-you'
683        const lit = t.hasReported ? meterCells(t.percent) : meterCells(0, frame)
684        return (
685          <Text wrap="truncate-end">
686            <Text color={isWaiting ? AMBER : PINK}>{isWaiting ? '‖ ' : '● '}</Text>
687            <Text bold>{name}</Text>
688            {'  '}
689            {cells(lit, t.hasReported ? PINK : SWEEP)}
690            <Text bold color={PINK}>
691              {'  ' + (t.hasReported ? `${t.percent}%` : 'Working')}
692            </Text>
693          </Text>
694        )
695      }
696      return (
697        <Text wrap="truncate-end">
698          <Text color={MUTED}>{'○ ' + name}</Text>
699          {'  '}
700          {cells(meterCells(0), LINE)}
701          <Text color={MUTED}>{'  ' + (i === firstUpcoming ? 'Next' : 'Up next')}</Text>
702        </Text>
703      )
704    }
705
706    return (
707      <Box flexDirection="column">
708        <Box flexDirection="column" borderStyle="round" borderColor={FRAME[list.phase]} paddingX={1} width={width}>
709          {header}
710          {overall}
711          {list.tasks.map(row)}
712        </Box>
713        {below}
714      </Box>
715    )
716  })
717}
718
hooks/checklist.ts 270 lines
1import type { CleanViewChecklist, CleanViewTask, CleanViewTaskStatus } from '../types'
2
3export const MAX_NAME = 40
4export const FALLBACK_NAME = 'Working on it'
5export const FALLBACK_TITLE = 'Working on your request'
6export const METER_CELLS = 10
7
8const CODE_FILE =
9  /\b[\w.-]+\.(?:tsx?|jsx?|mjs|cjs|mts|cts|py|rb|go|rs|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|ps1|bat|cmd|json|jsonc|ya?ml|toml|ini|cfg|conf|env|lock|md|mdx|html?|css|scss|sass|less|sql|xml|vue|svelte|astro|graphql|proto|tf|gradle|dart|lua|r|pl|ex|exs|zig)\b/gi
10
11/**
12 * The one cleaner every step name and job title passes through: no code, no
13 * paths, no file names, one line, a capital first letter, at most 40 characters.
14 */
15export function cleanName(raw: unknown, fallback: string = FALLBACK_NAME): string {
16  let text = typeof raw === 'string' ? raw : ''
17  text = text.replace(/`[^`]*`/g, ' ').replace(/`/g, ' ')
18  text = text
19    .split(/\s+/)
20    .filter(word => !word.includes('/') && !word.includes('\\'))
21    .join(' ')
22  text = text.replace(CODE_FILE, ' ')
23  text = text.replace(/\s+/g, ' ').trim()
24  text = text.replace(/^[\s,.;:–—-]+/, '').trim()
25  if (text === '') return fallback
26  text = text.charAt(0).toUpperCase() + text.slice(1)
27  if (text.length <= MAX_NAME) return text
28  const head = text.slice(0, MAX_NAME - 1)
29  const space = head.lastIndexOf(' ')
30  const cut = (space > 0 ? head.slice(0, space) : head).replace(/[\s,.;:–—-]+$/, '')
31  return `${cut}…`
32}
33
34/** Fits a name into `width` cells, ending in "…" when it is cut. */
35export function fit(text: string, width: number): string {
36  if (width <= 0) return ''
37  if (text.length <= width) return text.padEnd(width)
38  if (width === 1) return '…'
39  return `${text.slice(0, width - 1)}…`
40}
41
42/** The 10-cell meter: filled to `percent`, or a moving sweep when `sweep` is a frame number. */
43export function meter(percent: number, sweep?: number): string {
44  if (sweep !== undefined) {
45    const at = sweep % METER_CELLS
46    let cells = ''
47    for (let i = 0; i < METER_CELLS; i++) {
48      const distance = (i - at + METER_CELLS) % METER_CELLS
49      cells += distance < 3 ? '▓' : '░'
50    }
51    return cells
52  }
53  const filled = Math.max(0, Math.min(METER_CELLS, Math.round(clampPercent(percent) / 10)))
54  return '█'.repeat(filled) + '░'.repeat(METER_CELLS - filled)
55}
56
57export function clampPercent(value: unknown): number {
58  const n = typeof value === 'number' ? value : Number(value)
59  if (!Number.isFinite(n)) return 0
60  return Math.round(Math.max(0, Math.min(100, n)))
61}
62
63/** "45s", "1m 12s", "1h 3m". */
64export function formatDuration(ms: number): string {
65  const total = Math.max(0, Math.floor(ms / 1000))
66  const hours = Math.floor(total / 3600)
67  const minutes = Math.floor((total % 3600) / 60)
68  const seconds = total % 60
69  if (hours > 0) return `${hours}h ${minutes}m`
70  if (minutes > 0) return `${minutes}m ${seconds}s`
71  return `${seconds}s`
72}
73
74function task(id: string, name: string, status: CleanViewTaskStatus): CleanViewTask {
75  return { id, name, status, percent: status === 'done' ? 100 : 0, hasReported: false }
76}
77
78const sameName = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
79
80/** A new job with placeholder steps, shown until the real plan arrives. */
81export function newJob(now: number, title: string = FALLBACK_TITLE): CleanViewChecklist {
82  return {
83    title,
84    phase: 'working',
85    tasks: [
86      task('placeholder-1', 'Understand your request', 'active'),
87      task('placeholder-2', 'Plan the steps', 'upcoming'),
88    ],
89    needsYouReason: null,
90    stuckReason: null,
91    startedAt: now,
92    finishedAt: null,
93    isCollapsed: false,
94    isPlanned: false,
95  }
96}
97
98function base(list: CleanViewChecklist | null, now: number): CleanViewChecklist {
99  return list ?? newJob(now)
100}
101
102/** Keeps one active step at most, and starts the first upcoming one when none is. */
103function settle(tasks: CleanViewTask[]): CleanViewTask[] {
104  let seen = false
105  const one = tasks.map(t => {
106    if (t.status !== 'active') return t
107    if (seen) return { ...t, status: 'upcoming' as const }
108    seen = true
109    return t
110  })
111  if (seen) return one
112  const next = one.findIndex(t => t.status === 'upcoming')
113  if (next === -1) return one
114  return one.map((t, i) => (i === next ? { ...t, status: 'active' as const } : t))
115}
116
117/** plan_steps: every step laid out, the first one started. */
118export function planSteps(list: CleanViewChecklist | null, names: readonly string[], now: number): CleanViewChecklist {
119  const job = base(list, now)
120  const tasks = names.map((name, i) => task(`step-${i + 1}`, name, i === 0 ? 'active' : 'upcoming'))
121  return { ...job, tasks, isPlanned: true, phase: 'working', needsYouReason: null, stuckReason: null, isCollapsed: false, finishedAt: null }
122}
123
124/**
125 * report_progress: steps before the reported one are checked off; at 100 the
126 * step is checked off and the next starts; a name not in the plan is added
127 * where the unfinished work begins.
128 */
129export function reportProgress(list: CleanViewChecklist | null, name: string, percent: number, now: number): CleanViewChecklist {
130  const job = base(list, now)
131  let tasks = [...job.tasks]
132  let at = tasks.findIndex(t => sameName(t.name, name))
133  if (at === -1) {
134    const firstOpen = tasks.findIndex(t => t.status !== 'done')
135    at = firstOpen === -1 ? tasks.length : firstOpen
136    tasks.splice(at, 0, task(`step-${now}-${tasks.length + 1}`, name, 'active'))
137  }
138  const isFinished = percent >= 100
139  tasks = tasks.map((t, i) => {
140    if (i < at) return t.status === 'done' ? t : { ...t, status: 'done' as const, percent: 100 }
141    if (i === at) return { ...t, status: isFinished ? ('done' as const) : ('active' as const), percent, hasReported: true }
142    return t.status === 'active' ? { ...t, status: 'upcoming' as const } : t
143  })
144  return { ...job, tasks: settle(tasks) }
145}
146
147type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed' }
148
149const STATUS_OF: Record<Todo['status'], CleanViewTaskStatus> = {
150  completed: 'done',
151  in_progress: 'active',
152  pending: 'upcoming',
153}
154
155/** TodoWrite: the to-do list becomes the plan, percents kept for steps it already had. */
156export function fromTodos(list: CleanViewChecklist | null, todos: readonly Todo[], now: number): CleanViewChecklist {
157  const job = base(list, now)
158  const kept = job.isPlanned ? job.tasks : []
159  const tasks = todos.map((todo, i) => {
160    const name = cleanName(todo.content)
161    const status = STATUS_OF[todo.status] ?? 'upcoming'
162    const before = kept.find(t => sameName(t.name, name))
163    return {
164      id: `todo-${i + 1}`,
165      name,
166      status,
167      percent: status === 'done' ? 100 : (before?.percent ?? 0),
168      hasReported: before?.hasReported ?? false,
169    }
170  })
171  return { ...job, tasks: settle(tasks), isPlanned: tasks.length > 0 || job.isPlanned }
172}
173
174/** TaskCreate: one more step; the first replaces the placeholders. */
175export function addTask(list: CleanViewChecklist | null, id: string, name: string, now: number): CleanViewChecklist {
176  const job = base(list, now)
177  const tasks = job.isPlanned ? [...job.tasks] : []
178  tasks.push(task(id, name, 'upcoming'))
179  return { ...job, tasks: settle(tasks), isPlanned: true }
180}
181
182type TaskChange = {
183  status?: 'pending' | 'in_progress' | 'completed' | 'deleted'
184  name?: string
185}
186
187/** TaskUpdate: a step started, finished, renamed or removed. */
188export function updateTask(list: CleanViewChecklist | null, id: string, change: TaskChange, now: number): CleanViewChecklist {
189  const job = base(list, now)
190  if (change.status === 'deleted') {
191    return { ...job, tasks: settle(job.tasks.filter(t => t.id !== id)) }
192  }
193  let tasks = job.tasks.map(t => {
194    if (t.id !== id) return t
195    const renamed = change.name ? { ...t, name: change.name } : t
196    if (change.status === 'completed') return { ...renamed, status: 'done' as const, percent: 100 }
197    if (change.status === 'in_progress') return { ...renamed, status: 'active' as const }
198    if (change.status === 'pending') return { ...renamed, status: 'upcoming' as const }
199    return renamed
200  })
201  if (change.status === 'in_progress') {
202    tasks = tasks.map(t => (t.id !== id && t.status === 'active' ? { ...t, status: 'upcoming' as const } : t))
203  }
204  return { ...job, tasks: settle(tasks) }
205}
206
207/** Every step checked off. */
208export function finishAll(tasks: readonly CleanViewTask[]): CleanViewTask[] {
209  return tasks.map(t => ({ ...t, status: 'done' as const, percent: 100 }))
210}
211
212/** One calm sentence for an API error, by the kind Claude Code classifies it as. */
213export function errorSentence(kind: string | null, details: string | null): string {
214  const text = (details ?? '').toLowerCase()
215  if (/too long|context|too many tokens/.test(text)) return 'type /compact and try again'
216  if (/econn|enotfound|etimedout|network|fetch failed|socket|connection/.test(text)) return 'the internet connection dropped'
217  switch (kind) {
218    case 'rate_limit':
219    case 'billing_error':
220      return 'you hit your usage limit, try again a little later'
221    case 'overloaded':
222    case 'server_error':
223      return "Claude's servers are busy, try again in a minute"
224    case 'authentication_failed':
225    case 'oauth_org_not_allowed':
226    case 'cloud_credential_error':
227      return 'type /login'
228    case 'network':
229      return 'the internet connection dropped'
230    default:
231      return 'something went wrong, try again in a moment'
232  }
233}
234
235/** Which of the 10 meter cells are lit: filled to `percent`, or a 3-cell sweep at frame `sweep`. */
236export function meterCells(percent: number, sweep?: number): boolean[] {
237  const cells: boolean[] = []
238  if (sweep !== undefined) {
239    const at = sweep % METER_CELLS
240    for (let i = 0; i < METER_CELLS; i++) cells.push((i - at + METER_CELLS) % METER_CELLS < 3)
241    return cells
242  }
243  const filled = Math.round(clampPercent(percent) / 10)
244  for (let i = 0; i < METER_CELLS; i++) cells.push(i < filled)
245  return cells
246}
247
248/** The whole job's progress: each step counts the same, the current one by its percent. */
249export function overallPercent(tasks: readonly CleanViewTask[]): number {
250  if (tasks.length === 0) return 0
251  const sum = tasks.reduce((n, t) => n + (t.status === 'done' ? 100 : t.status === 'active' ? t.percent : 0), 0)
252  return Math.round(sum / tasks.length)
253}
254
255/** "Step 2 of 4": the current step, or the last one finished. */
256export function stepLabel(tasks: readonly CleanViewTask[]): string {
257  const active = tasks.findIndex(t => t.status === 'active')
258  const at = active === -1 ? tasks.filter(t => t.status === 'done').length : active + 1
259  return `Step ${Math.max(1, at)} of ${Math.max(1, tasks.length)}`
260}
261
262const TITLE_STOPS = ['#F2994A', '#F06292', '#C86DD7', '#8E7CF0', '#6FA8FF'] as const
263
264/** The colour of word `i` of `n` in the title's orange-to-blue fade. */
265export function titleColor(i: number, n: number): string {
266  if (n <= 1) return TITLE_STOPS[0]
267  const at = Math.round((i / (n - 1)) * (TITLE_STOPS.length - 1))
268  return TITLE_STOPS[Math.min(TITLE_STOPS.length - 1, Math.max(0, at))] ?? TITLE_STOPS[0]
269}
270
types/index.d.ts 42 lines
1// Clean View's shared state, readable by other mods under the 'clean-view' key.
2
3export type CleanViewTaskStatus = 'done' | 'active' | 'upcoming'
4
5export type CleanViewTask = {
6  id: string
7  name: string
8  status: CleanViewTaskStatus
9  /** 0 to 100. */
10  percent: number
11  /** True once Claude has reported a percent for this step. */
12  hasReported: boolean
13}
14
15export type CleanViewPhase = 'working' | 'needs-you' | 'stuck' | 'stopped' | 'done'
16
17export type CleanViewChecklist = {
18  /** The job's name in 2 to 6 plain words. */
19  title: string
20  phase: CleanViewPhase
21  tasks: CleanViewTask[]
22  needsYouReason: string | null
23  stuckReason: string | null
24  /** Milliseconds since the epoch. */
25  startedAt: number
26  finishedAt: number | null
27  /** True once a finished job has shrunk to one line. */
28  isCollapsed: boolean
29  /** True once a real plan replaced the placeholder steps. */
30  isPlanned: boolean
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    'clean-view': {
36      cleanViewEnabled: boolean
37      checklist: CleanViewChecklist | null
38      tick: number
39    }
40  }
41}
42