SLOPSHOPPER

clean-view

A calm view for non-technical people: hides tool rows and shows one plain-English checklist above the prompt.

newbandrowsguardcommandtoast
v0.1.1no licenseupdated 2026-10-09b00y0h/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: call mcp__clean-view__plan_steps first to lay out the steps of this job ● 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 ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ ○ Clean View: OFF ] ⟨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, the tool calls, file changes and command output are hidden. One simple checklist sits above the prompt instead, so you can always see the plan, what's 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

The header also tells you when Claude needs you (for example, to approve a step), when it's stuck, when you stopped it, and when it's all done.

Install

Type this at the Claude Code prompt in a terminal:

/plugin install clean-view --marketplace b00y0h/clean-view

Answer y to add the marketplace, then press Enter to install it for your user. Clean View starts on right away.

Turn it on and off

  • Click the Clean View button above the prompt, or
  • type /simple on, /simple off, or just /simple to flip it.

Your choice is remembered after a restart. When it's off, everything Claude Code normally shows comes back.

How it works

  • Claude lays out the steps of every job first, in plain English, and reports progress as it goes.
  • Step names never contain file names, paths, commands or code.
  • Permission prompts and questions switch the header to Needs you.
  • Errors become one plain sentence, such as "Claude's servers are busy, try again in a minute".
  • Each new job gets a short name from Claude Haiku. That's one tiny model call per request, and it counts toward your usage.

Develop

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 9 lines
1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5// One entry point, so more mods can be registered beside Clean View later.
6export const register: Register = on => {
7  registerCleanView(on)
8}
9
hooks/clean-view.tsx 866 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderElement, Timer } from 'claude-code'
3
4import type { CleanViewChecklist, CleanViewTask } from '../types'
5
6type $ = EngineInterface
7
8const PLUGIN = 'clean-view'
9const PLAN_TOOL = `mcp__${PLUGIN}__plan_steps`
10const PROGRESS_TOOL = `mcp__${PLUGIN}__report_progress`
11const ALWAYS_ALLOWED = new Set([
12  'ToolSearch',
13  'TodoWrite',
14  'TaskCreate',
15  'TaskUpdate',
16  'AskUserQuestion',
17  PLAN_TOOL,
18  PROGRESS_TOOL,
19])
20
21const MAX_NAME = 40
22const METER = 10
23const TICK_MS = 250
24const COLLAPSE_MS = 5000
25const FAILURES_BEFORE_STUCK = 3
26
27const enabledAtom = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
28const checklistAtom = atom({ plugin: 'clean-view', key: 'checklist' } as const, null)
29const tickAtom = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
30
31const REASON = {
32  permission: 'Claude needs your OK to continue',
33  question: 'Claude has a question for you',
34  waiting: 'Claude is waiting for your reply',
35  saidNo: 'you said no to a step, so Claude paused',
36  failing: 'a step keeps failing, Claude is trying another way',
37  refusal: "Claude couldn't help with that request",
38  rateLimit: 'you hit your usage limit, try again a little later',
39  overloaded: "Claude's servers are busy, try again in a minute",
40  tooLong: 'the conversation is too long, type /compact and try again',
41  network: 'the internet connection dropped, try again',
42  auth: "Claude isn't signed in, type /login",
43  unknownError: 'something went wrong, try again',
44} as const
45
46const CODE_EXTENSIONS =
47  'tsx?|jsx?|mjs|cjs|mts|cts|py|rb|go|rs|java|kt|swift|c|cc|cpp|h|hpp|cs|php|css|scss|sass|less|html?|json|ya?ml|toml|mdx?|sh|zsh|bash|sql|xml|vue|svelte|lock|env|ini|cfg|conf'
48const FILE_NAME = new RegExp(`\\S*\\.(?:${CODE_EXTENSIONS})(?![A-Za-z0-9])\\S*`, 'gi')
49
50/**
51 * The one cleaner every step and job name goes through: no code, no paths,
52 * no file names, one line, capitalised, at most 40 characters.
53 */
54export function cleanName(raw: unknown, fallback = 'Working on it'): string {
55  let text = typeof raw === 'string' ? raw : ''
56  text = text
57    .replace(/`[^`]*`/g, ' ')
58    .replace(/`/g, ' ')
59    .replace(/\S*[/\\]\S*/g, ' ')
60    .replace(FILE_NAME, ' ')
61    .replace(/\(\s*\)|\[\s*\]/g, ' ')
62    .replace(/\s+/g, ' ')
63    .trim()
64    .replace(/^[\s,;:.)\]–—-]+/, '')
65    .replace(/[\s,;:([–—-]+$/, '')
66    .trim()
67
68  if (text === '') {
69    return fallback
70  }
71
72  text = text.charAt(0).toUpperCase() + text.slice(1)
73
74  if (text.length <= MAX_NAME) {
75    return text
76  }
77
78  const head = text.slice(0, MAX_NAME - 1)
79  const lastSpace = head.lastIndexOf(' ')
80  const cut = (lastSpace > 0 ? head.slice(0, lastSpace) : head).replace(/[\s,;:.–—-]+$/, '')
81
82  return `${cut}…`
83}
84
85/** A job title from Haiku: the cleaner, then 2 to 6 words, or nothing. */
86function cleanTitle(raw: string): string | null {
87  const firstLine = raw.split('\n').find(line => line.trim() !== '') ?? ''
88  const words = cleanName(firstLine.replace(/["'“”‘’*_#.!?]/g, ''), '')
89    .split(' ')
90    .filter(Boolean)
91
92  if (words.length < 2) {
93    return null
94  }
95
96  return cleanName(words.slice(0, 6).join(' '))
97}
98
99function formatDuration(ms: number): string {
100  const seconds = Math.max(0, Math.floor(ms / 1000))
101  const h = Math.floor(seconds / 3600)
102  const m = Math.floor((seconds % 3600) / 60)
103  const s = seconds % 60
104
105  if (h > 0) {
106    return `${h}h ${m}m`
107  }
108
109  return m > 0 ? `${m}m ${s}s` : `${s}s`
110}
111
112function clampPercent(value: unknown): number {
113  const n = typeof value === 'number' ? value : Number(value)
114
115  return Number.isFinite(n) ? Math.min(100, Math.max(0, Math.round(n))) : 0
116}
117
118function sameName(a: string, b: string): boolean {
119  return a.toLowerCase() === b.toLowerCase()
120}
121
122// ---------------------------------------------------------------------------
123// Checklist transitions (pure)
124// ---------------------------------------------------------------------------
125
126function newChecklist(title: string, startedAt: number): CleanViewChecklist {
127  return {
128    title,
129    phase: 'working',
130    tasks: [
131      { id: 'placeholder-1', name: 'Understand your request', status: 'active', percent: 0, hasReported: false },
132      { id: 'placeholder-2', name: 'Plan the steps', status: 'upcoming', percent: 0, hasReported: false },
133    ],
134    needsYouReason: null,
135    stuckReason: null,
136    startedAt,
137    finishedAt: null,
138    isCollapsed: false,
139    isPlanned: false,
140  }
141}
142
143/** Makes the first upcoming step active when nothing is active and work remains. */
144function ensureActive(tasks: CleanViewTask[]): CleanViewTask[] {
145  if (tasks.some(t => t.status === 'active')) {
146    return tasks
147  }
148
149  const next = tasks.findIndex(t => t.status === 'upcoming')
150
151  return next === -1
152    ? tasks
153    : tasks.map((t, i) => (i === next ? { ...t, status: 'active', percent: 0, hasReported: false } : t))
154}
155
156function backToWork(c: CleanViewChecklist): CleanViewChecklist {
157  return {
158    ...c,
159    phase: 'working',
160    needsYouReason: null,
161    stuckReason: null,
162    finishedAt: null,
163    isCollapsed: false,
164  }
165}
166
167function withPlan(c: CleanViewChecklist, names: string[]): CleanViewChecklist {
168  const tasks: CleanViewTask[] = names.map((name, i) => ({
169    id: `step-${i + 1}`,
170    name,
171    status: i === 0 ? 'active' : 'upcoming',
172    percent: 0,
173    hasReported: false,
174  }))
175
176  return { ...backToWork(c), tasks, isPlanned: true }
177}
178
179function withProgress(c: CleanViewChecklist, name: string, percent: number): CleanViewChecklist {
180  let tasks = [...c.tasks]
181  let index = tasks.findIndex(t => sameName(t.name, name))
182
183  if (index === -1) {
184    const step: CleanViewTask = {
185      id: `step-${tasks.length + 1}-${tasks.filter(t => t.id.startsWith('step-')).length}`,
186      name,
187      status: 'active',
188      percent: 0,
189      hasReported: false,
190    }
191    const activeAt = tasks.findIndex(t => t.status === 'active')
192    index = activeAt === -1 ? tasks.length : activeAt
193    tasks.splice(index, 0, step)
194  }
195
196  tasks = tasks.map((t, i) => {
197    if (i < index) {
198      return { ...t, status: 'done', percent: 100 }
199    }
200
201    if (i === index) {
202      return percent >= 100
203        ? { ...t, status: 'done', percent: 100, hasReported: true }
204        : { ...t, status: 'active', percent, hasReported: true }
205    }
206
207    return t.status === 'active' ? { ...t, status: 'upcoming', percent: 0, hasReported: false } : t
208  })
209
210  return { ...backToWork(c), tasks: ensureActive(tasks), isPlanned: true }
211}
212
213type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed' }
214
215function withTodos(c: CleanViewChecklist, todos: readonly Todo[]): CleanViewChecklist {
216  if (todos.length === 0) {
217    return c
218  }
219
220  const tasks: CleanViewTask[] = todos.map((todo, i) => {
221    const name = cleanName(todo.content)
222    const before = c.tasks.find(t => sameName(t.name, name))
223    const status = todo.status === 'completed' ? 'done' : todo.status === 'in_progress' ? 'active' : 'upcoming'
224    const keep = status === 'active' && before?.status === 'active' && before.hasReported
225
226    return {
227      id: `todo-${i + 1}`,
228      name,
229      status,
230      percent: status === 'done' ? 100 : keep ? before.percent : 0,
231      hasReported: status === 'done' || Boolean(keep),
232    }
233  })
234
235  return { ...backToWork(c), tasks: ensureActive(tasks), isPlanned: true }
236}
237
238function withTaskCreated(c: CleanViewChecklist, id: string, subject: string): CleanViewChecklist {
239  const base = c.isPlanned ? c.tasks : []
240  const task: CleanViewTask = { id: `task-${id}`, name: cleanName(subject), status: 'upcoming', percent: 0, hasReported: false }
241
242  return { ...backToWork(c), tasks: ensureActive([...base, task]), isPlanned: true }
243}
244
245type TaskUpdate = { taskId: string; subject?: string; status?: 'pending' | 'in_progress' | 'completed' | 'deleted' }
246
247function withTaskUpdated(c: CleanViewChecklist, u: TaskUpdate): CleanViewChecklist {
248  const id = `task-${u.taskId}`
249
250  if (!c.tasks.some(t => t.id === id)) {
251    return c
252  }
253
254  let tasks = c.tasks
255    .filter(t => !(t.id === id && u.status === 'deleted'))
256    .map(t => {
257      if (t.id === id) {
258        const name = u.subject === undefined ? t.name : cleanName(u.subject)
259
260        if (u.status === 'completed') {
261          return { ...t, name, status: 'done' as const, percent: 100 }
262        }
263
264        if (u.status === 'in_progress') {
265          return { ...t, name, status: 'active' as const }
266        }
267
268        if (u.status === 'pending') {
269          return { ...t, name, status: 'upcoming' as const, percent: 0, hasReported: false }
270        }
271
272        return { ...t, name }
273      }
274
275      return u.status === 'in_progress' && t.status === 'active'
276        ? { ...t, status: 'upcoming' as const, percent: 0, hasReported: false }
277        : t
278    })
279  tasks = ensureActive(tasks)
280
281  return { ...backToWork(c), tasks }
282}
283
284function apiErrorReason(kind: string, details: string): string {
285  const said = details.toLowerCase()
286
287  if (/prompt is too long|too long|context (window|length)|max.*tokens/.test(said)) {
288    return REASON.tooLong
289  }
290
291  if (/econn|enotfound|network|socket|fetch failed|connection|timed? ?out|offline/.test(said)) {
292    return REASON.network
293  }
294
295  switch (kind) {
296    case 'rate_limit':
297    case 'billing_error':
298      return REASON.rateLimit
299    case 'overloaded':
300    case 'server_error':
301      return REASON.overloaded
302    case 'authentication_failed':
303    case 'oauth_org_not_allowed':
304    case 'cloud_credential_error':
305      return REASON.auth
306    case 'invalid_request':
307      return REASON.tooLong
308    default:
309      return REASON.unknownError
310  }
311}
312
313const USER_SAID_NO = /doesn.t want to proceed|user rejected|was rejected|user declined|denied by the user/i
314
315const SYSTEM_PROMPT = [
316  '# Clean View',
317  'The person using this session is not technical. Instead of your tool calls they see one simple checklist of the steps you take, so write it for them.',
318  `- For every request, even a quick question, call \`${PLAN_TOOL}\` first with every step of the job, 2 to 8 short names in order. If it is deferred, load it with ToolSearch first (query "select:${PLAN_TOOL},${PROGRESS_TOOL}"). Other tools are refused until a plan exists.`,
319  '- 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".',
320  '- Never put file paths, file names, commands, code or tool names in a step name.',
321  `- Call \`${PROGRESS_TOOL}\` with the step's name and a percent as real progress happens, and with 100 the moment a step finishes.`,
322  '- If this session has TodoWrite or TaskCreate, you may use your to-do list as the plan instead; the same naming rules apply to every item.',
323].join('\n')
324
325// ---------------------------------------------------------------------------
326// Hooks
327// ---------------------------------------------------------------------------
328
329// Module state: lost on a hot reload, which is fine for each of these.
330let ticker: Timer | undefined
331let failures = 0
332let lastApiError: string | null = null
333
334async function isEnabled($: $): Promise<boolean> {
335  return read($, enabledAtom)
336}
337
338function syncTicker($: $, c: CleanViewChecklist | null, enabled: boolean): void {
339  const isLive = enabled && c !== null && (c.phase === 'working' || c.phase === 'needs-you')
340
341  if (isLive && ticker === undefined) {
342    ticker = $.clock.every(TICK_MS, () => {
343      void update($, tickAtom, n => (n + 1) % 1_000_000)
344    })
345  } else if (!isLive && ticker !== undefined) {
346    ticker.cancel()
347    ticker = undefined
348  }
349}
350
351async function change(
352  $: $,
353  fn: (c: CleanViewChecklist) => CleanViewChecklist | null,
354): Promise<CleanViewChecklist | null> {
355  let after: CleanViewChecklist | null = null
356  await update($, checklistAtom, c => {
357    after = c === null ? null : fn(c)
358
359    return after
360  })
361  syncTicker($, after, await isEnabled($))
362
363  return after
364}
365
366/** A checklist to write into, even when the plan arrives before any prompt was seen. */
367async function ensureJob($: $): Promise<void> {
368  if ((await read($, checklistAtom)) === null) {
369    const job = newChecklist('Working on it', await $.clock.now())
370    await update($, checklistAtom, () => job)
371  }
372}
373
374async function startJob($: $, prompt: string): Promise<void> {
375  const startedAt = await $.clock.now()
376  const job = newChecklist(cleanName(prompt), startedAt)
377  failures = 0
378  lastApiError = null
379  await update($, checklistAtom, () => job)
380  syncTicker($, job, true)
381
382  // Name the job in the background so the turn never waits on it.
383  $.clock.after(0, () => {
384    void nameJob($, prompt, startedAt)
385  })
386}
387
388async function nameJob($: $, prompt: string, startedAt: number): Promise<void> {
389  const reply = await $.model.complete({
390    model: 'haiku',
391    effort: 'low',
392    maxTokens: 30,
393    timeoutMs: 20_000,
394    system:
395      'You name jobs for a non-technical person. Reply with the name only: 2 to 6 plain words, starting with a verb, no punctuation, no file names, no code.',
396    prompt: `Name this job:\n\n${prompt.slice(0, 2000)}`,
397  })
398
399  if (!reply.isAnswered) {
400    return
401  }
402
403  const title = cleanTitle(reply.text)
404
405  if (title === null) {
406    return
407  }
408
409  // A newer job has started: this name belongs to the old one.
410  await update($, checklistAtom, c => (c !== null && c.startedAt === startedAt ? { ...c, title } : c))
411}
412
413async function setEnabled($: $, value: boolean): Promise<void> {
414  await update($, enabledAtom, () => value)
415  await $.store.set('cleanViewEnabled', value)
416  syncTicker($, await read($, checklistAtom), value)
417  $.ui.toast(value ? 'Clean View is on: the technical details are hidden.' : 'Clean View is off: showing everything.')
418}
419
420async function finishJob($: $): Promise<void> {
421  const finishedAt = await $.clock.now()
422  const done = await change($, c => ({
423    ...c,
424    phase: 'done',
425    needsYouReason: null,
426    stuckReason: null,
427    finishedAt,
428    isCollapsed: false,
429    tasks: c.tasks.map(t => ({ ...t, status: 'done', percent: 100 })),
430  }))
431
432  if (done === null) {
433    return
434  }
435
436  $.clock.after(COLLAPSE_MS, () => {
437    void update($, checklistAtom, c =>
438      c !== null && c.startedAt === done.startedAt && c.phase === 'done' ? { ...c, isCollapsed: true } : c,
439    )
440  })
441}
442
443export function registerCleanView(on: On): void {
444  on('session.start', async ($, e, next) => {
445    await Promise.all([
446      $.tool.register({
447        name: 'plan_steps',
448        description:
449          'Lay out every step of the job up front, before any other tool: 2 to 8 short plain-English step names in order, each under 40 characters and starting with a verb. No file paths, file names, commands, code or tool names. The first step starts right away.',
450        inputSchema: {
451          type: 'object',
452          properties: {
453            steps: {
454              type: 'array',
455              items: { type: 'string' },
456              minItems: 1,
457              maxItems: 8,
458              description: 'The step names, in order.',
459            },
460          },
461          required: ['steps'],
462        },
463        isDeferred: false,
464      }),
465      $.tool.register({
466        name: 'report_progress',
467        description:
468          'Report real progress on the current step. Use the step name exactly as planned; percent 0 to 100, and 100 the moment the step is finished, which starts the next one. A new plain-English name adds a step.',
469        inputSchema: {
470          type: 'object',
471          properties: {
472            task: { type: 'string', description: 'The step name, as planned.' },
473            percent: { type: 'number', minimum: 0, maximum: 100 },
474          },
475          required: ['task', 'percent'],
476        },
477        isDeferred: false,
478      }),
479      $.command.register({
480        name: 'simple',
481        description: 'Turn Clean View on or off (no argument flips it)',
482        argumentHint: '[on|off]',
483        immediate: true,
484      }),
485    ])
486
487    const saved = await $.store.get('cleanViewEnabled')
488    await update($, enabledAtom, () => saved !== false)
489    syncTicker($, await read($, checklistAtom), saved !== false)
490
491    return next(e)
492  })
493
494  on('command.run', { command: 'simple' }, async ($, e) => {
495    const arg = e.args.trim().toLowerCase()
496
497    if (arg !== '' && arg !== 'on' && arg !== 'off') {
498      return { text: 'Use /simple on, /simple off, or /simple on its own to switch.' }
499    }
500
501    const value = arg === '' ? !(await isEnabled($)) : arg === 'on'
502    await setEnabled($, value)
503
504    return { text: value ? 'Clean View is on.' : 'Clean View is off.' }
505  })
506
507  on('prompt.compose', async ($, e, next) => {
508    const composed = await next(e)
509
510    if (!(await isEnabled($))) {
511      return composed
512    }
513
514    return {
515      sections: [
516        ...composed.sections.filter(s => s.id !== `${PLUGIN}:plain-steps`),
517        { id: `${PLUGIN}:plain-steps`, text: SYSTEM_PROMPT, scope: 'session' as const },
518      ],
519    }
520  })
521
522  on('turn.start', async ($, e, next) => {
523    const text = e.text.trim()
524
525    if (text !== '' && !text.startsWith('/') && (await isEnabled($))) {
526      const c = await read($, checklistAtom)
527      const isRunning = c !== null && (c.phase === 'working' || c.phase === 'needs-you' || c.phase === 'stuck')
528
529      if (isRunning) {
530        // The person replied to a running job: carry on with it.
531        await change($, backToWork)
532      } else {
533        await startJob($, text)
534      }
535    }
536
537    return next(e)
538  })
539
540  on('tool.call', async ($, e, next) => {
541    const tool = String(e.tool)
542    const enabled = await isEnabled($)
543    const isMain = e.agentId === undefined
544
545    if (tool === PLAN_TOOL) {
546      const raw: unknown = (e as { steps?: unknown }).steps
547      const names = (Array.isArray(raw) ? raw : [])
548        .map(step => cleanName(step, ''))
549        .filter(name => name !== '')
550        .slice(0, 8)
551
552      if (names.length === 0) {
553        return { deny: 'plan_steps needs 2 to 8 plain-English step names in `steps`.' }
554      }
555
556      await ensureJob($)
557      await change($, c => withPlan(c, names))
558
559      return { result: `Planned ${names.length} steps. The first one has started.` }
560    }
561
562    if (tool === PROGRESS_TOOL) {
563      const input = e as { task?: unknown; percent?: unknown }
564      const percent = clampPercent(input.percent)
565      const name = cleanName(input.task)
566
567      await ensureJob($)
568      await change($, c => withProgress(c, name, percent))
569
570      return { result: `Progress noted: ${percent}%.` }
571    }
572
573    if (enabled && isMain && !ALWAYS_ALLOWED.has(tool)) {
574      const c = await read($, checklistAtom)
575
576      if (c === null || !c.isPlanned) {
577        return {
578          deny: `Clean View: call ${PLAN_TOOL} first to lay out the steps of this job in plain English (load it with ToolSearch if it is deferred), then try again.`,
579        }
580      }
581    }
582
583    if (isMain && tool === 'AskUserQuestion') {
584      await change($, c => ({ ...c, phase: 'needs-you', needsYouReason: REASON.question }))
585    } else if (isMain) {
586      await change($, c => (c.phase === 'needs-you' ? { ...c, phase: 'working', needsYouReason: null } : c))
587    }
588
589    const ran = await next(e)
590
591    if (!isMain) {
592      return ran
593    }
594
595    if (ran.deny !== undefined || ran.isError === true) {
596      const said = `${ran.deny ?? ''} ${ran.text ?? ''}`
597
598      if (USER_SAID_NO.test(said)) {
599        failures = 0
600        await change($, c => ({ ...c, phase: 'stuck', stuckReason: REASON.saidNo, needsYouReason: null }))
601      } else {
602        failures += 1
603
604        if (failures >= FAILURES_BEFORE_STUCK) {
605          await change($, c => ({ ...c, phase: 'stuck', stuckReason: REASON.failing, needsYouReason: null }))
606        }
607      }
608
609      return ran
610    }
611
612    failures = 0
613
614    if (tool === 'TodoWrite' || tool === 'TaskCreate') {
615      await ensureJob($)
616    }
617
618    if (tool === 'TodoWrite') {
619      const todos = (e as { todos?: Todo[] }).todos ?? []
620      await change($, c => withTodos(c, todos))
621    } else if (tool === 'TaskCreate') {
622      const created = (ran.result as { task?: { id?: string; subject?: string } } | undefined)?.task
623      const subject = created?.subject ?? (e as { subject?: string }).subject ?? ''
624
625      if (created?.id !== undefined) {
626        await change($, c => withTaskCreated(c, String(created.id), subject))
627      }
628    } else if (tool === 'TaskUpdate') {
629      await change($, c => withTaskUpdated(c, e as unknown as TaskUpdate))
630    } else {
631      await change($, c => (c.phase === 'needs-you' || c.phase === 'stuck' ? backToWork(c) : c))
632    }
633
634    return ran
635  }).catch(($, e, next) => next(e))
636
637  on('classic.Notification', async ($, e, next) => {
638    const kind = String(e.notification_type ?? '')
639    const isPermission = kind === 'permission_prompt' || /permission/i.test(e.message ?? '')
640    const isQuestion = kind.startsWith('elicitation')
641
642    if (isPermission || isQuestion) {
643      await change($, c =>
644        c.phase === 'working' || c.phase === 'stuck' || c.phase === 'needs-you'
645          ? { ...c, phase: 'needs-you', needsYouReason: isPermission ? REASON.permission : REASON.question }
646          : c,
647      )
648    }
649
650    return next(e)
651  })
652
653  on('classic.StopFailure', async ($, e, next) => {
654    lastApiError = apiErrorReason(String(e.error), String(e.error_details ?? ''))
655    const reason = lastApiError
656    const finishedAt = await $.clock.now()
657    await change($, c => (c.phase === 'done' ? c : { ...c, phase: 'stuck', stuckReason: reason, needsYouReason: null, finishedAt }))
658
659    return next(e)
660  })
661
662  on('turn.complete', async ($, e, next) => {
663    if (e.agentId !== undefined || (await read($, checklistAtom)) === null) {
664      return next(e)
665    }
666
667    const finishedAt = await $.clock.now()
668
669    if (e.reason === 'aborted') {
670      await change($, c => ({ ...c, phase: 'stopped', needsYouReason: null, finishedAt }))
671    } else if (e.reason === 'refusal') {
672      await change($, c => ({ ...c, phase: 'stuck', stuckReason: REASON.refusal, needsYouReason: null, finishedAt }))
673    } else if (e.reason === 'error') {
674      const reason = lastApiError ?? REASON.unknownError
675      await change($, c => ({ ...c, phase: 'stuck', stuckReason: reason, needsYouReason: null, finishedAt }))
676    } else {
677      const c = await read($, checklistAtom)
678      const isUnfinished = c !== null && c.isPlanned && c.tasks.some(t => t.status !== 'done')
679
680      if (c?.phase === 'stuck') {
681        // Keep the reason (a "no" to a permission ends the turn as an answer).
682      } else if (isUnfinished) {
683        await change($, current => ({ ...current, phase: 'needs-you', needsYouReason: REASON.waiting }))
684      } else {
685        await finishJob($)
686      }
687    }
688
689    lastApiError = null
690
691    return next(e)
692  })
693
694  // Hide the technical rows while Clean View is on.
695  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
696    const { Box } = $.ui.resolve(e)
697
698    return (await isEnabled($)) ? <Box display="none" /> : next(e)
699  })
700
701  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
702    const { Box } = $.ui.resolve(e)
703
704    return (await isEnabled($)) ? <Box display="none" /> : next(e)
705  })
706
707  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
708    const { Box } = $.ui.resolve(e)
709
710    return (await isEnabled($)) ? <Box display="none" /> : next(e)
711  })
712
713  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) =>
714    (await isEnabled($)) ? next({ ...e, props: { ...e.props, hint: '' } }) : next(e),
715  )
716
717  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
718    if (e.props.hasSurvey) {
719      return next(e)
720    }
721
722    // What the app and later mods draw in this band: the branch, worktree and merge status
723    // indicators among them. A tree drawn here replaces theirs, so theirs always goes under ours.
724    const theirs = await next(e)
725    const ours = await (async (): Promise<RenderElement> => {
726    const enabled = await read($, enabledAtom)
727    const c = await read($, checklistAtom)
728    const tick = await read($, tickAtom)
729    const now = await $.clock.now()
730    const columns = Math.max(20, e.props.bodyColumns)
731    const { Box, Text, Button } = $.ui.resolve(e)
732
733    const toggle = (
734      <Button
735        key="clean-view-toggle"
736        label={enabled ? '● Clean View: ON' : '○ Clean View: OFF'}
737        variant={enabled ? 'primary' : 'secondary'}
738        onPress={() => setEnabled($, !enabled)}
739      />
740    )
741
742    if (!enabled || c === null) {
743      return (
744        <Box flexDirection="row" justifyContent="flex-end" width={columns}>
745          {toggle}
746        </Box>
747      )
748    }
749
750    const elapsed = formatDuration((c.finishedAt ?? now) - c.startedAt)
751    const header =
752      c.phase === 'needs-you' ? (
753        <Text key="header" wrap="truncate-end">
754          <Text backgroundColor="warning" color="inverseText" bold>
755            {' Needs you '}
756          </Text>
757          {` ${c.needsYouReason ?? REASON.waiting}`}
758        </Text>
759      ) : c.phase === 'stuck' ? (
760        <Text key="header" color="warning" wrap="truncate-end">
761          {`⚠ Stuck: ${c.stuckReason ?? REASON.unknownError}`}
762        </Text>
763      ) : c.phase === 'stopped' ? (
764        <Text key="header" wrap="truncate-end">
765          {`■ Stopped · ${c.title} · you pressed Esc`}
766        </Text>
767      ) : c.phase === 'done' ? (
768        <Text key="header" color="success" wrap="truncate-end">
769          {`✓ All done · ${c.title} · took ${elapsed}`}
770        </Text>
771      ) : (
772        <Text key="header" wrap="truncate-end">
773          <Text bold>{c.title}</Text>
774          {` · ${elapsed}`}
775        </Text>
776      )
777
778    const headerRow = (
779      <Box key="header-row" flexDirection="row" justifyContent="space-between" width={columns}>
780        <Box flexGrow={1} flexShrink={1} marginRight={1}>
781          {header}
782        </Box>
783        {toggle}
784      </Box>
785    )
786
787    if (c.phase === 'done' && c.isCollapsed) {
788      return <Box flexDirection="column">{headerRow}</Box>
789    }
790
791    // marker + space, name, two spaces, meter, two spaces, label ("Up next")
792    const nameWidth = Math.max(4, columns - 2 - 2 - METER - 2 - 7)
793    const firstUpcoming = c.tasks.findIndex(t => t.status === 'upcoming')
794    const isMoving = c.phase === 'working' || c.phase === 'needs-you'
795
796    const rows = c.tasks.map((task, i) => {
797      const shown = task.name.length > nameWidth ? `${task.name.slice(0, nameWidth - 1)}…` : task.name
798      const name = shown.padEnd(nameWidth)
799      const key = `row-${task.id}`
800
801      if (task.status === 'done') {
802        return (
803          <Text key={key} wrap="truncate-end">
804            <Text color="success">✓ </Text>
805            <Text dimColor>{name}</Text>
806            {`  ${'█'.repeat(METER)}  `}
807            <Text dimColor>Done</Text>
808          </Text>
809        )
810      }
811
812      if (task.status === 'active') {
813        const meter = task.hasReported
814          ? '█'.repeat(Math.round(task.percent / 10)).padEnd(METER, '░')
815          : sweep(isMoving ? tick : 0)
816        const label = task.hasReported ? `${task.percent}%` : 'Working'
817
818        return (
819          <Text key={key} wrap="truncate-end">
820            <Text color="claude">{c.phase === 'needs-you' ? '‖ ' : '▶ '}</Text>
821            <Text bold>{name}</Text>
822            <Text color="claude">{`  ${meter}  `}</Text>
823            {label}
824          </Text>
825        )
826      }
827
828      return (
829        <Text key={key} dimColor wrap="truncate-end">
830          {`○ ${name}  ${'░'.repeat(METER)}  ${i === firstUpcoming ? 'Next' : 'Up next'}`}
831        </Text>
832      )
833    })
834
835    return (
836      <Box flexDirection="column">
837        {headerRow}
838        {rows}
839      </Box>
840    )
841    })()
842
843    const { Box: Band } = $.ui.resolve(e)
844
845    return (
846      <Band flexDirection="column">
847        {ours}
848        {theirs}
849      </Band>
850    )
851  })
852}
853
854/** A three-cell block sweeping across the meter while no percent is known. */
855function sweep(tick: number): string {
856  const span = METER + 3
857  const head = tick % span
858  let out = ''
859
860  for (let i = 0; i < METER; i += 1) {
861    out += i <= head && i > head - 3 ? '▓' : '░'
862  }
863
864  return out
865}
866
types/index.d.ts 35 lines
1export type CleanViewTaskStatus = 'done' | 'active' | 'upcoming'
2
3export type CleanViewTask = {
4  id: string
5  name: string
6  status: CleanViewTaskStatus
7  percent: number
8  hasReported: boolean
9}
10
11export type CleanViewPhase = 'working' | 'needs-you' | 'stuck' | 'stopped' | 'done'
12
13export type CleanViewChecklist = {
14  title: string
15  phase: CleanViewPhase
16  tasks: CleanViewTask[]
17  needsYouReason: string | null
18  stuckReason: string | null
19  startedAt: number
20  finishedAt: number | null
21  isCollapsed: boolean
22  /** True once Claude laid out a real plan (plan_steps or a to-do list), not the placeholders. */
23  isPlanned: boolean
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    'clean-view': {
29      cleanViewEnabled: boolean
30      checklist: CleanViewChecklist | null
31      tick: number
32    }
33  }
34}
35