SLOPSHOPPER

clean-view

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

newbandrowsguardcommandtoast
v0.1.0MITupdated 2026-10-14hearsawho25/roberts-mods/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 off: showing everything Claude │ ⎿ Read 6 lines │ does │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /simple ⎿ clean-view: Clean View is off. [ ○ Clean View: OFF ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ ○ Clean View: OFF ] ⟨Claude Code's own drawing⟩
README

roberts-mods

A small collection of Claude Code mods (hooks-module plugins), shared as a plugin marketplace. Use any of them, change them, pass them on: they're MIT-licensed.

Install

claude plugin marketplace add hearsawho25/roberts-mods
claude plugin install <mod>@roberts-mods

Install as many as you like, then run /reload-plugins or start a new session.

The mods

ModCommandWhat it does
roberts-toolbox/toolboxOne button at the end of the prompt hint line that opens a menu of your mods and the commands that change Claude Code.
clean-view`/simple on\off`Hides tool calls, diffs and command output, and shows one plain-English checklist above the prompt instead.
prompt-upgrade/betterTurns a rough prompt into a better one in a pane: up to three quick questions, a rewrite, and a side-by-side review before anything is sent.
daily-challenge/challengeOne small Claude Code exercise a day, with a streak and a year of challenges that never repeat.
work-report/reportCounts the tasks Claude finished for you and estimates the hours and dollars saved, by day, week, month and year.
copy-blocks/copyblock [n]A Copy button under every code block in Claude's replies, so you get clean text without the frame.
relay/relay, /handoff, /keepwarmA context and prompt-cache meter above the prompt, one-click handoffs to a fresh session, and cache warming with a cold-cache guard. See relay/README.md.
git-ship/shipRepo status above the prompt, and a pane whose buttons fill ready-to-run git and gh commands (commit messages and PRs drafted for you). It never runs a write: you press Enter. See git-ship/README.md.
mod-doctor/mod-doctorChecks that every enabled mod actually loaded. Stays quiet until one didn't, then shows a red row and the reason from claude plugin validate. See mod-doctor/README.md.

Credits

Several of these started as other people's ideas, and they deserve the credit:

  • Clean View, Prompt Upgrade, Daily Challenge and Work Report were built from prompts by Tristen O'Brien (AI Made Easy). The ideas are Tristen's. These are my builds of them.
  • Robert's Toolbox is based on a toolbox Tristen O'Brien showed in a video (tristenobrien.ai).
  • Relay's price table is adapted from cache-tax by Karan Bansal (MIT License).

The mods themselves were built with Claude Code.

License

MIT.

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 633 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, Timer } from 'claude-code'
3
4import type { CleanViewChecklist, CleanViewTask } from '../types'
5
6/**
7 * Clean View: hides the tool rows of the transcript and draws one plain-English
8 * checklist above the prompt (the plan, the step under way, how far along it is).
9 */
10
11type Engine = EngineInterface
12
13const PLAN_TOOL = 'mcp__clean-view__plan_steps'
14const PROGRESS_TOOL = 'mcp__clean-view__report_progress'
15const ALWAYS_ALLOWED = new Set(['ToolSearch', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'AskUserQuestion', PLAN_TOOL])
16const STORE_KEY = 'cleanViewEnabled'
17const MAX_NAME = 40
18const METER = 10
19const FILLED = '▰'
20const EMPTY = '▱'
21const LABEL_WIDTH = 7
22const TICK_MS = 250
23const COLLAPSE_MS = 5000
24const FALLBACK_NAME = 'Working on it'
25const FIRST_TITLE = 'Working on your request'
26
27const ASKED_OK = 'Claude needs your OK to continue'
28const ASKED_QUESTION = 'Claude has a question for you'
29const WAITING_REPLY = 'Claude is waiting for your reply'
30const SAID_NO = 'you said no to a step, so Claude paused'
31const KEEPS_FAILING = 'a step keeps failing, Claude is trying another way'
32const REFUSED = "Claude couldn't help with that request"
33
34const GATE_MESSAGE =
35  `Clean View: before any other tool, call ${PLAN_TOOL} with every step of this job ` +
36  '(2 to 8 short plain-English names, in order). Load it with ToolSearch if it is deferred, then carry on.'
37
38const GUIDE = [
39  '# Clean View',
40  'The person sees a simple checklist above the prompt instead of your tool calls. Keep it true:',
41  `- For every request, even a quick question, call \`${PLAN_TOOL}\` first with every step of the job, 2 to 8 in order. Load it with ToolSearch if it is deferred. Other tools are refused until you do.`,
42  `- Call \`${PROGRESS_TOOL}\` with the step's exact name and a percent as real progress happens, and with 100 the moment a step finishes.`,
43  '- Write every step name in plain English a non-technical person understands, under 40 characters, starting with a verb, like "Build the pricing section".',
44  '- Never put file paths, file names, commands, code or tool names in a step name.',
45  '- If this session has TodoWrite or TaskCreate, your to-do list can be the plan instead; the same naming rules apply to its items.',
46].join('\n')
47
48const enabledAtom = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
49const checklistAtom = atom({ plugin: 'clean-view', key: 'checklist' } as const, null)
50const tickAtom = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
51
52const CODE_EXT =
53  /\.(tsx?|jsx?|mjs|cjs|mts|cts|py|rb|go|rs|java|kt|swift|c|h|cc|cpp|hpp|cs|php|html?|css|scss|sass|less|json|jsonc|ya?ml|toml|md|mdx|sh|bash|zsh|ps1|bat|cmd|sql|vue|svelte|xml|lock|ini|cfg|conf|env|txt|csv|gradle|dart|lua|ex|exs|pl|dockerfile)$/i
54
55/** Turns any step or job name into a short plain name: no code, paths or file names. */
56export function cleanName(raw: unknown): string {
57  const source = typeof raw === 'string' ? raw : ''
58  const words = source
59    .replace(/`[^`]*`?/g, ' ')
60    .split(/\s+/)
61    .filter(word => {
62      if (word === '' || word.includes('/') || word.includes('\\')) return false
63      return !CODE_EXT.test(word.replace(/^[("'[]+|[)"'\],.;:!?]+$/g, ''))
64    })
65  let text = words
66    .join(' ')
67    .replace(/\s+([,.;:!?])/g, '$1')
68    .replace(/^[\s,.;:!?-]+/, '')
69    .replace(/[\s,;:-]+$/, '')
70    .trim()
71  if (text === '') return FALLBACK_NAME
72  text = text.charAt(0).toUpperCase() + text.slice(1)
73  if (text.length > MAX_NAME) {
74    const cut = text.slice(0, MAX_NAME - 1)
75    const space = cut.lastIndexOf(' ')
76    text = (space > 0 ? cut.slice(0, space) : cut).replace(/[\s,.;:!?-]+$/, '') + '…'
77  }
78  return text
79}
80
81/** A job title: the cleaned name, at most six words, without quotes or a closing full stop. */
82function cleanTitle(raw: string): string {
83  const firstLine = raw.split('\n').find(line => line.trim() !== '') ?? ''
84  const words = firstLine.replace(/["“”*]/g, '').replace(/\.\s*$/, '').trim().split(/\s+/).slice(0, 6)
85  return cleanName(words.join(' '))
86}
87
88function newId(): string {
89  return crypto.randomUUID().slice(0, 8)
90}
91
92function step(name: string, status: CleanViewTask['status'], id = newId()): CleanViewTask {
93  return { id, name, status, percent: status === 'done' ? 100 : 0, hasReported: false }
94}
95
96function freshChecklist(jobId: string, now: number): CleanViewChecklist {
97  return {
98    jobId,
99    title: FIRST_TITLE,
100    phase: 'working',
101    tasks: [step('Understand your request', 'active', 'p1'), step('Plan the steps', 'upcoming', 'p2')],
102    isPlanned: false,
103    needsYouReason: null,
104    stuckReason: null,
105    startedAt: now,
106    finishedAt: null,
107    isCollapsed: false,
108  }
109}
110
111const sameName = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
112
113const isLive = (list: CleanViewChecklist) =>
114  list.phase === 'working' || list.phase === 'needsYou' || list.phase === 'stuck'
115
116/** The checklist after a progress report on `name`: earlier steps done, a finished step hands on. */
117export function applyProgress(tasks: readonly CleanViewTask[], name: string, percent: number): CleanViewTask[] {
118  const list = tasks.map(task => ({ ...task }))
119  let index = list.findIndex(task => sameName(task.name, name))
120  if (index === -1) {
121    index = list.reduce((last, task, at) => (task.status === 'upcoming' ? last : at), -1) + 1
122    list.splice(index, 0, step(name, 'upcoming'))
123  }
124  const updated = list.map((task, at): CleanViewTask => {
125    if (at < index) return { ...task, status: 'done', percent: 100 }
126    if (at === index) return { ...task, status: 'active', percent, hasReported: true }
127    return task.status === 'active' ? { ...task, status: 'upcoming' } : task
128  })
129  const reported = updated[index]
130  if (percent >= 100 && reported !== undefined) {
131    updated[index] = { ...reported, status: 'done', percent: 100 }
132    const following = updated.findIndex((task, at) => at > index && task.status === 'upcoming')
133    const nextTask = updated[following]
134    if (nextTask !== undefined) updated[following] = { ...nextTask, status: 'active', percent: 0, hasReported: false }
135  }
136  return updated
137}
138
139type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed' }
140
141function todoTasks(todos: readonly Todo[], previous: readonly CleanViewTask[]): CleanViewTask[] {
142  return todos.map((todo, at) => {
143    const name = cleanName(todo.content)
144    const old = previous.find(task => sameName(task.name, name))
145    if (todo.status === 'completed') return step(name, 'done', `todo-${at}`)
146    if (todo.status === 'pending') return step(name, 'upcoming', `todo-${at}`)
147    const kept = old?.status === 'active' && old.hasReported
148    return { id: `todo-${at}`, name, status: 'active', percent: kept ? old.percent : 0, hasReported: kept }
149  })
150}
151
152/** One calm sentence for an API failure, by the kind Claude Code classifies it as. */
153function apiSentence(kind: string, details: string): string {
154  if (/prompt is too long|context (window|length)|too many tokens/i.test(details)) return 'type /compact and try again'
155  if (kind === 'rate_limit' || kind === 'billing_error') return 'you hit your usage limit, try again a little later'
156  if (kind === 'overloaded' || kind === 'server_error') return "Claude's servers are busy, try again in a minute"
157  if (/auth|oauth|login|credential/i.test(kind)) return 'type /login'
158  if (/network|connect|socket|timed? ?out|fetch failed|ENOTFOUND|ECONN/i.test(details)) return 'the internet connection dropped'
159  return 'something went wrong, try sending your message again'
160}
161
162function formatDuration(ms: number): string {
163  const seconds = Math.max(0, Math.round(ms / 1000))
164  const hours = Math.floor(seconds / 3600)
165  const minutes = Math.floor((seconds % 3600) / 60)
166  if (hours > 0) return `${hours}h ${minutes}m`
167  if (minutes > 0) return `${minutes}m ${seconds % 60}s`
168  return `${seconds}s`
169}
170
171function fit(text: string, width: number): string {
172  if (width <= 0) return ''
173  return text.length <= width ? text : text.slice(0, Math.max(0, width - 1)) + '…'
174}
175
176function meter(task: CleanViewTask, tick: number): string {
177  if (task.status === 'done') return FILLED.repeat(METER)
178  if (task.status === 'upcoming') return EMPTY.repeat(METER)
179  if (task.hasReported) {
180    const filled = Math.round(task.percent / 10)
181    return FILLED.repeat(filled) + EMPTY.repeat(METER - filled)
182  }
183  const at = (tick % (METER + 3)) - 2
184  return Array.from({ length: METER }, (_, cell) => (cell >= at && cell < at + 3 ? FILLED : EMPTY)).join('')
185}
186
187/** What the module keeps between events; a hot reload starts it over (the checklist itself is in $.state). */
188const runtime = {
189  ticker: null as Timer | null,
190  collapser: null as Timer | null,
191  failures: 0,
192  apiFailure: null as string | null,
193}
194
195const isEnabled = ($: Engine) => read($, enabledAtom)
196const getChecklist = ($: Engine) => read($, checklistAtom)
197
198/** Runs the 250ms animation clock only while a job is working or waiting on the person. */
199async function syncClock($: Engine) {
200  const list = await getChecklist($)
201  const isAnimated =
202    (await isEnabled($)) && list !== null && (list.phase === 'working' || list.phase === 'needsYou')
203  if (isAnimated && runtime.ticker === null) {
204    runtime.ticker = $.clock.every(TICK_MS, () => {
205      void update($, tickAtom, n => (n + 1) % 1_000_000).catch(() => undefined)
206    })
207  } else if (!isAnimated && runtime.ticker !== null) {
208    runtime.ticker.cancel()
209    runtime.ticker = null
210  }
211}
212
213async function edit($: Engine, change: (list: CleanViewChecklist) => CleanViewChecklist) {
214  await update($, checklistAtom, list => (list === null ? list : change(list)))
215  await syncClock($)
216}
217
218async function replace($: Engine, list: CleanViewChecklist | null) {
219  await update($, checklistAtom, () => list)
220  await syncClock($)
221}
222
223async function setEnabled($: Engine, isOn: boolean) {
224  await update($, enabledAtom, () => isOn)
225  await $.store.set(STORE_KEY, isOn)
226  $.ui.toast(isOn ? "Clean View on: Claude's technical steps are hidden" : 'Clean View off: showing everything Claude does')
227  await syncClock($)
228}
229
230function scheduleCollapse($: Engine, jobId: string) {
231  runtime.collapser?.cancel()
232  runtime.collapser = $.clock.after(COLLAPSE_MS, () => {
233    void edit($, list => (list.jobId === jobId && list.phase === 'done' ? { ...list, isCollapsed: true } : list)).catch(
234      () => undefined,
235    )
236  })
237}
238
239/** Asks Haiku for a short name for the job in the background; a newer job's start makes the answer moot. */
240function nameJob($: Engine, jobId: string, request: string) {
241  const prompt =
242    'Name this job in 2 to 6 plain words a non-technical person understands, starting with a verb ' +
243    '(like "Build my landing page"). No file names, code or quotes. Reply with the name only.\n\n' +
244    `Request: ${request.slice(0, 2000)}`
245  $.clock.after(0, () => {
246    void (async () => {
247      const ask = { model: 'haiku', prompt, maxTokens: 30 }
248      const reply = await $.model.complete({ ...ask, effort: 'low' }).catch(() => $.model.complete(ask))
249      if (!reply.isAnswered) return
250      const title = cleanTitle(reply.text)
251      if (title === FALLBACK_NAME) return
252      await edit($, list => (list.jobId === jobId ? { ...list, title } : list))
253    })().catch(() => undefined)
254  })
255}
256
257async function isGated($: Engine) {
258  if (!(await isEnabled($))) return false
259  const list = await getChecklist($)
260  return list !== null && isLive(list) && !list.isPlanned
261}
262
263export function registerCleanView(on: On): void {
264  on('session.start', async ($, e, next) => {
265    await $.tool.register({
266      name: 'plan_steps',
267      description:
268        'Lay out every step of the job up front for the checklist the person sees: 2 to 8 short plain-English names in order, each under 40 characters and starting with a verb. The first step starts right away. Call this first for every request.',
269      inputSchema: {
270        type: 'object',
271        properties: {
272          steps: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8, description: 'Step names, in order' },
273        },
274        required: ['steps'],
275      },
276      isDeferred: false,
277    })
278    await $.tool.register({
279      name: 'report_progress',
280      description:
281        'Report progress on the current step of the checklist. Use the step name exactly as planned and a percent from 0 to 100; report 100 the moment a step finishes and the next one starts by itself.',
282      inputSchema: {
283        type: 'object',
284        properties: {
285          task: { type: 'string', description: 'The step name, as planned' },
286          percent: { type: 'number', minimum: 0, maximum: 100 },
287        },
288        required: ['task', 'percent'],
289      },
290      isDeferred: false,
291    })
292    await $.command.register({
293      name: 'simple',
294      description: 'Turn Clean View on or off (hides the technical details while Claude works)',
295      argumentHint: '[on|off]',
296      immediate: true,
297    })
298    const saved = await $.store.get(STORE_KEY)
299    await update($, enabledAtom, () => saved !== false)
300    await syncClock($)
301    return next(e)
302  })
303
304  on('session.end', async ($, e, next) => {
305    if (e.reason === 'clear') await replace($, null)
306    return next(e)
307  }).catch(($, e, next) => next(e))
308
309  on('command.run', { command: 'simple' }, async ($, e) => {
310    const arg = e.args.trim().toLowerCase()
311    if (arg !== '' && arg !== 'on' && arg !== 'off') {
312      return { text: 'Type /simple on, /simple off, or just /simple to switch Clean View.' }
313    }
314    const isOn = arg === '' ? !(await isEnabled($)) : arg === 'on'
315    await setEnabled($, isOn)
316    return { text: isOn ? 'Clean View is on.' : 'Clean View is off.' }
317  })
318
319  on('prompt.compose', async ($, e, next) => {
320    const composed = await next(e)
321    if (!(await isEnabled($))) return composed
322    return { sections: [...composed.sections, { id: 'clean-view:steps', text: GUIDE, scope: 'session' as const }] }
323  }).catch(($, e, next) => next(e))
324
325  on('turn.start', async ($, e, next) => {
326    const text = e.text.trim()
327    if (text !== '' && !text.startsWith('/') && (await isEnabled($))) {
328      const list = await getChecklist($)
329      const isContinuing = list !== null && list.phase === 'needsYou' && list.tasks.some(task => task.status !== 'done')
330      runtime.failures = 0
331      runtime.apiFailure = null
332      if (isContinuing) {
333        await edit($, current => ({ ...current, phase: 'working', needsYouReason: null, stuckReason: null }))
334      } else {
335        runtime.collapser?.cancel()
336        await replace($, freshChecklist(e.turnId, await $.clock.now()))
337        nameJob($, e.turnId, text)
338      }
339    }
340    return next(e)
341  }).catch(($, e, next) => next(e))
342
343  on('tool.call', async ($, e, next) => {
344    const tool = String(e.tool)
345    const isMain = e.agentId === undefined
346    if (isMain && !ALWAYS_ALLOWED.has(tool) && (await isGated($))) return { deny: GATE_MESSAGE }
347
348    if (tool === PLAN_TOOL) {
349      const steps = (e as { steps?: unknown }).steps
350      const names = (Array.isArray(steps) ? steps : []).map(cleanName).slice(0, 8)
351      if (names.length === 0) return { deny: 'plan_steps needs 2 to 8 step names.' }
352      if (!isMain) return { result: 'Only the main conversation keeps the plan; carry on with your task.' }
353      const tasks = names.map((name, at) => step(name, at === 0 ? 'active' : 'upcoming', `s${at + 1}`))
354      const now = await $.clock.now()
355      await update($, checklistAtom, (list): CleanViewChecklist => ({
356        ...(list ?? freshChecklist(newId(), now)),
357        tasks,
358        isPlanned: true,
359        phase: 'working',
360        needsYouReason: null,
361        stuckReason: null,
362        finishedAt: null,
363        isCollapsed: false,
364      }))
365      await syncClock($)
366      return { result: `Planned ${names.length} steps. The first one has started.` }
367    }
368
369    if (tool === PROGRESS_TOOL) {
370      const input = e as { task?: unknown; percent?: unknown }
371      const raw = Number(input.percent)
372      const percent = Math.round(Math.min(100, Math.max(0, Number.isFinite(raw) ? raw : 0)))
373      if (isMain) {
374        const name = cleanName(input.task)
375        const now = await $.clock.now()
376        await update($, checklistAtom, (list): CleanViewChecklist => {
377          const base = list ?? { ...freshChecklist(newId(), now), tasks: [], isPlanned: true }
378          return {
379            ...base,
380            tasks: applyProgress(base.tasks, name, percent),
381            phase: base.phase === 'stuck' || base.phase === 'needsYou' ? 'working' : base.phase,
382            needsYouReason: null,
383            stuckReason: null,
384          }
385        })
386        runtime.failures = 0
387        await syncClock($)
388      }
389      return { result: `Progress noted: ${percent}%.` }
390    }
391
392    if (!isMain) return next(e)
393
394    if (tool === 'AskUserQuestion') {
395      await edit($, list => (isLive(list) ? { ...list, phase: 'needsYou', needsYouReason: ASKED_QUESTION } : list))
396    } else {
397      await edit($, list => (list.phase === 'needsYou' ? { ...list, phase: 'working', needsYouReason: null } : list))
398    }
399
400    const ran = await next(e)
401
402    const isError = ran.deny !== undefined || ran.isError === true
403    const said = ran.deny ?? ran.text ?? ''
404    const isRefusedByPerson = isError && /doesn't want to proceed|user (rejected|denied|declined)|was rejected/i.test(said)
405    if (isRefusedByPerson) runtime.failures = 0
406    else runtime.failures = isError ? runtime.failures + 1 : 0
407
408    await edit($, list => {
409      if (!isLive(list)) return list
410      let changed: CleanViewChecklist =
411        list.phase === 'needsYou' ? { ...list, phase: 'working', needsYouReason: null } : list
412      if (isRefusedByPerson) changed = { ...changed, phase: 'stuck', stuckReason: SAID_NO }
413      else if (runtime.failures >= 3) changed = { ...changed, phase: 'stuck', stuckReason: KEEPS_FAILING }
414      else if (!isError && changed.phase === 'stuck') changed = { ...changed, phase: 'working', stuckReason: null }
415
416      if (isError) return changed
417      if (tool === 'TodoWrite') {
418        const todos = (e as { todos?: Todo[] }).todos ?? []
419        if (todos.length > 0) changed = { ...changed, tasks: todoTasks(todos, changed.tasks), isPlanned: true }
420      } else if (tool === 'TaskCreate') {
421        const subject = (e as { subject?: string }).subject
422        const created = (ran.result as { task?: { id?: string } } | undefined)?.task?.id ?? newId()
423        const kept = changed.isPlanned ? changed.tasks : []
424        changed = { ...changed, tasks: [...kept, step(cleanName(subject), 'upcoming', `task-${created}`)], isPlanned: true }
425      } else if (tool === 'TaskUpdate') {
426        const input = e as { taskId?: string; subject?: string; status?: string }
427        const id = `task-${input.taskId ?? ''}`
428        const tasks = changed.tasks
429          .filter(task => !(task.id === id && input.status === 'deleted'))
430          .map((task): CleanViewTask => {
431            if (task.id !== id) return task
432            const named = input.subject === undefined ? task : { ...task, name: cleanName(input.subject) }
433            if (input.status === 'completed') return { ...named, status: 'done', percent: 100 }
434            if (input.status === 'in_progress') return { ...named, status: 'active' }
435            if (input.status === 'pending') return { ...named, status: 'upcoming' }
436            return named
437          })
438        changed = { ...changed, tasks }
439      }
440      return changed
441    })
442    return ran
443  }).catch(($, e, next) => next(e))
444
445  on('classic.Notification', async ($, e, next) => {
446    const reason =
447      e.notification_type === 'permission_prompt'
448        ? ASKED_OK
449        : e.notification_type === 'elicitation_dialog'
450          ? ASKED_QUESTION
451          : null
452    if (reason !== null && e.agent_id === undefined) {
453      await edit($, list => (isLive(list) ? { ...list, phase: 'needsYou', needsYouReason: reason } : list))
454    }
455    return next(e)
456  }).catch(($, e, next) => next(e))
457
458  on('classic.StopFailure', async ($, e, next) => {
459    if (e.agent_id === undefined) {
460      const sentence = apiSentence(e.error, e.error_details ?? '')
461      runtime.apiFailure = sentence
462      await edit($, list =>
463        list.phase === 'stopped' || list.phase === 'done' ? list : { ...list, phase: 'stuck', stuckReason: sentence },
464      )
465    }
466    return next(e)
467  }).catch(($, e, next) => next(e))
468
469  on('turn.complete', async ($, e, next) => {
470    if (e.agentId === undefined) {
471      const now = await $.clock.now()
472      let finishedJob: string | null = null
473      await edit($, list => {
474        if (!isLive(list)) return list
475        if (e.reason === 'error') {
476          return { ...list, phase: 'stuck', stuckReason: runtime.apiFailure ?? apiSentence('unknown', ''), finishedAt: now }
477        }
478        if (e.reason === 'refusal') return { ...list, phase: 'stuck', stuckReason: REFUSED, finishedAt: now }
479        if (e.reason === 'aborted') return { ...list, phase: 'stopped', finishedAt: now }
480        if (list.phase === 'stuck' && list.stuckReason === SAID_NO) return list
481        const hasUnfinished = list.isPlanned && list.tasks.some(task => task.status !== 'done')
482        if (hasUnfinished) return { ...list, phase: 'needsYou', needsYouReason: WAITING_REPLY, stuckReason: null }
483        finishedJob = list.jobId
484        return {
485          ...list,
486          phase: 'done',
487          tasks: list.tasks.map(task => ({ ...task, status: 'done' as const, percent: 100 })),
488          needsYouReason: null,
489          stuckReason: null,
490          finishedAt: now,
491          isCollapsed: false,
492        }
493      })
494      runtime.apiFailure = null
495      runtime.failures = 0
496      if (finishedJob !== null) scheduleCollapse($, finishedJob)
497    }
498    return next(e)
499  }).catch(($, e, next) => next(e))
500
501  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
502    if (!(await isEnabled($))) return next(e)
503    const { Box } = $.ui.resolve(e)
504    return <Box display="none" />
505  })
506
507  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
508    if (!(await isEnabled($))) return next(e)
509    const { Box } = $.ui.resolve(e)
510    return <Box display="none" />
511  })
512
513  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
514    if (!(await isEnabled($))) return next(e)
515    const { Box } = $.ui.resolve(e)
516    return <Box display="none" />
517  })
518
519  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) =>
520    (await isEnabled($)) ? next({ ...e, props: { ...e.props, hint: '' } }) : next(e),
521  )
522
523  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
524    if (e.props.hasSurvey) return next(e)
525    const { Box, Text, Button } = $.ui.resolve(e)
526    const isOn = await isEnabled($)
527    const list = isOn ? await getChecklist($) : null
528    const isAnimated = list !== null && (list.phase === 'working' || list.phase === 'needsYou')
529    const tick = isAnimated ? await read($, tickAtom) : 0
530    const now = list !== null && list.finishedAt === null ? await $.clock.now() : 0
531    const below = await next(e)
532
533    const columns = Math.max(24, e.props.bodyColumns)
534    const buttonLabel = isOn ? '● Clean View: ON' : '○ Clean View: OFF'
535    const room = Math.max(8, columns - buttonLabel.length - 5)
536
537    const header = () => {
538      if (list === null) return <Text> </Text>
539      const took = formatDuration((list.finishedAt ?? now) - list.startedAt)
540      if (list.phase === 'needsYou') {
541        return (
542          <Text>
543            <Text inverse bold color="warning"> Needs you </Text>
544            <Text> {fit(list.needsYouReason ?? WAITING_REPLY, room - 12)}</Text>
545          </Text>
546        )
547      }
548      if (list.phase === 'stuck') {
549        return <Text color="warning">{fit(`⚠ Stuck: ${list.stuckReason ?? KEEPS_FAILING}`, room)}</Text>
550      }
551      if (list.phase === 'stopped') {
552        return <Text dimColor>{fit(`■ Stopped · ${list.title} · you pressed Esc`, room)}</Text>
553      }
554      if (list.phase === 'done') {
555        return (
556          <Text>
557            <Text color="success" bold>✓ All done</Text>
558            <Text>{fit(` · ${list.title} · took ${took}`, room - 10)}</Text>
559          </Text>
560        )
561      }
562      const time = ` · ${took}`
563      return (
564        <Text>
565          <Text bold>{fit(list.title, room - time.length)}</Text>
566          <Text dimColor>{time}</Text>
567        </Text>
568      )
569    }
570
571    const rows = () => {
572      if (list === null || (list.phase === 'done' && list.isCollapsed)) return []
573      // The name column is as wide as the longest name, so the bars sit close to the names.
574      const longest = Math.max(...list.tasks.map(task => task.name.length), 4)
575      const nameWidth = Math.max(4, Math.min(longest, MAX_NAME, columns - 2 - 1 - METER - 2 - LABEL_WIDTH))
576      const firstUpcoming = list.tasks.findIndex(task => task.status === 'upcoming')
577      return list.tasks.map((task, at) => {
578        const name = fit(task.name, nameWidth).padEnd(nameWidth)
579        const bar = meter(task, tick)
580        if (task.status === 'done') {
581          return (
582            <Box key={`row-${task.id}`}>
583              <Text>
584              <Text color="success">✓ </Text>
585              <Text dimColor>{name} </Text>
586              <Text color="success">{bar}</Text>
587              <Text dimColor>  Done</Text>
588              </Text>
589            </Box>
590          )
591        }
592        if (task.status === 'active') {
593          const mark = list.phase === 'needsYou' ? '‖ ' : '▶ '
594          const label = task.hasReported ? `${task.percent}%` : 'Working'
595          return (
596            <Box key={`row-${task.id}`}>
597              <Text>
598              <Text color="claude">{mark}</Text>
599              <Text bold>{name} </Text>
600              <Text color="claude">{bar}</Text>
601              <Text>  {label}</Text>
602              </Text>
603            </Box>
604          )
605        }
606        return (
607          <Box key={`row-${task.id}`}>
608            <Text dimColor>{`○ ${name} ${bar}  ${at === firstUpcoming ? 'Next' : 'Up next'}`}</Text>
609          </Box>
610        )
611      })
612    }
613
614    return (
615      <Box flexDirection="column">
616        <Box key="clean-view-header" flexDirection="row">
617          <Box flexGrow={1} flexShrink={1}>
618            {header()}
619          </Box>
620          <Button
621            key="clean-view-toggle"
622            label={buttonLabel}
623            variant={isOn ? 'primary' : 'secondary'}
624            onPress={() => setEnabled($, !isOn)}
625          />
626        </Box>
627        {rows()}
628        {below}
629      </Box>
630    )
631  }).catch(($, e, next) => next(e))
632}
633
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' | 'needsYou' | 'stuck' | 'stopped' | 'done'
12
13export type CleanViewChecklist = {
14  jobId: string
15  title: string
16  phase: CleanViewPhase
17  tasks: CleanViewTask[]
18  isPlanned: boolean
19  needsYouReason: string | null
20  stuckReason: string | null
21  startedAt: number
22  finishedAt: number | null
23  isCollapsed: 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