SLOPSHOPPER

clean-view-plus

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

newbandrowsguardcommandtoast
v0.5.0MITupdated 2026-10-22LFDM-85/claude-mods/clean-view-plus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clean-view-plus
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ clean-view-plus │ ⏺ 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-plus: Clean View: call mcp__clean-view-plus__plan_steps first to lay out the steps of ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /simple ⎿ clean-view-plus: 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-mods

Two Claude Code mods, forked from MIT-licensed originals:

  • glowup-plus — glowup by Aliasgar Khimani, plus two pane tabs:
  • Tokens: every model request with its cache reads, cache writes, uncached input and output, newest first, and the session's cache hit rate.
  • Flow: the last turn as a diagram — your prompt, each request with its context bar, the tools it ran, the answer.
  • clean-view-plus — Clean View by schmas, plus sub-steps: plan_steps takes optional substeps, report_progress an optional substep, and the active step unfolds its sub-steps and the last actions actually done.

The tabs add no model calls. The sub-step schema and instruction add about 130 tokens to the cached prompt prefix.

Install

/plugin install glowup-plus --marketplace LFDM-85/claude-mods
/plugin install clean-view-plus --marketplace LFDM-85/claude-mods

Answer y to add the marketplace, then pick a scope. Disable the original glowup / clean-view if you have them: both register the same commands and pane.

License

MIT, as the originals; each mod keeps its upstream LICENSE/author.

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