SLOPSHOPPER

clean-view

Calm, non-technical view: hides tool rows and shows one plain-English checklist above the prompt.

newbandrowsguardcommandtoast
v0.5.0MITupdated 2026-10-06schmas/claude-plugins/plugins/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 on. You see the checklist │ ⎿ Read 6 lines │ and every detail. │ ⏺ 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 on, with every detail shown. [ ◐ Clean View: BOTH ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ ◐ Clean View: BOTH ] ⟨Claude Code's own drawing⟩
README

claude-plugins

Public Claude Code plugins and mods by schmas.

PluginScopeContents
clean-viewCalm view for non-technical users (mod)Hides tool calls, diffs and command output; shows one plain-English checklist with progress meters above the prompt. Three modes: on hides details, both shows details and the checklist, off. Switch with the band button or `/simple on\both\off`. With Toolbox installed, the switch is a row in the Toolbox panel
toolboxModel and effort switcher (mod)◆ Toolbox button sits in the prompt footer, next to the mode labels, and opens a panel last above the prompt. A SETTINGS row moves the button above the prompt too. One click sets the session model or effort. Other mods add rows to it through $.toolbox. Toggle with the button or /toolbox

Install

Add the marketplace once, then install the plugins you want.

/plugin marketplace add schmas/claude-plugins
/plugin install clean-view@schmas
/plugin install toolbox@schmas
/reload-plugins

Repo layout

claude-plugins/
├── .claude-plugin/
│   └── marketplace.json        # Lists 2 plugins
├── plugins/
│   ├── clean-view/
│   │   ├── .claude-plugin/plugin.json
│   │   ├── hooks/              # Function-hook mod + tests
│   │   └── types/              # Shared checklist state contract
│   └── toolbox/
│       ├── .claude-plugin/plugin.json
│       ├── hooks/              # Function-hook mod + tests
│       └── types/              # $.toolbox add-on API + state contract
└── README.md

License

MIT. See LICENSE.

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 1150 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderElement, RenderInput, Timer } from 'claude-code'
3
4import type { CleanViewChecklist, CleanViewMode, CleanViewPhase, CleanViewTask } from '../types'
5
6type Engine = EngineInterface
7
8const PLAN_TOOL = 'mcp__clean-view__plan_steps'
9const PROGRESS_TOOL = 'mcp__clean-view__report_progress'
10const STORE_KEY = 'cleanViewMode'
11/** The on/off value saved before the both mode existed. */
12const LEGACY_STORE_KEY = 'cleanViewEnabled'
13const SECTION_ID = 'clean-view:checklist'
14
15const MAX_NAME = 40
16const FALLBACK_NAME = 'Working on it'
17const METER_CELLS = 16
18const FRAME_MS = 250
19const FAIL_LIMIT = 3
20const MIN_STEPS = 2
21const MAX_STEPS = 8
22
23const ALWAYS_ALLOWED = new Set([
24  'ToolSearch',
25  'TodoWrite',
26  'TaskCreate',
27  'TaskUpdate',
28  'AskUserQuestion',
29  PLAN_TOOL,
30])
31
32const PLACEHOLDER_STEPS = ['Understand your request', 'Plan the steps']
33
34const OWN_TOOLS = new Set([PLAN_TOOL, PROGRESS_TOOL])
35const OFF_DESCRIPTION = 'Clean View is off. Do not call this tool.'
36const OFF_REPLY = 'Clean View is off. Skip this tool and go on with the work.'
37
38const NEEDS_OK = 'Claude needs your OK to continue'
39const HAS_QUESTION = 'Claude has a question for you'
40const WAITING_REPLY = 'Claude is waiting for your reply'
41const SAID_NO = 'you said no to a step, so Claude paused'
42const KEEPS_FAILING = 'a step keeps failing, Claude is trying another way'
43const REFUSED = "Claude couldn't help with that request"
44const API_USAGE = 'you hit your usage limit, try again a little later'
45const API_BUSY = "Claude's servers are busy, try again in a minute"
46const API_TOO_LONG = 'type /compact and try again'
47const API_NETWORK = 'the internet connection dropped'
48const API_AUTH = 'type /login'
49const API_OTHER = 'something went wrong talking to Claude, try again'
50
51const USER_SAID_NO = /doesn't want to proceed|tool use was rejected/i
52
53const GATE_MESSAGE =
54  `Clean View: call ${PLAN_TOOL} first to lay out the steps of this job in plain English ` +
55  '(load it with ToolSearch if it is deferred). Then try this tool again.'
56
57const CODE_EXTENSIONS =
58  'tsx?|jsx?|mjs|cjs|mts|cts|py|rb|go|rs|java|kt|kts|swift|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|fish|ps1|' +
59  'json|jsonc|ya?ml|toml|ini|env|lock|md|mdx|html?|css|scss|sass|less|sql|vue|svelte|xml|gradle|tf|dart|lua|r'
60const FILE_NAME = new RegExp(`^[("'\\[]*[\\w.-]+\\.(${CODE_EXTENSIONS})[)"'\\],.:;!?]*$`, 'i')
61
62const EMPTY: CleanViewChecklist = {
63  title: '',
64  phase: 'idle',
65  tasks: [],
66  needsYouReason: null,
67  stuckReason: null,
68  startedAt: null,
69  finishedAt: null,
70  isPlanned: false,
71}
72
73const MODES: readonly CleanViewMode[] = ['on', 'both', 'off']
74
75const MODE_LABEL: Record<CleanViewMode, string> = {
76  on: '● Clean View: ON',
77  both: '◐ Clean View: BOTH',
78  off: '○ Clean View: OFF',
79}
80
81const MODE_TOAST: Record<CleanViewMode, string> = {
82  on: 'Clean View is on. Technical details are hidden.',
83  both: 'Clean View is on. You see the checklist and every detail.',
84  off: 'Clean View is off. You see every detail again.',
85}
86
87/** The Toolbox row's choices, in /simple's order. */
88const MODE_CHOICE: Record<CleanViewMode, string> = {
89  on: 'On',
90  both: 'Both',
91  off: 'Off',
92}
93
94const MODE_REPLY: Record<CleanViewMode, string> = {
95  on: 'Clean View is on.',
96  both: 'Clean View is on, with every detail shown.',
97  off: 'Clean View is off.',
98}
99
100const MODE = { plugin: 'clean-view', key: 'mode' } as const
101const mode = atom(MODE, 'on' as CleanViewMode)
102const checklist = atom({ plugin: 'clean-view', key: 'checklist' } as const, EMPTY)
103const tick = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
104
105/** Turns any step or job name into short plain English with no code in it. */
106export function cleanName(raw: string): string {
107  const words = raw
108    .replace(/`[^`]*`/g, ' ')
109    .replace(/`/g, ' ')
110    .split(/\s+/)
111    .filter(word => word !== '' && !word.includes('/') && !word.includes('\\') && !FILE_NAME.test(word))
112  const text = words.join(' ').replace(/\s+/g, ' ').trim()
113
114  if (text === '') {
115    return FALLBACK_NAME
116  }
117
118  const named = text[0]!.toUpperCase() + text.slice(1)
119
120  if (named.length <= MAX_NAME) {
121    return named
122  }
123
124  const head = named.slice(0, MAX_NAME - 1)
125  const space = head.lastIndexOf(' ')
126  const cut = (space > 0 ? head.slice(0, space) : head).replace(/[\s,.;:!?-]+$/, '')
127
128  return `${cut}…`
129}
130
131function jobTitle(raw: string): string {
132  const firstLine = raw.split('\n').find(line => line.trim() !== '') ?? ''
133  const words = cleanName(firstLine.replace(/["'*_#.]/g, ' ')).replace(/…$/, '').split(' ')
134
135  return cleanName(words.slice(0, 6).join(' '))
136}
137
138function elapsed(ms: number): string {
139  const seconds = Math.max(0, Math.floor(ms / 1000))
140  const hours = Math.floor(seconds / 3600)
141  const minutes = Math.floor((seconds % 3600) / 60)
142
143  if (hours > 0) {
144    return `${hours}h ${minutes}m`
145  }
146
147  return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`
148}
149
150function clampPercent(value: unknown): number {
151  const number = Number(value)
152
153  return Number.isFinite(number) ? Math.min(100, Math.max(0, Math.round(number))) : 0
154}
155
156function isMode(value: unknown): value is CleanViewMode {
157  return MODES.includes(value as CleanViewMode)
158}
159
160function nextMode(current: CleanViewMode): CleanViewMode {
161  return MODES[(MODES.indexOf(current) + 1) % MODES.length]!
162}
163
164/**
165 * The mode now. A /clear starts the session's state over, and no event this
166 * mod can count on fires after it, so a mode not set yet this session comes
167 * from the store.
168 */
169async function currentMode($: Engine): Promise<CleanViewMode> {
170  const { value } = await $.state.get(MODE)
171
172  return isMode(value) ? value : storedMode($)
173}
174
175/** The checklist runs in `on` and `both`. */
176async function isTracking($: Engine): Promise<boolean> {
177  return (await currentMode($)) !== 'off'
178}
179
180/** Tool rows are hidden only in `on`; `both` keeps them next to the checklist. */
181async function isHiding($: Engine): Promise<boolean> {
182  return (await currentMode($)) === 'on'
183}
184
185function isRunning(phase: CleanViewPhase): boolean {
186  return phase === 'working' || phase === 'needsYou' || phase === 'stuck'
187}
188
189function task(id: string, name: string, status: CleanViewTask['status']): CleanViewTask {
190  const isDone = status === 'done'
191
192  return { id, name, status, percent: isDone ? 100 : 0, hasReported: isDone }
193}
194
195function finishAll(tasks: CleanViewTask[]): CleanViewTask[] {
196  return tasks.map(one => ({ ...one, status: 'done', percent: 100, hasReported: true }))
197}
198
199/** Checks off every step before `index`, sets that step's percent and starts the next one at 100. */
200function reportAt(tasks: CleanViewTask[], index: number, percent: number): CleanViewTask[] {
201  const isFinished = percent >= 100
202  const reported = tasks.map((one, at): CleanViewTask => {
203    if (at < index) {
204      return { ...one, status: 'done', percent: 100, hasReported: true }
205    }
206
207    if (at === index) {
208      return { ...one, status: isFinished ? 'done' : 'active', percent, hasReported: true }
209    }
210
211    return one.status === 'active' ? { ...one, status: 'upcoming', percent: 0, hasReported: false } : one
212  })
213
214  if (!isFinished) {
215    return reported
216  }
217
218  const following = reported.findIndex((one, at) => at > index && one.status === 'upcoming')
219
220  return following < 0
221    ? reported
222    : reported.map((one, at) => (at === following ? { ...one, status: 'active', percent: 0, hasReported: false } : one))
223}
224
225function applyProgress(tasks: CleanViewTask[], rawName: string, percent: number): CleanViewTask[] {
226  const name = cleanName(rawName)
227  const found = tasks.findIndex(one => one.name.toLowerCase() === name.toLowerCase())
228
229  if (found >= 0) {
230    return reportAt(tasks, found, percent)
231  }
232
233  // An unplanned step lands where the current step is, so nothing is checked off by mistake.
234  const active = tasks.findIndex(one => one.status === 'active')
235  const at = active >= 0 ? active : tasks.filter(one => one.status === 'done').length
236  const added = [...tasks.slice(0, at), task(`extra-${tasks.length + 1}`, name, 'upcoming'), ...tasks.slice(at)]
237
238  return reportAt(added, at, percent)
239}
240
241function todoStatus(status: unknown): CleanViewTask['status'] {
242  if (status === 'completed') {
243    return 'done'
244  }
245
246  return status === 'in_progress' ? 'active' : 'upcoming'
247}
248
249function apiReason(kind: string, details: string): string {
250  const text = `${kind} ${details}`
251
252  if (/rate_limit|billing|usage limit|429/i.test(text)) {
253    return API_USAGE
254  }
255
256  if (/overloaded|server_error|529|50\d/i.test(text)) {
257    return API_BUSY
258  }
259
260  if (/too long|context|too many tokens|max_output_tokens/i.test(text)) {
261    return API_TOO_LONG
262  }
263
264  if (/authentication|oauth|credential|verification|401|403|\/login|api key/i.test(text)) {
265    return API_AUTH
266  }
267
268  if (/network|connection|connect|econn|enotfound|fetch failed|socket|offline|timed? ?out/i.test(text)) {
269    return API_NETWORK
270  }
271
272  return API_OTHER
273}
274
275function guide(tools: readonly string[]): string {
276  const hasTodos = tools.includes('TodoWrite') || tools.includes('TaskCreate')
277  const lines = [
278    '# Clean View',
279    'The person sees a simple checklist of your work instead of tool calls. Keep it accurate.',
280    '- 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".',
281    '- Never put file paths, file names, commands, code or tool names in a step name.',
282    `- For every request, even a quick question, call ${PLAN_TOOL} first with 2 to 8 short steps in order. Load it with ToolSearch if it is deferred. Other tools are blocked until a plan exists.`,
283    `- Then call ${PROGRESS_TOOL} as real progress happens, and with percent 100 the moment a step finishes.`,
284  ]
285
286  if (hasTodos) {
287    lines.push('- You can use your to-do list (TodoWrite or TaskCreate) as the plan instead; the same naming rules apply.')
288  }
289
290  return lines.join('\n')
291}
292
293function padEnd(text: string, width: number): string {
294  if (width <= 0) {
295    return ''
296  }
297
298  const chars = [...text]
299
300  if (chars.length > width) {
301    return `${chars.slice(0, Math.max(0, width - 1)).join('')}…`
302  }
303
304  return text + ' '.repeat(width - chars.length)
305}
306
307type Stops = readonly string[]
308
309const PINK = '#ff4f8b'
310const TITLE_STOPS: Stops = ['#ff8a3d', '#ff4f8b', '#c86bfa', '#7aa2ff']
311const OVERALL_STOPS: Stops = ['#ff8a3d', '#ff4f8b']
312const DONE_STOPS: Stops = ['#1e8f4e', '#6ee7a0']
313const ACTIVE_STOPS: Stops = ['#8a2f5a', '#ff6fb0']
314const GRADIENT_STEPS = 8
315const SHIMMER_CELLS = 6
316/** The empty part of a bar: a dark gray box, just above the terminal background. */
317const TRACK = '#1f1f25'
318/** One bar cell. The box is narrower than the cell, so each step shows as its own box. */
319const BOX = '■'
320
321const BORDER: Record<CleanViewPhase, string> = {
322  idle: PINK,
323  working: PINK,
324  needsYou: 'yellow',
325  stuck: 'yellow',
326  stopped: 'red',
327  done: 'green',
328}
329
330function hex(color: string): number[] {
331  return [1, 3, 5].map(at => parseInt(color.slice(at, at + 2), 16))
332}
333
334/** The color at `t` (0 to 1) along evenly spaced stops. */
335export function blend(stops: Stops, t: number): string {
336  if (stops.length === 1) {
337    return stops[0]!
338  }
339
340  const at = Math.min(1, Math.max(0, t)) * (stops.length - 1)
341  const low = Math.min(stops.length - 2, Math.floor(at))
342  const from = hex(stops[low]!)
343  const to = hex(stops[low + 1]!)
344  const mix = at - low
345
346  return `#${from.map((one, i) => Math.round(one + (to[i]! - one) * mix).toString(16).padStart(2, '0')).join('')}`
347}
348
349type Cell = { char: string; color: string }
350
351/** Draws cells as Text runs, one run per color, so a long bar stays a few elements. */
352function cells($: Engine, e: RenderInput<'AbovePrompt'>, key: string, list: Cell[]): RenderElement {
353  const { Text } = $.ui.resolve(e)
354  const runs: Cell[] = []
355
356  for (const one of list) {
357    const last = runs[runs.length - 1]
358
359    if (last !== undefined && last.color === one.color) {
360      last.char += one.char
361    } else {
362      runs.push({ ...one })
363    }
364  }
365
366  return (
367    <Text key={key}>
368      {runs.map((run, at) => (
369        <Text key={`${key}-${at}`} color={run.color}>
370          {run.char}
371        </Text>
372      ))}
373    </Text>
374  )
375}
376
377/** `filled` gradient boxes, then dark track boxes, `width` in all. */
378function bar(width: number, filled: number, stops: Stops): Cell[] {
379  return Array.from({ length: width }, (_, at): Cell => {
380    if (at >= filled) {
381      return { char: BOX, color: TRACK }
382    }
383
384    const t = Math.round((filled <= 1 ? 1 : at / (filled - 1)) * (GRADIENT_STEPS - 1)) / (GRADIENT_STEPS - 1)
385
386    return { char: BOX, color: blend(stops, t) }
387  })
388}
389
390/** A short pink streak that runs along the track while a step has no percent yet. */
391function shimmer(width: number, frame: number): Cell[] {
392  const head = frame % (width + SHIMMER_CELLS)
393
394  return Array.from({ length: width }, (_, at): Cell => {
395    const behind = head - at
396
397    if (behind < 0 || behind >= SHIMMER_CELLS) {
398      return { char: BOX, color: TRACK }
399    }
400
401    return { char: BOX, color: blend(ACTIVE_STOPS, 1 - behind / (SHIMMER_CELLS - 1)) }
402  })
403}
404
405function meter(one: CleanViewTask, frame: number, width: number): Cell[] {
406  if (one.status === 'done') {
407    return bar(width, width, DONE_STOPS)
408  }
409
410  if (one.status === 'upcoming') {
411    return bar(width, 0, DONE_STOPS)
412  }
413
414  if (!one.hasReported) {
415    return shimmer(width, frame)
416  }
417
418  return bar(width, Math.round((one.percent / 100) * width), ACTIVE_STOPS)
419}
420
421/** Whole-job progress: done steps plus the active step's reported share. */
422export function overallPercent(tasks: readonly CleanViewTask[]): number {
423  if (tasks.length === 0) {
424    return 0
425  }
426
427  const share = tasks.reduce((sum, one) => {
428    if (one.status === 'done') {
429      return sum + 1
430    }
431
432    return one.status === 'active' && one.hasReported ? sum + one.percent / 100 : sum
433  }, 0)
434
435  return Math.round((share / tasks.length) * 100)
436}
437
438/** The step number the header names: the active step, or the count done when none is active. */
439function stepNumber(tasks: readonly CleanViewTask[]): number {
440  const active = tasks.findIndex(one => one.status === 'active')
441
442  return active >= 0 ? active + 1 : tasks.filter(one => one.status === 'done').length
443}
444
445let frameTimer: Timer | null = null
446let failures = 0
447let lastApiError: { kind: string; details: string } | null = null
448
449function syncClock($: Engine, list: CleanViewChecklist): void {
450  const shouldAnimate = list.phase === 'working' || list.phase === 'needsYou'
451
452  if (shouldAnimate && frameTimer === null) {
453    frameTimer = $.clock.every(FRAME_MS, () => {
454      void update($, tick, frame => frame + 1)
455    })
456  }
457
458  if (!shouldAnimate && frameTimer !== null) {
459    frameTimer.cancel()
460    frameTimer = null
461  }
462}
463
464async function change($: Engine, fn: (list: CleanViewChecklist) => CleanViewChecklist): Promise<CleanViewChecklist> {
465  const list = await update($, checklist, fn)
466  syncClock($, list)
467
468  return list
469}
470
471async function startJob($: Engine, title: string): Promise<CleanViewChecklist> {
472  const now = await $.clock.now()
473  failures = 0
474  lastApiError = null
475
476  return change($, () => ({
477    ...EMPTY,
478    title,
479    phase: 'working',
480    tasks: PLACEHOLDER_STEPS.map((name, at) => task(`placeholder-${at + 1}`, name, at === 0 ? 'active' : 'upcoming')),
481    startedAt: now,
482  }))
483}
484
485async function ensureJob($: Engine): Promise<void> {
486  const list = await read($, checklist)
487
488  if (!isRunning(list.phase)) {
489    await startJob($, FALLBACK_NAME)
490  }
491}
492
493async function nameJob($: Engine, prompt: string, startedAt: number | null): Promise<void> {
494  try {
495    const reply = await $.model.complete({
496      model: 'haiku',
497      effort: 'low',
498      maxTokens: 40,
499      timeoutMs: 15000,
500      system:
501        'Name the request as a job title a non-technical person understands: 2 to 6 plain English words that start with a verb, like "Build my landing page". No file names, paths, code or quotes. Reply with the title only.',
502      prompt: prompt.slice(0, 4000),
503    })
504
505    if (!reply.isAnswered) {
506      return
507    }
508
509    const title = jobTitle(reply.text)
510    await update($, checklist, list => (list.startedAt === startedAt ? { ...list, title } : list))
511  } catch {
512    // The header keeps its fallback name.
513  }
514}
515
516async function setStuck($: Engine, reason: string): Promise<void> {
517  await change($, list => (isRunning(list.phase) ? { ...list, phase: 'stuck', stuckReason: reason, needsYouReason: null } : list))
518}
519
520async function setNeedsYou($: Engine, reason: string): Promise<void> {
521  await ensureJob($)
522  await change($, list => ({ ...list, phase: 'needsYou', needsYouReason: reason }))
523}
524
525async function resume($: Engine): Promise<void> {
526  await change($, list =>
527    list.phase === 'needsYou' || list.phase === 'stuck'
528      ? { ...list, phase: 'working', needsYouReason: null, stuckReason: null }
529      : list,
530  )
531}
532
533async function setMode($: Engine, value: CleanViewMode): Promise<void> {
534  const wasTracking = await isTracking($)
535  await update($, mode, () => value)
536
537  // The tool descriptions change only between off and the other modes; a redescribe spends the prompt cache.
538  if (wasTracking !== (value !== 'off')) {
539    $.ui.invalidate('tool.describe')
540  }
541
542  await $.store.set(STORE_KEY, value)
543  sendToolboxRow($, value)
544  $.ui.toast(MODE_TOAST[value])
545}
546
547/** The mode a Toolbox choice names, or null for an unknown one. */
548export function choiceMode(choice: unknown): CleanViewMode | null {
549  return MODES.find(one => MODE_CHOICE[one] === choice) ?? null
550}
551
552const TOOLBOX_ROW = 'clean-view.mode'
553
554/**
555 * Sends the mode switch to the Toolbox panel when the Toolbox mod is loaded.
556 * Returns false when Toolbox is not loaded, so Clean View keeps its own button.
557 */
558function sendToolboxRow($: Engine, current: CleanViewMode): boolean {
559  try {
560    $.toolbox.addSetting({
561      id: TOOLBOX_ROW,
562      label: 'Clean View',
563      hint: 'simple checklist',
564      kind: 'choice',
565      options: MODES.map(one => MODE_CHOICE[one]),
566      value: MODE_CHOICE[current],
567    })
568
569    return true
570  } catch {
571    // Toolbox is not installed or not enabled: Clean View works as before.
572    return false
573  }
574}
575
576async function storedMode($: Engine): Promise<CleanViewMode> {
577  const stored = await $.store.get(STORE_KEY)
578
579  if (isMode(stored)) {
580    return stored
581  }
582
583  const legacy = await $.store.get(LEGACY_STORE_KEY)
584
585  return legacy === false ? 'off' : 'on'
586}
587
588async function planSteps($: Engine, input: Record<string, unknown>) {
589  const raw = Array.isArray(input.steps) ? input.steps : []
590  const steps = raw.map(step => cleanName(String(step))).slice(0, MAX_STEPS)
591
592  if (steps.length < MIN_STEPS) {
593    return { deny: `Give ${MIN_STEPS} to ${MAX_STEPS} short step names, in order.` }
594  }
595
596  await ensureJob($)
597  await change($, list => ({
598    ...list,
599    phase: list.phase === 'stuck' ? 'working' : list.phase,
600    stuckReason: list.phase === 'stuck' ? null : list.stuckReason,
601    tasks: steps.map((name, at) => task(`step-${at + 1}`, name, at === 0 ? 'active' : 'upcoming')),
602    isPlanned: true,
603  }))
604
605  return { result: `Planned ${steps.length} steps. The first one has started.` }
606}
607
608async function reportProgress($: Engine, input: Record<string, unknown>) {
609  const percent = clampPercent(input.percent)
610  await ensureJob($)
611  await change($, list => ({ ...list, tasks: applyProgress(list.tasks, String(input.task ?? ''), percent) }))
612
613  return { result: `Progress noted: ${percent}%.` }
614}
615
616async function trackTodos($: Engine, input: Record<string, unknown>): Promise<void> {
617  if (!Array.isArray(input.todos)) {
618    return
619  }
620
621  const todos = input.todos as Array<Record<string, unknown>>
622  await ensureJob($)
623  await change($, list => ({
624    ...list,
625    isPlanned: list.isPlanned || todos.length > 0,
626    tasks: todos.map((todo, at) => {
627      const name = cleanName(String(todo.content ?? todo.activeForm ?? ''))
628      const status = todoStatus(todo.status)
629      const before = list.tasks.find(one => one.name === name && one.status === 'active')
630
631      return status === 'active' && before !== undefined
632        ? { ...before, id: `todo-${at + 1}` }
633        : task(`todo-${at + 1}`, name, status)
634    }),
635  }))
636}
637
638async function trackTaskCreate($: Engine, input: Record<string, unknown>, resultText: string): Promise<void> {
639  const name = cleanName(String(input.subject ?? input.description ?? ''))
640  const number = /#?(\d+)/.exec(resultText)?.[1]
641  await ensureJob($)
642  await change($, list => {
643    const kept = list.isPlanned ? list.tasks : []
644    const id = `task-${number ?? kept.length + 1}`
645
646    return { ...list, isPlanned: true, tasks: [...kept.filter(one => one.id !== id), task(id, name, 'upcoming')] }
647  })
648}
649
650async function trackTaskUpdate($: Engine, input: Record<string, unknown>): Promise<void> {
651  const id = `task-${String(input.taskId ?? '')}`
652  const subject = typeof input.subject === 'string' ? cleanName(input.subject) : null
653  await change($, list => {
654    if (input.status === 'deleted') {
655      return { ...list, tasks: list.tasks.filter(one => one.id !== id) }
656    }
657
658    const status = input.status === undefined ? null : todoStatus(input.status)
659
660    return {
661      ...list,
662      tasks: list.tasks.map(one => {
663        if (one.id !== id) {
664          return one
665        }
666
667        const named = subject === null ? one : { ...one, name: subject }
668
669        if (status === null || status === named.status) {
670          return named
671        }
672
673        return status === 'active' ? { ...named, status, percent: 0, hasReported: false } : task(id, named.name, status)
674      }),
675    }
676  })
677}
678
679async function trackOutcome($: Engine, ran: { deny?: string; isError?: true; text?: string; result?: unknown }): Promise<void> {
680  const message = ran.deny ?? ran.text ?? (typeof ran.result === 'string' ? ran.result : '')
681
682  if (USER_SAID_NO.test(message)) {
683    failures = 0
684    await setStuck($, SAID_NO)
685
686    return
687  }
688
689  if (ran.deny !== undefined) {
690    return
691  }
692
693  if (ran.isError === true) {
694    failures += 1
695
696    if (failures >= FAIL_LIMIT) {
697      await setStuck($, KEEPS_FAILING)
698    }
699
700    return
701  }
702
703  failures = 0
704  await change($, list => (list.phase === 'stuck' ? { ...list, phase: 'working', stuckReason: null } : list))
705}
706
707/** Colors each word of the title along the title gradient. */
708function gradientTitle($: Engine, e: RenderInput<'AbovePrompt'>, title: string): RenderElement {
709  const { Text } = $.ui.resolve(e)
710  const words = title.split(' ')
711
712  return (
713    <Text bold>
714      {words.map((word, at) => (
715        <Text key={`word-${at}`} color={blend(TITLE_STOPS, words.length <= 1 ? 0 : at / (words.length - 1))}>
716          {at === 0 ? word : ` ${word}`}
717        </Text>
718      ))}
719    </Text>
720  )
721}
722
723/** Draws the band; null when there is nothing to show. With Toolbox loaded, the mode switch lives in its panel. */
724async function drawBand($: Engine, e: RenderInput<'AbovePrompt'>, hasToolbox: boolean): Promise<RenderElement | null> {
725  const { Box, Text, Button } = $.ui.resolve(e)
726  const current = await currentMode($)
727  const list = await read($, checklist)
728  const frame = await read($, tick)
729  const now = await $.clock.now()
730  const width = Math.max(24, e.props.bodyColumns)
731  // The round border and one cell of padding on each side.
732  const inner = width - 4
733
734  const toggle = hasToolbox ? null : (
735    <Box key="toggle-row" flexDirection="row" justifyContent="flex-end" width={width}>
736      <Button key="toggle" label={MODE_LABEL[current]} onPress={() => setMode($, nextMode(current))} />
737    </Box>
738  )
739
740  if (current === 'off' || list.phase === 'idle') {
741    return toggle === null ? null : <Box flexDirection="column">{toggle}</Box>
742  }
743
744  const title = list.title || FALLBACK_NAME
745  const runFor = elapsed((list.finishedAt ?? now) - (list.startedAt ?? now))
746  let left: RenderElement
747
748  switch (list.phase) {
749    case 'needsYou':
750      left = (
751        <Text wrap="truncate">
752          <Text bold inverse color="yellow"> Needs you </Text> {list.needsYouReason ?? WAITING_REPLY}
753        </Text>
754      )
755      break
756    case 'stuck':
757      left = (
758        <Text wrap="truncate" color="yellow">
759          ⚠ Stuck: {list.stuckReason ?? KEEPS_FAILING}
760        </Text>
761      )
762      break
763    case 'stopped':
764      left = (
765        <Text wrap="truncate">
766          <Text color="red">■ Stopped</Text> · {title} · you pressed Esc
767        </Text>
768      )
769      break
770    case 'done':
771      left = (
772        <Text wrap="truncate">
773          <Text color="green">✓ All done</Text> · {title}
774        </Text>
775      )
776      break
777    default:
778      left = (
779        <Text wrap="truncate">
780          <Text color={PINK}>✶ </Text>
781          {gradientTitle($, e, title)}
782        </Text>
783      )
784  }
785
786  // One button per mode; the current one is bracketed and full strength, the others dim.
787  const switches = MODES.map(one =>
788    one === current ? (
789      <Button key={`mode-${one}`} plain label={`[${MODE_CHOICE[one]}]`} onPress={() => setMode($, one)} />
790    ) : (
791      <Button key={`mode-${one}`} plain dimColor label={` ${MODE_CHOICE[one]} `} onPress={() => setMode($, one)} />
792    ),
793  )
794  // A finished list stays until the next request or until this button clears it.
795  const clear = isRunning(list.phase) ? null : (
796    <Button key="clear" plain dimColor label=" ✕ Clear " onPress={() => change($, () => EMPTY)} />
797  )
798  const header = (
799    <Box key="header" flexDirection="row" justifyContent="space-between" width={inner}>
800      <Box flexShrink={1}>{left}</Box>
801      <Box key="header-right" flexDirection="row" flexShrink={0}>
802        {switches}
803        {clear}
804        <Text dimColor> {runFor}</Text>
805      </Box>
806    </Box>
807  )
808  const frameBox = (children: RenderElement[]) => (
809    <Box key="frame" flexDirection="column" borderStyle="round" borderColor={BORDER[list.phase]} paddingX={1} width={width}>
810      {children}
811    </Box>
812  )
813
814  const total = list.tasks.length
815  const percent = overallPercent(list.tasks)
816  const stepLabel = `Step ${stepNumber(list.tasks)} of ${total} `
817  // label + bar + gap (1) + percent (4)
818  const overallWidth = Math.max(4, inner - stepLabel.length - 1 - 4)
819  const overall = (
820    <Box key="overall" flexDirection="row">
821      <Text>{stepLabel}</Text>
822      {cells($, e, 'overall-bar', bar(overallWidth, Math.round((percent / 100) * overallWidth), OVERALL_STOPS))}
823      <Text bold color={PINK}> {`${percent}%`.padStart(4)}</Text>
824    </Box>
825  )
826
827  // icon (2) + name + meter + gap (2) + label (7)
828  const meterWidth = Math.max(4, Math.min(METER_CELLS, inner - 2 - 12 - 2 - 7))
829  const nameWidth = Math.max(4, Math.min(MAX_NAME + 2, inner - 2 - meterWidth - 2 - 7))
830  const firstUpcoming = list.tasks.findIndex(one => one.status === 'upcoming')
831  const rows = list.tasks.map((one, at) => {
832    const name = `${padEnd(one.name, nameWidth - 1)} `
833    const track = cells($, e, `meter-${one.id}`, meter(one, frame, meterWidth))
834
835    if (one.status === 'done') {
836      return (
837        <Box key={`row-${one.id}`} flexDirection="row">
838          <Text color="green">✓ </Text>
839          <Text>{name}</Text>
840          {track}
841          <Text>  Done</Text>
842        </Box>
843      )
844    }
845
846    if (one.status === 'active') {
847      const isWaiting = list.phase === 'needsYou'
848
849      return (
850        <Box key={`row-${one.id}`} flexDirection="row">
851          <Text color={isWaiting ? 'yellow' : PINK}>{isWaiting ? '‖ ' : '● '}</Text>
852          <Text bold>{name}</Text>
853          {track}
854          <Text bold color={PINK}>
855            {'  '}
856            {one.hasReported ? `${one.percent}%` : 'Working'}
857          </Text>
858        </Box>
859      )
860    }
861
862    return (
863      <Box key={`row-${one.id}`} flexDirection="row">
864        <Text dimColor>○ </Text>
865        <Text dimColor>{name}</Text>
866        {track}
867        <Text dimColor>  {at === firstUpcoming ? 'Next' : 'Up next'}</Text>
868      </Box>
869    )
870  })
871
872  return (
873    <Box flexDirection="column" marginTop={1}>
874      {toggle}
875      {frameBox([header, overall, ...rows])}
876    </Box>
877  )
878}
879
880export function registerCleanView(on: On): void {
881  on('session.start', async ($, e, next) => {
882    await $.tool.register({
883      name: 'plan_steps',
884      description:
885        'Lay out every step of the current job up front, in order, so the person sees a simple checklist. Call it first for every request. Each step is plain English, under 40 characters, starts with a verb, and never holds file paths, file names, commands, code or tool names.',
886      inputSchema: {
887        type: 'object',
888        properties: {
889          steps: {
890            type: 'array',
891            items: { type: 'string' },
892            minItems: MIN_STEPS,
893            maxItems: MAX_STEPS,
894            description: '2 to 8 short step names, in order, like "Build the pricing section".',
895          },
896        },
897        required: ['steps'],
898      },
899    })
900    await $.tool.register({
901      name: 'report_progress',
902      description:
903        'Report progress on the current step of the plan. Call it as real progress happens, and with percent 100 the moment a step finishes; the next step then starts by itself. Use the step name exactly as planned.',
904      inputSchema: {
905        type: 'object',
906        properties: {
907          task: { type: 'string', description: 'The step name, as given to plan_steps.' },
908          percent: { type: 'number', minimum: 0, maximum: 100, description: 'How far along the step is, 0 to 100.' },
909        },
910        required: ['task', 'percent'],
911      },
912    })
913    await $.command.register({
914      name: 'simple',
915      description: 'Set Clean View: on hides details, both shows details and the checklist, off (no argument cycles)',
916      argumentHint: 'on|both|off',
917    })
918
919    const stored = await storedMode($)
920    await update($, mode, () => stored)
921    syncClock($, await read($, checklist))
922    sendToolboxRow($, stored)
923
924    return next(e)
925  })
926
927  // A click on Clean View's row in the Toolbox panel.
928  on('state.set', { plugin: 'toolbox', key: 'press' }, async ($, e, next) => {
929    const result = await next(e)
930    const pressed = e.value
931    const value = pressed !== null && pressed.id === TOOLBOX_ROW ? choiceMode(pressed.value) : null
932
933    if (value !== null) {
934      await setMode($, value)
935    }
936
937    return result
938  })
939
940  on('command.run', { command: 'simple' }, async ($, e) => {
941    const arg = e.args.trim().toLowerCase()
942
943    if (arg !== '' && !isMode(arg)) {
944      return { text: 'Use /simple on, /simple both, /simple off, or /simple to go to the next mode.' }
945    }
946
947    const value = arg === '' ? nextMode(await currentMode($)) : arg
948    await setMode($, value)
949
950    return { text: MODE_REPLY[value] }
951  })
952
953  on('prompt.compose', async ($, e, next) => {
954    const composed = await next(e)
955
956    if (!(await isTracking($))) {
957      return composed
958    }
959
960    const sections = composed.sections.filter(section => section.id !== SECTION_ID)
961
962    return { ...composed, sections: [...sections, { id: SECTION_ID, text: guide(e.tools), scope: 'session' as const }] }
963  })
964
965  // Off: the model is told to leave the Clean View tools alone, not only the checklist section.
966  on('tool.describe', async ($, e, next) => {
967    const described = await next(e)
968
969    if (!OWN_TOOLS.has(e.tool) || (await isTracking($))) {
970      return described
971    }
972
973    return { description: OFF_DESCRIPTION, isDeferred: true }
974  })
975
976  on('turn.start', async ($, e, next) => {
977    const text = e.text.trim()
978
979    if (text === '' || text.startsWith('/')) {
980      return next(e)
981    }
982
983    const list = await read($, checklist)
984
985    if (isRunning(list.phase)) {
986      await resume($)
987
988      return next(e)
989    }
990
991    const started = await startJob($, FALLBACK_NAME)
992
993    if (await isTracking($)) {
994      void nameJob($, text, started.startedAt)
995    }
996
997    return next(e)
998  })
999
1000  on('tool.call', async ($, e, next) => {
1001    const tool = String(e.tool)
1002    const input = e as unknown as Record<string, unknown>
1003    const isMain = e.agentId === undefined
1004
1005    if (isMain && !ALWAYS_ALLOWED.has(tool) && (await isTracking($)) && !(await read($, checklist)).isPlanned) {
1006      return { deny: GATE_MESSAGE }
1007    }
1008
1009    if (OWN_TOOLS.has(tool) && !(await isTracking($))) {
1010      return { result: OFF_REPLY }
1011    }
1012
1013    if (tool === PLAN_TOOL) {
1014      return planSteps($, input)
1015    }
1016
1017    if (tool === PROGRESS_TOOL) {
1018      return reportProgress($, input)
1019    }
1020
1021    if (!isMain) {
1022      return next(e)
1023    }
1024
1025    if (tool === 'AskUserQuestion') {
1026      await setNeedsYou($, HAS_QUESTION)
1027    } else {
1028      await resume($)
1029    }
1030
1031    const ran = await next(e)
1032
1033    await change($, list => (list.phase === 'needsYou' ? { ...list, phase: 'working', needsYouReason: null } : list))
1034
1035    if (ran.deny === undefined && ran.isError !== true) {
1036      if (tool === 'TodoWrite') {
1037        await trackTodos($, input)
1038      } else if (tool === 'TaskCreate') {
1039        await trackTaskCreate($, input, ran.text ?? JSON.stringify(ran.result ?? ''))
1040      } else if (tool === 'TaskUpdate') {
1041        await trackTaskUpdate($, input)
1042      }
1043    }
1044
1045    await trackOutcome($, ran)
1046
1047    return ran
1048  })
1049
1050  on('classic.Notification', async ($, e, next) => {
1051    if (e.notification_type === 'permission_prompt') {
1052      await setNeedsYou($, NEEDS_OK)
1053    } else if (e.notification_type === 'elicitation_dialog') {
1054      await setNeedsYou($, HAS_QUESTION)
1055    }
1056
1057    return next(e)
1058  })
1059
1060  on('classic.StopFailure', async ($, e, next) => {
1061    lastApiError = { kind: e.error, details: e.error_details ?? e.last_assistant_message ?? '' }
1062    await setStuck($, apiReason(lastApiError.kind, lastApiError.details))
1063
1064    return next(e)
1065  })
1066
1067  on('turn.complete', async ($, e, next) => {
1068    if (e.agentId !== undefined) {
1069      return next(e)
1070    }
1071
1072    const list = await read($, checklist)
1073
1074    if (!isRunning(list.phase)) {
1075      return next(e)
1076    }
1077
1078    const now = await $.clock.now()
1079    const apiError = lastApiError
1080    failures = 0
1081    lastApiError = null
1082
1083    if (e.reason === 'error') {
1084      await setStuck($, apiReason(apiError?.kind ?? '', apiError?.details ?? e.answer))
1085    } else if (e.reason === 'refusal') {
1086      await setStuck($, REFUSED)
1087    } else if (e.reason === 'aborted') {
1088      await change($, current => ({ ...current, phase: 'stopped', needsYouReason: null, stuckReason: null, finishedAt: now }))
1089    } else if (list.isPlanned && list.tasks.some(one => one.status !== 'done')) {
1090      await change($, current => ({ ...current, phase: 'needsYou', needsYouReason: WAITING_REPLY, stuckReason: null }))
1091    } else {
1092      await change($, current => ({
1093        ...current,
1094        phase: 'done',
1095        tasks: finishAll(current.tasks),
1096        needsYouReason: null,
1097        stuckReason: null,
1098        finishedAt: now,
1099      }))
1100    }
1101
1102    return next(e)
1103  })
1104
1105  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
1106    const { Box } = $.ui.resolve(e)
1107
1108    return (await isHiding($)) ? <Box display="none" /> : next(e)
1109  })
1110
1111  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
1112    const { Box } = $.ui.resolve(e)
1113
1114    return (await isHiding($)) ? <Box display="none" /> : next(e)
1115  })
1116
1117  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
1118    const { Box } = $.ui.resolve(e)
1119
1120    return (await isHiding($)) ? <Box display="none" /> : next(e)
1121  })
1122
1123  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) =>
1124    (await isHiding($)) ? next({ ...e, props: { ...e.props, hint: '' } }) : next(e),
1125  )
1126
1127  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1128    if (e.props.hasSurvey) {
1129      return next(e)
1130    }
1131
1132    // Sent on each draw, so the Toolbox panel has the row again after Toolbox reloads.
1133    const hasToolbox = sendToolboxRow($, await currentMode($))
1134    const { Box } = $.ui.resolve(e)
1135    const band = await drawBand($, e, hasToolbox)
1136    const beneath = await next(e)
1137
1138    if (band === null) {
1139      return beneath
1140    }
1141
1142    return (
1143      <Box flexDirection="column">
1144        {band}
1145        {beneath}
1146      </Box>
1147    )
1148  })
1149}
1150
types/index.d.ts 37 lines
1/** `on` hides details, `both` shows details and the checklist, `off` shows details only. */
2export type CleanViewMode = 'on' | 'both' | 'off'
3
4export type CleanViewTaskStatus = 'done' | 'active' | 'upcoming'
5
6export type CleanViewTask = {
7  id: string
8  name: string
9  status: CleanViewTaskStatus
10  percent: number
11  hasReported: boolean
12}
13
14export type CleanViewPhase = 'idle' | 'working' | 'needsYou' | 'stuck' | 'stopped' | 'done'
15
16export type CleanViewChecklist = {
17  title: string
18  phase: CleanViewPhase
19  tasks: CleanViewTask[]
20  needsYouReason: string | null
21  stuckReason: string | null
22  startedAt: number | null
23  finishedAt: number | null
24  /** True once Claude laid out a real plan (plan_steps or a to-do list). */
25  isPlanned: boolean
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'clean-view': {
31      mode: CleanViewMode
32      checklist: CleanViewChecklist
33      tick: number
34    }
35  }
36}
37