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

A Claude Code mod that makes Claude Code calm and friendly for people who aren't technical.
While Claude works, tool calls, file diffs and command output are hidden. Instead, a progress box sits above the prompt so you can always see the plan, what's happening now and how far along it is:

The same box, drawn in text:
╭──────────────────────────────────────────────────────────────╮
│ ✦ Build my landing page 1m 12s │
│ Step 2 of 4 ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░ 40% │
│ ✓ Read your brand notes ━━━━━━━━━━ Done │
│ ● Build the pricing section ━━━━━━┄┄┄┄ 60% │
│ ○ Add the contact form ┄┄┄┄┄┄┄┄┄┄ Next │
│ ○ Polish the footer ┄┄┄┄┄┄┄┄┄┄ Up next │
╰──────────────────────────────────────────────────────────────╯
Claude's written replies stay visible as normal.
The frame's colour tells you how things are going:
| Frame | What it means |
|---|---|
| Pink | Claude is working |
| Amber | Needs you: Claude is waiting for your OK or has a question (the current step shows ‖), or it's stuck and says why in one plain sentence |
| Grey | Stopped: you pressed Esc |
| Green | All done, with how long it took. After 5 seconds the box shrinks to one line. |
Type this at the prompt of a Claude Code terminal session:
/plugin install clean-view --marketplace mugenracerman/clean-view
Answer y to add the marketplace, then press Enter to install it for your user.
/simple on, /simple off, or just /simple to flip it (/clean-view on and off do the same, and are what the Damon's Tools toggle uses).Your choice is remembered after a restart. When it's off, the box goes away and every hidden row comes back.
claude plugin marketplace update clean-view
claude plugin update clean-view@clean-view
Then type /reload-plugins in Claude Code, or restart it.
claude plugin test .
MIT. See LICENSE.
hooks/register.tsx 9 lines1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5// One entry point; more mods can be registered here beside Clean View.
6export const register: Register = on => {
7 registerCleanView(on)
8}
9hooks/clean-view.tsx 718 lines1import { atom, read, update } from 'claude-code'
2import type { ModelCompleteRequest, ModelCompleteResult, On, Timer } from 'claude-code'
3
4import type { CleanViewChecklist, CleanViewTask } from '../types'
5import {
6 addTask,
7 clampPercent,
8 cleanName,
9 errorSentence,
10 finishAll,
11 fit,
12 formatDuration,
13 fromTodos,
14 meterCells,
15 overallPercent,
16 stepLabel,
17 titleColor,
18 newJob,
19 planSteps,
20 reportProgress,
21 updateTask,
22} from './checklist'
23
24type Checklist = CleanViewChecklist | null
25
26// Damon's Tools (it registers /damons-tools) shows every plugin's switch in its
27// own dashboard and keeps the footer to one button; while it is installed this
28// plugin leaves its footer toggle out. Looked up at most every few seconds.
29let dashboardSeen: { at: number; isThere: boolean } | undefined
30async function hasDashboard($: {
31 clock: { now: () => Promise<number> }
32 command: { list: () => Promise<readonly { name: string }[]> }
33}): Promise<boolean> {
34 // Where the lookup cannot run, keep the toggle.
35 try {
36 const now = await $.clock.now()
37 if (dashboardSeen && now - dashboardSeen.at < 5000) return dashboardSeen.isThere
38 const isThere = (await $.command.list()).some(c => c.name === 'damons-tools')
39 dashboardSeen = { at: now, isThere }
40 return isThere
41 } catch {
42 return false
43 }
44}
45
46export const PLAN_TOOL = 'mcp__clean-view__plan_steps'
47export const PROGRESS_TOOL = 'mcp__clean-view__report_progress'
48
49const STORE_KEY = 'cleanViewEnabled'
50const SWITCH_KEY = 'isOn'
51const TICK_MS = 250
52const COLLAPSE_MS = 5000
53const FAILS_BEFORE_STUCK = 3
54const LABEL_CELLS = 7
55
56// The footer toggle's colours, the same as Prompt Upgrade's and Auto Model's.
57const LINE = '#262B33'
58const MUTED = '#7D8590'
59const TEXT = '#E6EDF3'
60
61// The progress box's colours.
62const PINK = '#E85D9E'
63const GREEN = '#3DD68C'
64const AMBER = '#E3B341'
65const SWEEP = '#6C78D8'
66const FRAME: Record<CleanViewChecklist['phase'], string> = {
67 working: PINK,
68 'needs-you': AMBER,
69 stuck: AMBER,
70 stopped: MUTED,
71 done: GREEN,
72}
73
74const ALWAYS_ALLOWED = new Set([
75 'ToolSearch',
76 'TodoWrite',
77 'TaskCreate',
78 'TaskUpdate',
79 'AskUserQuestion',
80 PLAN_TOOL,
81 PROGRESS_TOOL,
82])
83
84const NEEDS_OK = 'Claude needs your OK to continue'
85const HAS_QUESTION = 'Claude has a question for you'
86const WAITING_FOR_REPLY = 'Claude is waiting for your reply'
87const SAID_NO = 'you said no to a step, so Claude paused'
88const KEEPS_FAILING = 'a step keeps failing, Claude is trying another way'
89const REFUSED = "Claude couldn't help with that request"
90
91const REJECTED = /doesn't want to proceed|was rejected|user (?:rejected|denied|declined)|permission (?:was )?denied/i
92
93const GATE_MESSAGE =
94 `Clean View: lay out the plan first. Call ${PLAN_TOOL} with 2 to 8 plain-English step names ` +
95 '(load it with ToolSearch if it is deferred), then try this again.'
96
97const GUIDE = [
98 '# Clean View',
99 'The person is using Clean View. They do not see tool calls, file changes or command output: only your written replies and a checklist of your plan above the prompt.',
100 `- For every request, even a quick question, call \`${PLAN_TOOL}\` first, before any other tool. Load it with ToolSearch if it is deferred. Other tools are refused until a plan exists.`,
101 '- 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".',
102 '- Never put file paths, file names, commands, code or tool names in a step name.',
103 `- Call \`${PROGRESS_TOOL}\` with the step's name and a percent as real progress happens, and with 100 the moment a step finishes; the next step then starts by itself.`,
104 '- If this session has TodoWrite or TaskCreate, you may use your to-do list as the plan instead. The same naming rules apply.',
105].join('\n')
106
107const enabled = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
108const checklist = atom({ plugin: 'clean-view', key: 'checklist' } as const, null)
109const tick = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
110
111// The module's own variables start over on a hot reload; everything drawn lives in $.state.
112let ticker: Timer | undefined
113let failures = 0
114let lastApiError: { kind: string | null; details: string | null } | null = null
115
116const isLive = (list: Checklist) => list !== null && (list.phase === 'working' || list.phase === 'needs-you')
117
118/**
119 * What the shared helpers need from the engine, as closures each hook builds
120 * over its own $ (a hooks module never passes $ itself around).
121 */
122type IO = {
123 write: (fn: (list: Checklist) => Checklist) => Promise<unknown>
124 isLive: () => Promise<boolean>
125 every: (ms: number, fn: () => void) => Timer
126 bump: () => Promise<unknown>
127}
128
129/** The 250ms clock runs only while a job is working or waiting on the person. */
130async function keepTicking(io: IO): Promise<void> {
131 if (ticker !== undefined || !(await io.isLive())) return
132 ticker = io.every(TICK_MS, () => {
133 void (async () => {
134 if (!(await io.isLive())) {
135 ticker?.cancel()
136 ticker = undefined
137 return
138 }
139 await io.bump()
140 })()
141 })
142}
143
144async function change(io: IO, fn: (list: Checklist) => Checklist): Promise<void> {
145 await io.write(fn)
146 await keepTicking(io)
147}
148
149const whileOpen = (fn: (list: CleanViewChecklist) => CleanViewChecklist) => (list: Checklist) =>
150 list === null || list.finishedAt !== null ? list : fn(list)
151
152const backToWork = (list: CleanViewChecklist): CleanViewChecklist => ({
153 ...list,
154 phase: 'working',
155 needsYouReason: null,
156 stuckReason: null,
157})
158
159const toastFor = (value: boolean) =>
160 value ? 'Clean View is on: tool details are hidden' : 'Clean View is off: showing everything'
161
162/** Asks Haiku for a 2 to 6 word name, in the background; a newer job wins. */
163function nameJob(
164 complete: (request: ModelCompleteRequest) => Promise<ModelCompleteResult>,
165 write: IO['write'],
166 request: string,
167 startedAt: number,
168): void {
169 void (async () => {
170 const reply = await complete({
171 model: 'haiku',
172 effort: 'low',
173 maxTokens: 30,
174 timeoutMs: 20000,
175 system:
176 'You name tasks for a progress checklist read by non-technical people. Reply with the name only: 2 to 6 plain English words that start with a verb, like "Build my landing page". No file names, code, quotes or ending punctuation.',
177 prompt: `Name this request:\n\n${request.slice(0, 2000)}`,
178 })
179 if (!reply.isAnswered) return
180 const line = (reply.text.split('\n').find(l => l.trim() !== '') ?? '').replace(
181 /^[\s"'“”‘’*#-]+|[\s"'“”‘’*.!?:]+$/g,
182 '',
183 )
184 const title = cleanName(line.split(/\s+/).slice(0, 6).join(' '), '')
185 if (title === '') return
186 await write(list => (list !== null && list.startedAt === startedAt ? { ...list, title } : list))
187 })().catch(() => undefined)
188}
189
190export function registerCleanView(on: On): void {
191 on('session.start', async ($, e, next) => {
192 const io: IO = {
193 write: fn => update($, checklist, fn),
194 isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
195 every: (ms, fn) => $.clock.every(ms, fn),
196 bump: () => update($, tick, n => n + 1),
197 }
198 const stored = await $.store.get(STORE_KEY)
199 await $.state.set({ plugin: 'clean-view', key: 'cleanViewEnabled' }, stored !== false)
200 // `isOn` is the switch a dashboard reads; it mirrors Clean View's own.
201 await $.store.set(SWITCH_KEY, stored !== false)
202 await $.tool.register({
203 name: 'plan_steps',
204 description:
205 'Lay out every step of the job before you start, in order: 2 to 8 short names in plain English a non-technical person understands, each under 40 characters and starting with a verb ("Build the pricing section"). Never put file paths, file names, commands, code or tool names in a step name. The first step starts right away.',
206 inputSchema: {
207 type: 'object',
208 properties: {
209 steps: {
210 type: 'array',
211 items: { type: 'string' },
212 minItems: 2,
213 maxItems: 8,
214 description: 'The step names, in order.',
215 },
216 },
217 required: ['steps'],
218 },
219 isDeferred: false,
220 })
221 await $.tool.register({
222 name: 'report_progress',
223 description:
224 "Report progress on the current step: its name as planned and how far along it is, 0 to 100. Report 100 the moment a step finishes; the next one starts by itself. Reporting a later step checks off the ones before it, and a name that isn't in the plan becomes a new step.",
225 inputSchema: {
226 type: 'object',
227 properties: {
228 task: { type: 'string', description: 'The step name, as planned.' },
229 percent: { type: 'number', minimum: 0, maximum: 100, description: 'How far along the step is.' },
230 },
231 required: ['task', 'percent'],
232 },
233 isDeferred: false,
234 })
235 await $.command.register({
236 name: 'simple',
237 description: 'Turn Clean View on or off (with no argument, flip it)',
238 argumentHint: '[on|off]',
239 immediate: true,
240 })
241 // The switch under the plugin's own name, which Damon's Tools flips.
242 await $.command.register({
243 name: 'clean-view',
244 description: 'Turn Clean View on or off',
245 argumentHint: 'on | off',
246 immediate: true,
247 })
248 await keepTicking(io)
249 return next(e)
250 })
251
252 on('command.run', { command: 'simple' }, async ($, e) => {
253 const io: IO = {
254 write: fn => update($, checklist, fn),
255 isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
256 every: (ms, fn) => $.clock.every(ms, fn),
257 bump: () => update($, tick, n => n + 1),
258 }
259 const arg = e.args.trim().toLowerCase()
260 const current = await read($, enabled)
261 const want = arg === 'on' ? true : arg === 'off' ? false : arg === '' ? !current : null
262 if (want === null) return { text: 'Use /simple on, /simple off, or just /simple to flip it.' }
263 await $.state.set({ plugin: 'clean-view', key: 'cleanViewEnabled' }, want)
264 await $.store.set(STORE_KEY, want)
265 await $.store.set(SWITCH_KEY, want)
266 $.ui.toast(toastFor(want))
267 await keepTicking(io)
268 return { text: want ? 'Clean View is on.' : 'Clean View is off. Everything is visible again.' }
269 })
270
271 on('command.run', { command: 'clean-view' }, async ($, e) => {
272 const io: IO = {
273 write: fn => update($, checklist, fn),
274 isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
275 every: (ms, fn) => $.clock.every(ms, fn),
276 bump: () => update($, tick, n => n + 1),
277 }
278 const arg = e.args.trim().toLowerCase()
279 if (arg !== 'on' && arg !== 'off') {
280 return { text: `Clean View is ${(await read($, enabled)) ? 'on' : 'off'}. Use /clean-view on or off.` }
281 }
282 const want = arg === 'on'
283 await $.state.set({ plugin: 'clean-view', key: 'cleanViewEnabled' }, want)
284 await $.store.set(STORE_KEY, want)
285 await $.store.set(SWITCH_KEY, want)
286 $.ui.toast(toastFor(want))
287 await keepTicking(io)
288 return { text: want ? 'Clean View is on.' : 'Clean View is off. Everything is visible again.' }
289 })
290
291 // A toggle in the prompt footer, left of the other mods' toggles and the
292 // engine's mode labels (all drawn by `next`); a click flips Clean View.
293 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
294 if (await hasDashboard($)) return next(e)
295 const { Box, Button, Text } = $.ui.resolve(e)
296 const isOn = await read($, enabled)
297 const label = `Clean View: ${isOn ? 'On' : 'Off'}`
298 const flip = async () => {
299 const io: IO = {
300 write: fn => update($, checklist, fn),
301 isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
302 every: (ms, fn) => $.clock.every(ms, fn),
303 bump: () => update($, tick, n => n + 1),
304 }
305 const value = await update($, enabled, one => !one)
306 await $.store.set(STORE_KEY, value)
307 await $.store.set(SWITCH_KEY, value)
308 $.ui.toast(toastFor(value))
309 await keepTicking(io)
310 }
311 const theirs = await next(e).catch(() => null)
312 return (
313 <Box flexDirection="row" columnGap={1}>
314 {e.surface === 'terminal' ? (
315 <Button key="clean-view-toggle" plain onPress={flip}>
316 <Text backgroundColor={LINE} color={isOn ? TEXT : MUTED}>{` ${label} `}</Text>
317 </Button>
318 ) : (
319 <Button key="clean-view-toggle" label={label} plain onPress={flip} />
320 )}
321 {theirs}
322 </Box>
323 )
324 })
325
326 // While Clean View is on, every request gets a plain-English plan first.
327 on('prompt.compose', async ($, e, next) => {
328 const composed = await next(e)
329 if (!(await read($, enabled))) return composed
330 return {
331 ...composed,
332 sections: [...composed.sections, { id: 'clean-view:plain-steps', text: GUIDE, scope: 'session' as const }],
333 }
334 })
335
336 on('tool.call', { tool: PLAN_TOOL }, async ($, e) => {
337 const io: IO = {
338 write: fn => update($, checklist, fn),
339 isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
340 every: (ms, fn) => $.clock.every(ms, fn),
341 bump: () => update($, tick, n => n + 1),
342 }
343 if (e.agentId !== undefined) return { result: 'Only the main agent keeps the plan. Carry on.' }
344 const raw = (e as { steps?: unknown }).steps
345 const names = Array.isArray(raw) ? raw.slice(0, 8).map(step => cleanName(step)) : []
346 if (names.length === 0) {
347 return { deny: 'plan_steps needs `steps`: a list of 2 to 8 short plain-English step names.' }
348 }
349 const now = await $.clock.now()
350 failures = 0
351 await change(io, list => planSteps(list, names, now))
352 return { result: `Planned ${names.length} steps. The first one has started.` }
353 })
354
355 on('tool.call', { tool: PROGRESS_TOOL }, async ($, e) => {
356 const io: IO = {
357 write: fn => update($, checklist, fn),
358 isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
359 every: (ms, fn) => $.clock.every(ms, fn),
360 bump: () => update($, tick, n => n + 1),
361 }
362 if (e.agentId !== undefined) return { result: 'Only the main agent keeps the plan. Carry on.' }
363 const input = e as { task?: unknown; percent?: unknown }
364 const percent = clampPercent(input.percent)
365 const name = cleanName(input.task)
366 const now = await $.clock.now()
367 await change(io, list => reportProgress(list, name, percent, now))
368 return { result: `Progress noted: ${percent}%.` }
369 })
370
371 // The gate, "Needs you" for questions, and the checklist built from what Claude already does.
372 on('tool.call', async ($, e, next) => {
373 if (e.agentId !== undefined) return next(e)
374 const tool = String(e.tool)
375 if (tool === PLAN_TOOL || tool === PROGRESS_TOOL) return next(e)
376 const io: IO = {
377 write: fn => update($, checklist, fn),
378 isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
379 every: (ms, fn) => $.clock.every(ms, fn),
380 bump: () => update($, tick, n => n + 1),
381 }
382
383 const isOn = await read($, enabled)
384 const before = await read($, checklist)
385 if (isOn && !ALWAYS_ALLOWED.has(tool) && !(before?.isPlanned ?? false)) {
386 return { deny: GATE_MESSAGE }
387 }
388
389 if (before?.phase === 'needs-you') await change(io, whileOpen(backToWork))
390 if (tool === 'AskUserQuestion') {
391 await change(io, whileOpen(list => ({ ...list, phase: 'needs-you', needsYouReason: HAS_QUESTION })))
392 }
393
394 const ran = await next(e)
395
396 const now = await $.clock.now()
397 const after = await read($, checklist)
398 if (after?.phase === 'needs-you' && after.needsYouReason !== WAITING_FOR_REPLY) {
399 await change(io, whileOpen(backToWork))
400 }
401 if (ran.deny !== undefined) return ran
402
403 if (ran.isError === true) {
404 if (REJECTED.test(ran.text ?? '')) {
405 failures = 0
406 await change(io, whileOpen(list => ({ ...list, phase: 'stuck', stuckReason: SAID_NO, needsYouReason: null })))
407 } else {
408 failures += 1
409 if (failures >= FAILS_BEFORE_STUCK) {
410 await change(io, whileOpen(list => ({ ...list, phase: 'stuck', stuckReason: KEEPS_FAILING })))
411 }
412 }
413 return ran
414 }
415
416 failures = 0
417 if ((await read($, checklist))?.phase === 'stuck') await change(io, whileOpen(backToWork))
418
419 if (e.tool === 'TodoWrite') {
420 const todos = e.todos
421 await change(io, list => fromTodos(list, todos, now))
422 } else if (e.tool === 'TaskCreate') {
423 const made = ran.result as { task?: { id?: unknown } } | undefined
424 const id = typeof made?.task?.id === 'string' ? made.task.id : `task-${now}`
425 const name = cleanName(e.subject)
426 await change(io, list => addTask(list, id, name, now))
427 } else if (e.tool === 'TaskUpdate') {
428 const { taskId, status, subject } = e
429 const name = subject === undefined ? undefined : cleanName(subject)
430 await change(io, list => updateTask(list, taskId, { status, name }, now))
431 }
432 return ran
433 })
434
435 // A permission prompt or a question for the person.
436 on('classic.Notification', async ($, e, next) => {
437 const io: IO = {
438 write: fn => update($, checklist, fn),
439 isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
440 every: (ms, fn) => $.clock.every(ms, fn),
441 bump: () => update($, tick, n => n + 1),
442 }
443 if (e.notification_type === 'permission_prompt' || e.notification_type === 'elicitation_dialog') {
444 const reason = e.notification_type === 'permission_prompt' ? NEEDS_OK : HAS_QUESTION
445 await change(io, whileOpen(list => ({ ...list, phase: 'needs-you', needsYouReason: reason })))
446 }
447 return next(e)
448 })
449
450 // The kind of API error, which turn.complete does not carry.
451 on('classic.StopFailure', async ($, e, next) => {
452 lastApiError = { kind: e.error, details: e.error_details ?? null }
453 const list = await read($, checklist)
454 if (list?.phase === 'stuck' && list.finishedAt !== null) {
455 const stuckReason = errorSentence(e.error, e.error_details ?? null)
456 await update($, checklist, l => (l === null ? l : { ...l, stuckReason }))
457 }
458 return next(e)
459 })
460
461 on('turn.start', async ($, e, next) => {
462 const io: IO = {
463 write: fn => update($, checklist, fn),
464 isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
465 every: (ms, fn) => $.clock.every(ms, fn),
466 bump: () => update($, tick, n => n + 1),
467 }
468 const text = e.text.trim()
469 const list = await read($, checklist)
470 const isOn = await read($, enabled)
471 failures = 0
472 lastApiError = null
473 const isRealPrompt = text !== '' && !text.startsWith('/')
474
475 if (isRealPrompt && isOn && list?.phase !== 'needs-you') {
476 const now = await $.clock.now()
477 await change(io, () => newJob(now))
478 nameJob(request => $.model.complete(request), io.write, text, now)
479 } else if (list !== null && (list.phase === 'needs-you' || list.finishedAt === null)) {
480 // The person replied, or the job goes on.
481 await change(io, l => (l === null ? l : { ...backToWork(l), finishedAt: null, isCollapsed: false }))
482 }
483 return next(e)
484 })
485
486 on('turn.complete', async ($, e, next) => {
487 if (e.agentId !== undefined) return next(e)
488 const list = await read($, checklist)
489 if (list === null || list.finishedAt !== null) return next(e)
490 const io: IO = {
491 write: fn => update($, checklist, fn),
492 isLive: async () => (await read($, enabled)) && isLive(await read($, checklist)),
493 every: (ms, fn) => $.clock.every(ms, fn),
494 bump: () => update($, tick, n => n + 1),
495 }
496
497 const now = await $.clock.now()
498 let outcome: CleanViewChecklist
499 if (e.reason === 'error') {
500 const reason = errorSentence(lastApiError?.kind ?? null, lastApiError?.details ?? null)
501 outcome = { ...list, phase: 'stuck', stuckReason: reason, needsYouReason: null, finishedAt: now }
502 } else if (e.reason === 'refusal') {
503 outcome = { ...list, phase: 'stuck', stuckReason: REFUSED, needsYouReason: null, finishedAt: now }
504 } else if (e.reason === 'aborted') {
505 outcome =
506 list.stuckReason === SAID_NO
507 ? { ...list, phase: 'stuck', needsYouReason: null, finishedAt: now }
508 : { ...list, phase: 'stopped', needsYouReason: null, stuckReason: null, finishedAt: now }
509 } else if (list.isPlanned && list.tasks.some(t => t.status !== 'done')) {
510 outcome = { ...list, phase: 'needs-you', needsYouReason: WAITING_FOR_REPLY, stuckReason: null }
511 } else {
512 outcome = {
513 ...list,
514 phase: 'done',
515 tasks: finishAll(list.tasks),
516 needsYouReason: null,
517 stuckReason: null,
518 finishedAt: now,
519 }
520 $.clock.after(COLLAPSE_MS, () => {
521 void update($, checklist, l =>
522 l !== null && l.phase === 'done' && l.finishedAt === now ? { ...l, isCollapsed: true } : l,
523 )
524 })
525 }
526 await change(io, () => outcome)
527 return next(e)
528 })
529
530 // Hide the technical rows while Clean View is on; Claude's replies stay.
531 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
532 if (!(await read($, enabled))) return next(e)
533 const { Box } = $.ui.resolve(e)
534 return <Box display="none" />
535 })
536
537 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
538 if (!(await read($, enabled))) return next(e)
539 const { Box } = $.ui.resolve(e)
540 return <Box display="none" />
541 })
542
543 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
544 if (!(await read($, enabled))) return next(e)
545 const { Box } = $.ui.resolve(e)
546 return <Box display="none" />
547 })
548
549 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
550 if (!(await read($, enabled))) return next(e)
551 return next({ ...e, props: { ...e.props, hint: '' } })
552 })
553
554 // The checklist band; the on/off toggle lives in the prompt footer.
555 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
556 if (e.props.hasSurvey) return next(e)
557
558 const isOn = await read($, enabled)
559 const list = isOn ? await read($, checklist) : null
560 // Nothing to show: leave the band to whatever else draws there.
561 if (list === null) return next(e)
562
563 const { Box, Text } = $.ui.resolve(e)
564 const frame = list.phase === 'working' ? await read($, tick) : 0
565 const now = await $.clock.now()
566 // Whatever else draws in the band (other mods) stays beneath ours. The engine
567 // refuses its own drawing under a Box with a width, so the outer Box has none.
568 const below = await next(e).catch(() => null)
569 const width = Math.max(24, e.props.bodyColumns)
570
571 // The box: a coloured frame, the job's name and time, the overall bar, then the steps.
572 const inner = Math.max(20, width - 4)
573 const elapsed = formatDuration((list.finishedAt ?? now) - list.startedAt)
574 const words = list.title.split(' ')
575
576 const title = (
577 <Text bold>
578 {words.map((word, i) => (
579 <Text color={titleColor(i, words.length)}>{(i === 0 ? '' : ' ') + word}</Text>
580 ))}
581 </Text>
582 )
583
584 const headline = (() => {
585 switch (list.phase) {
586 case 'working':
587 return (
588 <Text wrap="truncate-end">
589 <Text color={PINK}>✦ </Text>
590 {title}
591 </Text>
592 )
593 case 'needs-you':
594 return (
595 <Text wrap="truncate-end">
596 <Text inverse bold color={AMBER}>
597 {' Needs you '}
598 </Text>
599 <Text> {list.needsYouReason ?? NEEDS_OK}</Text>
600 </Text>
601 )
602 case 'stuck':
603 return (
604 <Text wrap="truncate-end" color={AMBER}>
605 ⚠ Stuck: {list.stuckReason ?? KEEPS_FAILING}
606 </Text>
607 )
608 case 'stopped':
609 return (
610 <Text wrap="truncate-end">
611 <Text bold>■ Stopped</Text>
612 <Text color={MUTED}> · {list.title} · you pressed Esc</Text>
613 </Text>
614 )
615 case 'done':
616 return (
617 <Text wrap="truncate-end">
618 <Text bold color={GREEN}>
619 ✓ All done
620 </Text>
621 <Text color={MUTED}> · {list.title}</Text>
622 </Text>
623 )
624 }
625 })()
626 const timeText = list.phase === 'done' ? `took ${elapsed}` : elapsed
627 const header = (
628 <Box flexDirection="row" justifyContent="space-between" width={inner}>
629 <Box flexShrink={1}>{headline}</Box>
630 <Text color={MUTED}>{' ' + timeText}</Text>
631 </Box>
632 )
633
634 // Finished and five seconds old: one line.
635 if (list.phase === 'done' && list.isCollapsed) {
636 return (
637 <Box flexDirection="column">
638 <Box paddingX={2} width={width}>
639 {header}
640 </Box>
641 {below}
642 </Box>
643 )
644 }
645
646 const accent = list.phase === 'done' ? GREEN : PINK
647 const percent = overallPercent(list.tasks)
648 const steps = stepLabel(list.tasks)
649 const barWidth = Math.max(4, inner - steps.length - 1 - 5)
650 const filled = Math.round((barWidth * percent) / 100)
651 const overall = (
652 <Text wrap="truncate-end">
653 <Text color={MUTED}>{steps + ' '}</Text>
654 <Text backgroundColor={accent}>{' '.repeat(filled)}</Text>
655 <Text backgroundColor={LINE}>{' '.repeat(barWidth - filled)}</Text>
656 <Text bold color={accent}>
657 {` ${percent}%`.padStart(5)}
658 </Text>
659 </Text>
660 )
661
662 const cells = (lit: boolean[], color: string) =>
663 // Thin bars, so the meters of neighbouring rows don't merge into one block.
664 lit.map(isLit => <Text color={isLit ? color : LINE}>━</Text>)
665
666 const nameWidth = Math.max(6, Math.min(44, inner - 2 - 2 - 10 - 2 - LABEL_CELLS))
667 const firstUpcoming = list.tasks.findIndex(t => t.status === 'upcoming')
668 const row = (t: CleanViewTask, i: number) => {
669 const name = fit(t.name, nameWidth)
670 if (t.status === 'done') {
671 return (
672 <Text wrap="truncate-end">
673 <Text color={GREEN}>✓ </Text>
674 <Text color={MUTED}>{name}</Text>
675 {' '}
676 {cells(meterCells(100), GREEN)}
677 <Text color={MUTED}>{' Done'}</Text>
678 </Text>
679 )
680 }
681 if (t.status === 'active') {
682 const isWaiting = list.phase === 'needs-you'
683 const lit = t.hasReported ? meterCells(t.percent) : meterCells(0, frame)
684 return (
685 <Text wrap="truncate-end">
686 <Text color={isWaiting ? AMBER : PINK}>{isWaiting ? '‖ ' : '● '}</Text>
687 <Text bold>{name}</Text>
688 {' '}
689 {cells(lit, t.hasReported ? PINK : SWEEP)}
690 <Text bold color={PINK}>
691 {' ' + (t.hasReported ? `${t.percent}%` : 'Working')}
692 </Text>
693 </Text>
694 )
695 }
696 return (
697 <Text wrap="truncate-end">
698 <Text color={MUTED}>{'○ ' + name}</Text>
699 {' '}
700 {cells(meterCells(0), LINE)}
701 <Text color={MUTED}>{' ' + (i === firstUpcoming ? 'Next' : 'Up next')}</Text>
702 </Text>
703 )
704 }
705
706 return (
707 <Box flexDirection="column">
708 <Box flexDirection="column" borderStyle="round" borderColor={FRAME[list.phase]} paddingX={1} width={width}>
709 {header}
710 {overall}
711 {list.tasks.map(row)}
712 </Box>
713 {below}
714 </Box>
715 )
716 })
717}
718hooks/checklist.ts 270 lines1import type { CleanViewChecklist, CleanViewTask, CleanViewTaskStatus } from '../types'
2
3export const MAX_NAME = 40
4export const FALLBACK_NAME = 'Working on it'
5export const FALLBACK_TITLE = 'Working on your request'
6export const METER_CELLS = 10
7
8const CODE_FILE =
9 /\b[\w.-]+\.(?:tsx?|jsx?|mjs|cjs|mts|cts|py|rb|go|rs|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|ps1|bat|cmd|json|jsonc|ya?ml|toml|ini|cfg|conf|env|lock|md|mdx|html?|css|scss|sass|less|sql|xml|vue|svelte|astro|graphql|proto|tf|gradle|dart|lua|r|pl|ex|exs|zig)\b/gi
10
11/**
12 * The one cleaner every step name and job title passes through: no code, no
13 * paths, no file names, one line, a capital first letter, at most 40 characters.
14 */
15export function cleanName(raw: unknown, fallback: string = FALLBACK_NAME): string {
16 let text = typeof raw === 'string' ? raw : ''
17 text = text.replace(/`[^`]*`/g, ' ').replace(/`/g, ' ')
18 text = text
19 .split(/\s+/)
20 .filter(word => !word.includes('/') && !word.includes('\\'))
21 .join(' ')
22 text = text.replace(CODE_FILE, ' ')
23 text = text.replace(/\s+/g, ' ').trim()
24 text = text.replace(/^[\s,.;:–—-]+/, '').trim()
25 if (text === '') return fallback
26 text = text.charAt(0).toUpperCase() + text.slice(1)
27 if (text.length <= MAX_NAME) return text
28 const head = text.slice(0, MAX_NAME - 1)
29 const space = head.lastIndexOf(' ')
30 const cut = (space > 0 ? head.slice(0, space) : head).replace(/[\s,.;:–—-]+$/, '')
31 return `${cut}…`
32}
33
34/** Fits a name into `width` cells, ending in "…" when it is cut. */
35export function fit(text: string, width: number): string {
36 if (width <= 0) return ''
37 if (text.length <= width) return text.padEnd(width)
38 if (width === 1) return '…'
39 return `${text.slice(0, width - 1)}…`
40}
41
42/** The 10-cell meter: filled to `percent`, or a moving sweep when `sweep` is a frame number. */
43export function meter(percent: number, sweep?: number): string {
44 if (sweep !== undefined) {
45 const at = sweep % METER_CELLS
46 let cells = ''
47 for (let i = 0; i < METER_CELLS; i++) {
48 const distance = (i - at + METER_CELLS) % METER_CELLS
49 cells += distance < 3 ? '▓' : '░'
50 }
51 return cells
52 }
53 const filled = Math.max(0, Math.min(METER_CELLS, Math.round(clampPercent(percent) / 10)))
54 return '█'.repeat(filled) + '░'.repeat(METER_CELLS - filled)
55}
56
57export function clampPercent(value: unknown): number {
58 const n = typeof value === 'number' ? value : Number(value)
59 if (!Number.isFinite(n)) return 0
60 return Math.round(Math.max(0, Math.min(100, n)))
61}
62
63/** "45s", "1m 12s", "1h 3m". */
64export function formatDuration(ms: number): string {
65 const total = Math.max(0, Math.floor(ms / 1000))
66 const hours = Math.floor(total / 3600)
67 const minutes = Math.floor((total % 3600) / 60)
68 const seconds = total % 60
69 if (hours > 0) return `${hours}h ${minutes}m`
70 if (minutes > 0) return `${minutes}m ${seconds}s`
71 return `${seconds}s`
72}
73
74function task(id: string, name: string, status: CleanViewTaskStatus): CleanViewTask {
75 return { id, name, status, percent: status === 'done' ? 100 : 0, hasReported: false }
76}
77
78const sameName = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
79
80/** A new job with placeholder steps, shown until the real plan arrives. */
81export function newJob(now: number, title: string = FALLBACK_TITLE): CleanViewChecklist {
82 return {
83 title,
84 phase: 'working',
85 tasks: [
86 task('placeholder-1', 'Understand your request', 'active'),
87 task('placeholder-2', 'Plan the steps', 'upcoming'),
88 ],
89 needsYouReason: null,
90 stuckReason: null,
91 startedAt: now,
92 finishedAt: null,
93 isCollapsed: false,
94 isPlanned: false,
95 }
96}
97
98function base(list: CleanViewChecklist | null, now: number): CleanViewChecklist {
99 return list ?? newJob(now)
100}
101
102/** Keeps one active step at most, and starts the first upcoming one when none is. */
103function settle(tasks: CleanViewTask[]): CleanViewTask[] {
104 let seen = false
105 const one = tasks.map(t => {
106 if (t.status !== 'active') return t
107 if (seen) return { ...t, status: 'upcoming' as const }
108 seen = true
109 return t
110 })
111 if (seen) return one
112 const next = one.findIndex(t => t.status === 'upcoming')
113 if (next === -1) return one
114 return one.map((t, i) => (i === next ? { ...t, status: 'active' as const } : t))
115}
116
117/** plan_steps: every step laid out, the first one started. */
118export function planSteps(list: CleanViewChecklist | null, names: readonly string[], now: number): CleanViewChecklist {
119 const job = base(list, now)
120 const tasks = names.map((name, i) => task(`step-${i + 1}`, name, i === 0 ? 'active' : 'upcoming'))
121 return { ...job, tasks, isPlanned: true, phase: 'working', needsYouReason: null, stuckReason: null, isCollapsed: false, finishedAt: null }
122}
123
124/**
125 * report_progress: steps before the reported one are checked off; at 100 the
126 * step is checked off and the next starts; a name not in the plan is added
127 * where the unfinished work begins.
128 */
129export function reportProgress(list: CleanViewChecklist | null, name: string, percent: number, now: number): CleanViewChecklist {
130 const job = base(list, now)
131 let tasks = [...job.tasks]
132 let at = tasks.findIndex(t => sameName(t.name, name))
133 if (at === -1) {
134 const firstOpen = tasks.findIndex(t => t.status !== 'done')
135 at = firstOpen === -1 ? tasks.length : firstOpen
136 tasks.splice(at, 0, task(`step-${now}-${tasks.length + 1}`, name, 'active'))
137 }
138 const isFinished = percent >= 100
139 tasks = tasks.map((t, i) => {
140 if (i < at) return t.status === 'done' ? t : { ...t, status: 'done' as const, percent: 100 }
141 if (i === at) return { ...t, status: isFinished ? ('done' as const) : ('active' as const), percent, hasReported: true }
142 return t.status === 'active' ? { ...t, status: 'upcoming' as const } : t
143 })
144 return { ...job, tasks: settle(tasks) }
145}
146
147type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed' }
148
149const STATUS_OF: Record<Todo['status'], CleanViewTaskStatus> = {
150 completed: 'done',
151 in_progress: 'active',
152 pending: 'upcoming',
153}
154
155/** TodoWrite: the to-do list becomes the plan, percents kept for steps it already had. */
156export function fromTodos(list: CleanViewChecklist | null, todos: readonly Todo[], now: number): CleanViewChecklist {
157 const job = base(list, now)
158 const kept = job.isPlanned ? job.tasks : []
159 const tasks = todos.map((todo, i) => {
160 const name = cleanName(todo.content)
161 const status = STATUS_OF[todo.status] ?? 'upcoming'
162 const before = kept.find(t => sameName(t.name, name))
163 return {
164 id: `todo-${i + 1}`,
165 name,
166 status,
167 percent: status === 'done' ? 100 : (before?.percent ?? 0),
168 hasReported: before?.hasReported ?? false,
169 }
170 })
171 return { ...job, tasks: settle(tasks), isPlanned: tasks.length > 0 || job.isPlanned }
172}
173
174/** TaskCreate: one more step; the first replaces the placeholders. */
175export function addTask(list: CleanViewChecklist | null, id: string, name: string, now: number): CleanViewChecklist {
176 const job = base(list, now)
177 const tasks = job.isPlanned ? [...job.tasks] : []
178 tasks.push(task(id, name, 'upcoming'))
179 return { ...job, tasks: settle(tasks), isPlanned: true }
180}
181
182type TaskChange = {
183 status?: 'pending' | 'in_progress' | 'completed' | 'deleted'
184 name?: string
185}
186
187/** TaskUpdate: a step started, finished, renamed or removed. */
188export function updateTask(list: CleanViewChecklist | null, id: string, change: TaskChange, now: number): CleanViewChecklist {
189 const job = base(list, now)
190 if (change.status === 'deleted') {
191 return { ...job, tasks: settle(job.tasks.filter(t => t.id !== id)) }
192 }
193 let tasks = job.tasks.map(t => {
194 if (t.id !== id) return t
195 const renamed = change.name ? { ...t, name: change.name } : t
196 if (change.status === 'completed') return { ...renamed, status: 'done' as const, percent: 100 }
197 if (change.status === 'in_progress') return { ...renamed, status: 'active' as const }
198 if (change.status === 'pending') return { ...renamed, status: 'upcoming' as const }
199 return renamed
200 })
201 if (change.status === 'in_progress') {
202 tasks = tasks.map(t => (t.id !== id && t.status === 'active' ? { ...t, status: 'upcoming' as const } : t))
203 }
204 return { ...job, tasks: settle(tasks) }
205}
206
207/** Every step checked off. */
208export function finishAll(tasks: readonly CleanViewTask[]): CleanViewTask[] {
209 return tasks.map(t => ({ ...t, status: 'done' as const, percent: 100 }))
210}
211
212/** One calm sentence for an API error, by the kind Claude Code classifies it as. */
213export function errorSentence(kind: string | null, details: string | null): string {
214 const text = (details ?? '').toLowerCase()
215 if (/too long|context|too many tokens/.test(text)) return 'type /compact and try again'
216 if (/econn|enotfound|etimedout|network|fetch failed|socket|connection/.test(text)) return 'the internet connection dropped'
217 switch (kind) {
218 case 'rate_limit':
219 case 'billing_error':
220 return 'you hit your usage limit, try again a little later'
221 case 'overloaded':
222 case 'server_error':
223 return "Claude's servers are busy, try again in a minute"
224 case 'authentication_failed':
225 case 'oauth_org_not_allowed':
226 case 'cloud_credential_error':
227 return 'type /login'
228 case 'network':
229 return 'the internet connection dropped'
230 default:
231 return 'something went wrong, try again in a moment'
232 }
233}
234
235/** Which of the 10 meter cells are lit: filled to `percent`, or a 3-cell sweep at frame `sweep`. */
236export function meterCells(percent: number, sweep?: number): boolean[] {
237 const cells: boolean[] = []
238 if (sweep !== undefined) {
239 const at = sweep % METER_CELLS
240 for (let i = 0; i < METER_CELLS; i++) cells.push((i - at + METER_CELLS) % METER_CELLS < 3)
241 return cells
242 }
243 const filled = Math.round(clampPercent(percent) / 10)
244 for (let i = 0; i < METER_CELLS; i++) cells.push(i < filled)
245 return cells
246}
247
248/** The whole job's progress: each step counts the same, the current one by its percent. */
249export function overallPercent(tasks: readonly CleanViewTask[]): number {
250 if (tasks.length === 0) return 0
251 const sum = tasks.reduce((n, t) => n + (t.status === 'done' ? 100 : t.status === 'active' ? t.percent : 0), 0)
252 return Math.round(sum / tasks.length)
253}
254
255/** "Step 2 of 4": the current step, or the last one finished. */
256export function stepLabel(tasks: readonly CleanViewTask[]): string {
257 const active = tasks.findIndex(t => t.status === 'active')
258 const at = active === -1 ? tasks.filter(t => t.status === 'done').length : active + 1
259 return `Step ${Math.max(1, at)} of ${Math.max(1, tasks.length)}`
260}
261
262const TITLE_STOPS = ['#F2994A', '#F06292', '#C86DD7', '#8E7CF0', '#6FA8FF'] as const
263
264/** The colour of word `i` of `n` in the title's orange-to-blue fade. */
265export function titleColor(i: number, n: number): string {
266 if (n <= 1) return TITLE_STOPS[0]
267 const at = Math.round((i / (n - 1)) * (TITLE_STOPS.length - 1))
268 return TITLE_STOPS[Math.min(TITLE_STOPS.length - 1, Math.max(0, at))] ?? TITLE_STOPS[0]
269}
270types/index.d.ts 42 lines1// Clean View's shared state, readable by other mods under the 'clean-view' key.
2
3export type CleanViewTaskStatus = 'done' | 'active' | 'upcoming'
4
5export type CleanViewTask = {
6 id: string
7 name: string
8 status: CleanViewTaskStatus
9 /** 0 to 100. */
10 percent: number
11 /** True once Claude has reported a percent for this step. */
12 hasReported: boolean
13}
14
15export type CleanViewPhase = 'working' | 'needs-you' | 'stuck' | 'stopped' | 'done'
16
17export type CleanViewChecklist = {
18 /** The job's name in 2 to 6 plain words. */
19 title: string
20 phase: CleanViewPhase
21 tasks: CleanViewTask[]
22 needsYouReason: string | null
23 stuckReason: string | null
24 /** Milliseconds since the epoch. */
25 startedAt: number
26 finishedAt: number | null
27 /** True once a finished job has shrunk to one line. */
28 isCollapsed: boolean
29 /** True once a real plan replaced the placeholder steps. */
30 isPlanned: boolean
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'clean-view': {
36 cleanViewEnabled: boolean
37 checklist: CleanViewChecklist | null
38 tick: number
39 }
40 }
41}
42