A calm view for non-technical people: hides tool rows and shows one plain-English checklist above the prompt.

A Claude Code mod that makes Claude Code calm and friendly for people who aren't technical.
While Claude works, the tool calls, file changes and command output are hidden. One simple checklist sits above the prompt instead, so you can always see the plan, what's happening now and how far along it is.
Build my landing page · 1m 12s [ ● Clean View: ON ]
✓ Read your brand notes ██████████ Done
▶ Build the pricing section ██████░░░░ 60%
○ Add the contact form ░░░░░░░░░░ Next
○ Polish the footer ░░░░░░░░░░ Up next
The header also tells you when Claude needs you (for example, to approve a step), when it's stuck, when you stopped it, and when it's all done.
Type this at the Claude Code prompt in a terminal:
/plugin install clean-view --marketplace b00y0h/clean-view
Answer y to add the marketplace, then press Enter to install it for your user. Clean View starts on right away.
/simple on, /simple off, or just /simple to flip it.Your choice is remembered after a restart. When it's off, everything Claude Code normally shows comes back.
claude plugin validate .
claude plugin test .hooks/register.tsx 9 lines1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5// One entry point, so more mods can be registered beside Clean View later.
6export const register: Register = on => {
7 registerCleanView(on)
8}
9hooks/clean-view.tsx 866 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderElement, Timer } from 'claude-code'
3
4import type { CleanViewChecklist, CleanViewTask } from '../types'
5
6type $ = EngineInterface
7
8const PLUGIN = 'clean-view'
9const PLAN_TOOL = `mcp__${PLUGIN}__plan_steps`
10const PROGRESS_TOOL = `mcp__${PLUGIN}__report_progress`
11const ALWAYS_ALLOWED = new Set([
12 'ToolSearch',
13 'TodoWrite',
14 'TaskCreate',
15 'TaskUpdate',
16 'AskUserQuestion',
17 PLAN_TOOL,
18 PROGRESS_TOOL,
19])
20
21const MAX_NAME = 40
22const METER = 10
23const TICK_MS = 250
24const COLLAPSE_MS = 5000
25const FAILURES_BEFORE_STUCK = 3
26
27const enabledAtom = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
28const checklistAtom = atom({ plugin: 'clean-view', key: 'checklist' } as const, null)
29const tickAtom = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
30
31const REASON = {
32 permission: 'Claude needs your OK to continue',
33 question: 'Claude has a question for you',
34 waiting: 'Claude is waiting for your reply',
35 saidNo: 'you said no to a step, so Claude paused',
36 failing: 'a step keeps failing, Claude is trying another way',
37 refusal: "Claude couldn't help with that request",
38 rateLimit: 'you hit your usage limit, try again a little later',
39 overloaded: "Claude's servers are busy, try again in a minute",
40 tooLong: 'the conversation is too long, type /compact and try again',
41 network: 'the internet connection dropped, try again',
42 auth: "Claude isn't signed in, type /login",
43 unknownError: 'something went wrong, try again',
44} as const
45
46const CODE_EXTENSIONS =
47 'tsx?|jsx?|mjs|cjs|mts|cts|py|rb|go|rs|java|kt|swift|c|cc|cpp|h|hpp|cs|php|css|scss|sass|less|html?|json|ya?ml|toml|mdx?|sh|zsh|bash|sql|xml|vue|svelte|lock|env|ini|cfg|conf'
48const FILE_NAME = new RegExp(`\\S*\\.(?:${CODE_EXTENSIONS})(?![A-Za-z0-9])\\S*`, 'gi')
49
50/**
51 * The one cleaner every step and job name goes through: no code, no paths,
52 * no file names, one line, capitalised, at most 40 characters.
53 */
54export function cleanName(raw: unknown, fallback = 'Working on it'): string {
55 let text = typeof raw === 'string' ? raw : ''
56 text = text
57 .replace(/`[^`]*`/g, ' ')
58 .replace(/`/g, ' ')
59 .replace(/\S*[/\\]\S*/g, ' ')
60 .replace(FILE_NAME, ' ')
61 .replace(/\(\s*\)|\[\s*\]/g, ' ')
62 .replace(/\s+/g, ' ')
63 .trim()
64 .replace(/^[\s,;:.)\]–—-]+/, '')
65 .replace(/[\s,;:([–—-]+$/, '')
66 .trim()
67
68 if (text === '') {
69 return fallback
70 }
71
72 text = text.charAt(0).toUpperCase() + text.slice(1)
73
74 if (text.length <= MAX_NAME) {
75 return text
76 }
77
78 const head = text.slice(0, MAX_NAME - 1)
79 const lastSpace = head.lastIndexOf(' ')
80 const cut = (lastSpace > 0 ? head.slice(0, lastSpace) : head).replace(/[\s,;:.–—-]+$/, '')
81
82 return `${cut}…`
83}
84
85/** A job title from Haiku: the cleaner, then 2 to 6 words, or nothing. */
86function cleanTitle(raw: string): string | null {
87 const firstLine = raw.split('\n').find(line => line.trim() !== '') ?? ''
88 const words = cleanName(firstLine.replace(/["'“”‘’*_#.!?]/g, ''), '')
89 .split(' ')
90 .filter(Boolean)
91
92 if (words.length < 2) {
93 return null
94 }
95
96 return cleanName(words.slice(0, 6).join(' '))
97}
98
99function formatDuration(ms: number): string {
100 const seconds = Math.max(0, Math.floor(ms / 1000))
101 const h = Math.floor(seconds / 3600)
102 const m = Math.floor((seconds % 3600) / 60)
103 const s = seconds % 60
104
105 if (h > 0) {
106 return `${h}h ${m}m`
107 }
108
109 return m > 0 ? `${m}m ${s}s` : `${s}s`
110}
111
112function clampPercent(value: unknown): number {
113 const n = typeof value === 'number' ? value : Number(value)
114
115 return Number.isFinite(n) ? Math.min(100, Math.max(0, Math.round(n))) : 0
116}
117
118function sameName(a: string, b: string): boolean {
119 return a.toLowerCase() === b.toLowerCase()
120}
121
122// ---------------------------------------------------------------------------
123// Checklist transitions (pure)
124// ---------------------------------------------------------------------------
125
126function newChecklist(title: string, startedAt: number): CleanViewChecklist {
127 return {
128 title,
129 phase: 'working',
130 tasks: [
131 { id: 'placeholder-1', name: 'Understand your request', status: 'active', percent: 0, hasReported: false },
132 { id: 'placeholder-2', name: 'Plan the steps', status: 'upcoming', percent: 0, hasReported: false },
133 ],
134 needsYouReason: null,
135 stuckReason: null,
136 startedAt,
137 finishedAt: null,
138 isCollapsed: false,
139 isPlanned: false,
140 }
141}
142
143/** Makes the first upcoming step active when nothing is active and work remains. */
144function ensureActive(tasks: CleanViewTask[]): CleanViewTask[] {
145 if (tasks.some(t => t.status === 'active')) {
146 return tasks
147 }
148
149 const next = tasks.findIndex(t => t.status === 'upcoming')
150
151 return next === -1
152 ? tasks
153 : tasks.map((t, i) => (i === next ? { ...t, status: 'active', percent: 0, hasReported: false } : t))
154}
155
156function backToWork(c: CleanViewChecklist): CleanViewChecklist {
157 return {
158 ...c,
159 phase: 'working',
160 needsYouReason: null,
161 stuckReason: null,
162 finishedAt: null,
163 isCollapsed: false,
164 }
165}
166
167function withPlan(c: CleanViewChecklist, names: string[]): CleanViewChecklist {
168 const tasks: CleanViewTask[] = names.map((name, i) => ({
169 id: `step-${i + 1}`,
170 name,
171 status: i === 0 ? 'active' : 'upcoming',
172 percent: 0,
173 hasReported: false,
174 }))
175
176 return { ...backToWork(c), tasks, isPlanned: true }
177}
178
179function withProgress(c: CleanViewChecklist, name: string, percent: number): CleanViewChecklist {
180 let tasks = [...c.tasks]
181 let index = tasks.findIndex(t => sameName(t.name, name))
182
183 if (index === -1) {
184 const step: CleanViewTask = {
185 id: `step-${tasks.length + 1}-${tasks.filter(t => t.id.startsWith('step-')).length}`,
186 name,
187 status: 'active',
188 percent: 0,
189 hasReported: false,
190 }
191 const activeAt = tasks.findIndex(t => t.status === 'active')
192 index = activeAt === -1 ? tasks.length : activeAt
193 tasks.splice(index, 0, step)
194 }
195
196 tasks = tasks.map((t, i) => {
197 if (i < index) {
198 return { ...t, status: 'done', percent: 100 }
199 }
200
201 if (i === index) {
202 return percent >= 100
203 ? { ...t, status: 'done', percent: 100, hasReported: true }
204 : { ...t, status: 'active', percent, hasReported: true }
205 }
206
207 return t.status === 'active' ? { ...t, status: 'upcoming', percent: 0, hasReported: false } : t
208 })
209
210 return { ...backToWork(c), tasks: ensureActive(tasks), isPlanned: true }
211}
212
213type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed' }
214
215function withTodos(c: CleanViewChecklist, todos: readonly Todo[]): CleanViewChecklist {
216 if (todos.length === 0) {
217 return c
218 }
219
220 const tasks: CleanViewTask[] = todos.map((todo, i) => {
221 const name = cleanName(todo.content)
222 const before = c.tasks.find(t => sameName(t.name, name))
223 const status = todo.status === 'completed' ? 'done' : todo.status === 'in_progress' ? 'active' : 'upcoming'
224 const keep = status === 'active' && before?.status === 'active' && before.hasReported
225
226 return {
227 id: `todo-${i + 1}`,
228 name,
229 status,
230 percent: status === 'done' ? 100 : keep ? before.percent : 0,
231 hasReported: status === 'done' || Boolean(keep),
232 }
233 })
234
235 return { ...backToWork(c), tasks: ensureActive(tasks), isPlanned: true }
236}
237
238function withTaskCreated(c: CleanViewChecklist, id: string, subject: string): CleanViewChecklist {
239 const base = c.isPlanned ? c.tasks : []
240 const task: CleanViewTask = { id: `task-${id}`, name: cleanName(subject), status: 'upcoming', percent: 0, hasReported: false }
241
242 return { ...backToWork(c), tasks: ensureActive([...base, task]), isPlanned: true }
243}
244
245type TaskUpdate = { taskId: string; subject?: string; status?: 'pending' | 'in_progress' | 'completed' | 'deleted' }
246
247function withTaskUpdated(c: CleanViewChecklist, u: TaskUpdate): CleanViewChecklist {
248 const id = `task-${u.taskId}`
249
250 if (!c.tasks.some(t => t.id === id)) {
251 return c
252 }
253
254 let tasks = c.tasks
255 .filter(t => !(t.id === id && u.status === 'deleted'))
256 .map(t => {
257 if (t.id === id) {
258 const name = u.subject === undefined ? t.name : cleanName(u.subject)
259
260 if (u.status === 'completed') {
261 return { ...t, name, status: 'done' as const, percent: 100 }
262 }
263
264 if (u.status === 'in_progress') {
265 return { ...t, name, status: 'active' as const }
266 }
267
268 if (u.status === 'pending') {
269 return { ...t, name, status: 'upcoming' as const, percent: 0, hasReported: false }
270 }
271
272 return { ...t, name }
273 }
274
275 return u.status === 'in_progress' && t.status === 'active'
276 ? { ...t, status: 'upcoming' as const, percent: 0, hasReported: false }
277 : t
278 })
279 tasks = ensureActive(tasks)
280
281 return { ...backToWork(c), tasks }
282}
283
284function apiErrorReason(kind: string, details: string): string {
285 const said = details.toLowerCase()
286
287 if (/prompt is too long|too long|context (window|length)|max.*tokens/.test(said)) {
288 return REASON.tooLong
289 }
290
291 if (/econn|enotfound|network|socket|fetch failed|connection|timed? ?out|offline/.test(said)) {
292 return REASON.network
293 }
294
295 switch (kind) {
296 case 'rate_limit':
297 case 'billing_error':
298 return REASON.rateLimit
299 case 'overloaded':
300 case 'server_error':
301 return REASON.overloaded
302 case 'authentication_failed':
303 case 'oauth_org_not_allowed':
304 case 'cloud_credential_error':
305 return REASON.auth
306 case 'invalid_request':
307 return REASON.tooLong
308 default:
309 return REASON.unknownError
310 }
311}
312
313const USER_SAID_NO = /doesn.t want to proceed|user rejected|was rejected|user declined|denied by the user/i
314
315const SYSTEM_PROMPT = [
316 '# Clean View',
317 'The person using this session is not technical. Instead of your tool calls they see one simple checklist of the steps you take, so write it for them.',
318 `- For every request, even a quick question, call \`${PLAN_TOOL}\` first with every step of the job, 2 to 8 short names in order. If it is deferred, load it with ToolSearch first (query "select:${PLAN_TOOL},${PROGRESS_TOOL}"). Other tools are refused until a plan exists.`,
319 '- 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".',
320 '- Never put file paths, file names, commands, code or tool names in a step name.',
321 `- Call \`${PROGRESS_TOOL}\` with the step's name and a percent as real progress happens, and with 100 the moment a step finishes.`,
322 '- If this session has TodoWrite or TaskCreate, you may use your to-do list as the plan instead; the same naming rules apply to every item.',
323].join('\n')
324
325// ---------------------------------------------------------------------------
326// Hooks
327// ---------------------------------------------------------------------------
328
329// Module state: lost on a hot reload, which is fine for each of these.
330let ticker: Timer | undefined
331let failures = 0
332let lastApiError: string | null = null
333
334async function isEnabled($: $): Promise<boolean> {
335 return read($, enabledAtom)
336}
337
338function syncTicker($: $, c: CleanViewChecklist | null, enabled: boolean): void {
339 const isLive = enabled && c !== null && (c.phase === 'working' || c.phase === 'needs-you')
340
341 if (isLive && ticker === undefined) {
342 ticker = $.clock.every(TICK_MS, () => {
343 void update($, tickAtom, n => (n + 1) % 1_000_000)
344 })
345 } else if (!isLive && ticker !== undefined) {
346 ticker.cancel()
347 ticker = undefined
348 }
349}
350
351async function change(
352 $: $,
353 fn: (c: CleanViewChecklist) => CleanViewChecklist | null,
354): Promise<CleanViewChecklist | null> {
355 let after: CleanViewChecklist | null = null
356 await update($, checklistAtom, c => {
357 after = c === null ? null : fn(c)
358
359 return after
360 })
361 syncTicker($, after, await isEnabled($))
362
363 return after
364}
365
366/** A checklist to write into, even when the plan arrives before any prompt was seen. */
367async function ensureJob($: $): Promise<void> {
368 if ((await read($, checklistAtom)) === null) {
369 const job = newChecklist('Working on it', await $.clock.now())
370 await update($, checklistAtom, () => job)
371 }
372}
373
374async function startJob($: $, prompt: string): Promise<void> {
375 const startedAt = await $.clock.now()
376 const job = newChecklist(cleanName(prompt), startedAt)
377 failures = 0
378 lastApiError = null
379 await update($, checklistAtom, () => job)
380 syncTicker($, job, true)
381
382 // Name the job in the background so the turn never waits on it.
383 $.clock.after(0, () => {
384 void nameJob($, prompt, startedAt)
385 })
386}
387
388async function nameJob($: $, prompt: string, startedAt: number): Promise<void> {
389 const reply = await $.model.complete({
390 model: 'haiku',
391 effort: 'low',
392 maxTokens: 30,
393 timeoutMs: 20_000,
394 system:
395 'You name jobs for a non-technical person. Reply with the name only: 2 to 6 plain words, starting with a verb, no punctuation, no file names, no code.',
396 prompt: `Name this job:\n\n${prompt.slice(0, 2000)}`,
397 })
398
399 if (!reply.isAnswered) {
400 return
401 }
402
403 const title = cleanTitle(reply.text)
404
405 if (title === null) {
406 return
407 }
408
409 // A newer job has started: this name belongs to the old one.
410 await update($, checklistAtom, c => (c !== null && c.startedAt === startedAt ? { ...c, title } : c))
411}
412
413async function setEnabled($: $, value: boolean): Promise<void> {
414 await update($, enabledAtom, () => value)
415 await $.store.set('cleanViewEnabled', value)
416 syncTicker($, await read($, checklistAtom), value)
417 $.ui.toast(value ? 'Clean View is on: the technical details are hidden.' : 'Clean View is off: showing everything.')
418}
419
420async function finishJob($: $): Promise<void> {
421 const finishedAt = await $.clock.now()
422 const done = await change($, c => ({
423 ...c,
424 phase: 'done',
425 needsYouReason: null,
426 stuckReason: null,
427 finishedAt,
428 isCollapsed: false,
429 tasks: c.tasks.map(t => ({ ...t, status: 'done', percent: 100 })),
430 }))
431
432 if (done === null) {
433 return
434 }
435
436 $.clock.after(COLLAPSE_MS, () => {
437 void update($, checklistAtom, c =>
438 c !== null && c.startedAt === done.startedAt && c.phase === 'done' ? { ...c, isCollapsed: true } : c,
439 )
440 })
441}
442
443export function registerCleanView(on: On): void {
444 on('session.start', async ($, e, next) => {
445 await Promise.all([
446 $.tool.register({
447 name: 'plan_steps',
448 description:
449 'Lay out every step of the job up front, before any other tool: 2 to 8 short plain-English step names in order, each under 40 characters and starting with a verb. No file paths, file names, commands, code or tool names. The first step starts right away.',
450 inputSchema: {
451 type: 'object',
452 properties: {
453 steps: {
454 type: 'array',
455 items: { type: 'string' },
456 minItems: 1,
457 maxItems: 8,
458 description: 'The step names, in order.',
459 },
460 },
461 required: ['steps'],
462 },
463 isDeferred: false,
464 }),
465 $.tool.register({
466 name: 'report_progress',
467 description:
468 'Report real progress on the current step. Use the step name exactly as planned; percent 0 to 100, and 100 the moment the step is finished, which starts the next one. A new plain-English name adds a step.',
469 inputSchema: {
470 type: 'object',
471 properties: {
472 task: { type: 'string', description: 'The step name, as planned.' },
473 percent: { type: 'number', minimum: 0, maximum: 100 },
474 },
475 required: ['task', 'percent'],
476 },
477 isDeferred: false,
478 }),
479 $.command.register({
480 name: 'simple',
481 description: 'Turn Clean View on or off (no argument flips it)',
482 argumentHint: '[on|off]',
483 immediate: true,
484 }),
485 ])
486
487 const saved = await $.store.get('cleanViewEnabled')
488 await update($, enabledAtom, () => saved !== false)
489 syncTicker($, await read($, checklistAtom), saved !== false)
490
491 return next(e)
492 })
493
494 on('command.run', { command: 'simple' }, async ($, e) => {
495 const arg = e.args.trim().toLowerCase()
496
497 if (arg !== '' && arg !== 'on' && arg !== 'off') {
498 return { text: 'Use /simple on, /simple off, or /simple on its own to switch.' }
499 }
500
501 const value = arg === '' ? !(await isEnabled($)) : arg === 'on'
502 await setEnabled($, value)
503
504 return { text: value ? 'Clean View is on.' : 'Clean View is off.' }
505 })
506
507 on('prompt.compose', async ($, e, next) => {
508 const composed = await next(e)
509
510 if (!(await isEnabled($))) {
511 return composed
512 }
513
514 return {
515 sections: [
516 ...composed.sections.filter(s => s.id !== `${PLUGIN}:plain-steps`),
517 { id: `${PLUGIN}:plain-steps`, text: SYSTEM_PROMPT, scope: 'session' as const },
518 ],
519 }
520 })
521
522 on('turn.start', async ($, e, next) => {
523 const text = e.text.trim()
524
525 if (text !== '' && !text.startsWith('/') && (await isEnabled($))) {
526 const c = await read($, checklistAtom)
527 const isRunning = c !== null && (c.phase === 'working' || c.phase === 'needs-you' || c.phase === 'stuck')
528
529 if (isRunning) {
530 // The person replied to a running job: carry on with it.
531 await change($, backToWork)
532 } else {
533 await startJob($, text)
534 }
535 }
536
537 return next(e)
538 })
539
540 on('tool.call', async ($, e, next) => {
541 const tool = String(e.tool)
542 const enabled = await isEnabled($)
543 const isMain = e.agentId === undefined
544
545 if (tool === PLAN_TOOL) {
546 const raw: unknown = (e as { steps?: unknown }).steps
547 const names = (Array.isArray(raw) ? raw : [])
548 .map(step => cleanName(step, ''))
549 .filter(name => name !== '')
550 .slice(0, 8)
551
552 if (names.length === 0) {
553 return { deny: 'plan_steps needs 2 to 8 plain-English step names in `steps`.' }
554 }
555
556 await ensureJob($)
557 await change($, c => withPlan(c, names))
558
559 return { result: `Planned ${names.length} steps. The first one has started.` }
560 }
561
562 if (tool === PROGRESS_TOOL) {
563 const input = e as { task?: unknown; percent?: unknown }
564 const percent = clampPercent(input.percent)
565 const name = cleanName(input.task)
566
567 await ensureJob($)
568 await change($, c => withProgress(c, name, percent))
569
570 return { result: `Progress noted: ${percent}%.` }
571 }
572
573 if (enabled && isMain && !ALWAYS_ALLOWED.has(tool)) {
574 const c = await read($, checklistAtom)
575
576 if (c === null || !c.isPlanned) {
577 return {
578 deny: `Clean View: call ${PLAN_TOOL} first to lay out the steps of this job in plain English (load it with ToolSearch if it is deferred), then try again.`,
579 }
580 }
581 }
582
583 if (isMain && tool === 'AskUserQuestion') {
584 await change($, c => ({ ...c, phase: 'needs-you', needsYouReason: REASON.question }))
585 } else if (isMain) {
586 await change($, c => (c.phase === 'needs-you' ? { ...c, phase: 'working', needsYouReason: null } : c))
587 }
588
589 const ran = await next(e)
590
591 if (!isMain) {
592 return ran
593 }
594
595 if (ran.deny !== undefined || ran.isError === true) {
596 const said = `${ran.deny ?? ''} ${ran.text ?? ''}`
597
598 if (USER_SAID_NO.test(said)) {
599 failures = 0
600 await change($, c => ({ ...c, phase: 'stuck', stuckReason: REASON.saidNo, needsYouReason: null }))
601 } else {
602 failures += 1
603
604 if (failures >= FAILURES_BEFORE_STUCK) {
605 await change($, c => ({ ...c, phase: 'stuck', stuckReason: REASON.failing, needsYouReason: null }))
606 }
607 }
608
609 return ran
610 }
611
612 failures = 0
613
614 if (tool === 'TodoWrite' || tool === 'TaskCreate') {
615 await ensureJob($)
616 }
617
618 if (tool === 'TodoWrite') {
619 const todos = (e as { todos?: Todo[] }).todos ?? []
620 await change($, c => withTodos(c, todos))
621 } else if (tool === 'TaskCreate') {
622 const created = (ran.result as { task?: { id?: string; subject?: string } } | undefined)?.task
623 const subject = created?.subject ?? (e as { subject?: string }).subject ?? ''
624
625 if (created?.id !== undefined) {
626 await change($, c => withTaskCreated(c, String(created.id), subject))
627 }
628 } else if (tool === 'TaskUpdate') {
629 await change($, c => withTaskUpdated(c, e as unknown as TaskUpdate))
630 } else {
631 await change($, c => (c.phase === 'needs-you' || c.phase === 'stuck' ? backToWork(c) : c))
632 }
633
634 return ran
635 }).catch(($, e, next) => next(e))
636
637 on('classic.Notification', async ($, e, next) => {
638 const kind = String(e.notification_type ?? '')
639 const isPermission = kind === 'permission_prompt' || /permission/i.test(e.message ?? '')
640 const isQuestion = kind.startsWith('elicitation')
641
642 if (isPermission || isQuestion) {
643 await change($, c =>
644 c.phase === 'working' || c.phase === 'stuck' || c.phase === 'needs-you'
645 ? { ...c, phase: 'needs-you', needsYouReason: isPermission ? REASON.permission : REASON.question }
646 : c,
647 )
648 }
649
650 return next(e)
651 })
652
653 on('classic.StopFailure', async ($, e, next) => {
654 lastApiError = apiErrorReason(String(e.error), String(e.error_details ?? ''))
655 const reason = lastApiError
656 const finishedAt = await $.clock.now()
657 await change($, c => (c.phase === 'done' ? c : { ...c, phase: 'stuck', stuckReason: reason, needsYouReason: null, finishedAt }))
658
659 return next(e)
660 })
661
662 on('turn.complete', async ($, e, next) => {
663 if (e.agentId !== undefined || (await read($, checklistAtom)) === null) {
664 return next(e)
665 }
666
667 const finishedAt = await $.clock.now()
668
669 if (e.reason === 'aborted') {
670 await change($, c => ({ ...c, phase: 'stopped', needsYouReason: null, finishedAt }))
671 } else if (e.reason === 'refusal') {
672 await change($, c => ({ ...c, phase: 'stuck', stuckReason: REASON.refusal, needsYouReason: null, finishedAt }))
673 } else if (e.reason === 'error') {
674 const reason = lastApiError ?? REASON.unknownError
675 await change($, c => ({ ...c, phase: 'stuck', stuckReason: reason, needsYouReason: null, finishedAt }))
676 } else {
677 const c = await read($, checklistAtom)
678 const isUnfinished = c !== null && c.isPlanned && c.tasks.some(t => t.status !== 'done')
679
680 if (c?.phase === 'stuck') {
681 // Keep the reason (a "no" to a permission ends the turn as an answer).
682 } else if (isUnfinished) {
683 await change($, current => ({ ...current, phase: 'needs-you', needsYouReason: REASON.waiting }))
684 } else {
685 await finishJob($)
686 }
687 }
688
689 lastApiError = null
690
691 return next(e)
692 })
693
694 // Hide the technical rows while Clean View is on.
695 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
696 const { Box } = $.ui.resolve(e)
697
698 return (await isEnabled($)) ? <Box display="none" /> : next(e)
699 })
700
701 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
702 const { Box } = $.ui.resolve(e)
703
704 return (await isEnabled($)) ? <Box display="none" /> : next(e)
705 })
706
707 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
708 const { Box } = $.ui.resolve(e)
709
710 return (await isEnabled($)) ? <Box display="none" /> : next(e)
711 })
712
713 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) =>
714 (await isEnabled($)) ? next({ ...e, props: { ...e.props, hint: '' } }) : next(e),
715 )
716
717 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
718 if (e.props.hasSurvey) {
719 return next(e)
720 }
721
722 // What the app and later mods draw in this band: the branch, worktree and merge status
723 // indicators among them. A tree drawn here replaces theirs, so theirs always goes under ours.
724 const theirs = await next(e)
725 const ours = await (async (): Promise<RenderElement> => {
726 const enabled = await read($, enabledAtom)
727 const c = await read($, checklistAtom)
728 const tick = await read($, tickAtom)
729 const now = await $.clock.now()
730 const columns = Math.max(20, e.props.bodyColumns)
731 const { Box, Text, Button } = $.ui.resolve(e)
732
733 const toggle = (
734 <Button
735 key="clean-view-toggle"
736 label={enabled ? '● Clean View: ON' : '○ Clean View: OFF'}
737 variant={enabled ? 'primary' : 'secondary'}
738 onPress={() => setEnabled($, !enabled)}
739 />
740 )
741
742 if (!enabled || c === null) {
743 return (
744 <Box flexDirection="row" justifyContent="flex-end" width={columns}>
745 {toggle}
746 </Box>
747 )
748 }
749
750 const elapsed = formatDuration((c.finishedAt ?? now) - c.startedAt)
751 const header =
752 c.phase === 'needs-you' ? (
753 <Text key="header" wrap="truncate-end">
754 <Text backgroundColor="warning" color="inverseText" bold>
755 {' Needs you '}
756 </Text>
757 {` ${c.needsYouReason ?? REASON.waiting}`}
758 </Text>
759 ) : c.phase === 'stuck' ? (
760 <Text key="header" color="warning" wrap="truncate-end">
761 {`⚠ Stuck: ${c.stuckReason ?? REASON.unknownError}`}
762 </Text>
763 ) : c.phase === 'stopped' ? (
764 <Text key="header" wrap="truncate-end">
765 {`■ Stopped · ${c.title} · you pressed Esc`}
766 </Text>
767 ) : c.phase === 'done' ? (
768 <Text key="header" color="success" wrap="truncate-end">
769 {`✓ All done · ${c.title} · took ${elapsed}`}
770 </Text>
771 ) : (
772 <Text key="header" wrap="truncate-end">
773 <Text bold>{c.title}</Text>
774 {` · ${elapsed}`}
775 </Text>
776 )
777
778 const headerRow = (
779 <Box key="header-row" flexDirection="row" justifyContent="space-between" width={columns}>
780 <Box flexGrow={1} flexShrink={1} marginRight={1}>
781 {header}
782 </Box>
783 {toggle}
784 </Box>
785 )
786
787 if (c.phase === 'done' && c.isCollapsed) {
788 return <Box flexDirection="column">{headerRow}</Box>
789 }
790
791 // marker + space, name, two spaces, meter, two spaces, label ("Up next")
792 const nameWidth = Math.max(4, columns - 2 - 2 - METER - 2 - 7)
793 const firstUpcoming = c.tasks.findIndex(t => t.status === 'upcoming')
794 const isMoving = c.phase === 'working' || c.phase === 'needs-you'
795
796 const rows = c.tasks.map((task, i) => {
797 const shown = task.name.length > nameWidth ? `${task.name.slice(0, nameWidth - 1)}…` : task.name
798 const name = shown.padEnd(nameWidth)
799 const key = `row-${task.id}`
800
801 if (task.status === 'done') {
802 return (
803 <Text key={key} wrap="truncate-end">
804 <Text color="success">✓ </Text>
805 <Text dimColor>{name}</Text>
806 {` ${'█'.repeat(METER)} `}
807 <Text dimColor>Done</Text>
808 </Text>
809 )
810 }
811
812 if (task.status === 'active') {
813 const meter = task.hasReported
814 ? '█'.repeat(Math.round(task.percent / 10)).padEnd(METER, '░')
815 : sweep(isMoving ? tick : 0)
816 const label = task.hasReported ? `${task.percent}%` : 'Working'
817
818 return (
819 <Text key={key} wrap="truncate-end">
820 <Text color="claude">{c.phase === 'needs-you' ? '‖ ' : '▶ '}</Text>
821 <Text bold>{name}</Text>
822 <Text color="claude">{` ${meter} `}</Text>
823 {label}
824 </Text>
825 )
826 }
827
828 return (
829 <Text key={key} dimColor wrap="truncate-end">
830 {`○ ${name} ${'░'.repeat(METER)} ${i === firstUpcoming ? 'Next' : 'Up next'}`}
831 </Text>
832 )
833 })
834
835 return (
836 <Box flexDirection="column">
837 {headerRow}
838 {rows}
839 </Box>
840 )
841 })()
842
843 const { Box: Band } = $.ui.resolve(e)
844
845 return (
846 <Band flexDirection="column">
847 {ours}
848 {theirs}
849 </Band>
850 )
851 })
852}
853
854/** A three-cell block sweeping across the meter while no percent is known. */
855function sweep(tick: number): string {
856 const span = METER + 3
857 const head = tick % span
858 let out = ''
859
860 for (let i = 0; i < METER; i += 1) {
861 out += i <= head && i > head - 3 ? '▓' : '░'
862 }
863
864 return out
865}
866types/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' | 'needs-you' | 'stuck' | 'stopped' | 'done'
12
13export type CleanViewChecklist = {
14 title: string
15 phase: CleanViewPhase
16 tasks: CleanViewTask[]
17 needsYouReason: string | null
18 stuckReason: string | null
19 startedAt: number
20 finishedAt: number | null
21 isCollapsed: boolean
22 /** True once Claude laid out a real plan (plan_steps or a to-do list), not the placeholders. */
23 isPlanned: 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