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

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.
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.
| Mod | Command | What it does | |
|---|---|---|---|
roberts-toolbox | /toolbox | One 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 | /better | Turns 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 | /challenge | One small Claude Code exercise a day, with a streak and a year of challenges that never repeat. | |
work-report | /report | Counts 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, /keepwarm | A 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 | /ship | Repo 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-doctor | Checks 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. |
Several of these started as other people's ideas, and they deserve the credit:
The mods themselves were built with Claude Code.
MIT.
hooks/register.tsx 8 lines1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5export const register: Register = on => {
6 registerCleanView(on)
7}
8hooks/clean-view.tsx 633 lines1import { 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}
633types/index.d.ts 35 lines1export 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