A calm checklist above the prompt that hides tool calls, diffs and command output while Claude works.

A Claude Code mod that makes Claude feel calm for non-technical people: tool calls, diffs and command output are hidden, and a simple checklist above the prompt shows the plan, the current step and how far along it is.
The idea and original prompt for Clean View came from Tristen O'Brien.
/simple on, /simple off, or /simple to flip it. The choice is remembered.See the marketplace README.
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; add more mods beside Clean View here.
6export const register: Register = on => {
7 registerCleanView(on)
8}
9hooks/clean-view.tsx 631 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderChildren } from 'claude-code'
3
4import type { CleanViewChecklist, CleanViewTask } from '../types'
5
6type $ = EngineInterface
7type Checklist = CleanViewChecklist
8type Task = CleanViewTask
9
10export const PLAN_TOOL = 'mcp__oli-claude-mod-clean-view__plan_steps'
11export const PROGRESS_TOOL = 'mcp__oli-claude-mod-clean-view__report_progress'
12
13const enabled = atom({ plugin: 'oli-claude-mod-clean-view', key: 'cleanViewEnabled' } as const, true)
14const checklist = atom({ plugin: 'oli-claude-mod-clean-view', key: 'checklist' } as const, null)
15const tickAtom = atom({ plugin: 'oli-claude-mod-clean-view', key: 'tick' } as const, 0)
16
17const ALWAYS_ALLOWED = new Set(['ToolSearch', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'AskUserQuestion'])
18const STORE_KEY = 'cleanViewEnabled'
19const MAX_NAME = 40
20const METER = 10
21
22const GUIDE = `# Clean View is on
23
24The person sees a simple checklist of your plan instead of tool calls, so keep it accurate and friendly.
25
26- For every request, even a quick question, call \`plan_steps\` first (load it with ToolSearch if it is deferred): 2 to 8 short steps, in order.
27- 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".
28- Never put file paths, file names, commands, code or tool names in a step name.
29- Call \`report_progress\` as real progress happens, and with percent 100 the moment a step finishes.
30- If this session has TodoWrite or TaskCreate, you may use your to-do list as the plan instead.`
31
32const CODE_FILE = /\b[\w.-]+\.(?:tsx?|jsx?|mjs|cjs|mts|cts|json|php|py|rb|cs|go|rs|java|kt|swift|c|cc|cpp|h|hpp|css|scss|less|html?|vue|svelte|md|ya?ml|toml|ini|sh|zsh|bash|sql|lock|env|xml|csproj|sln)\b/gi
33
34/** The one cleaner every step and job name passes through. */
35export function cleanName(raw: string): string {
36 let s = String(raw ?? '')
37 .replace(/`[^`]*`?/g, ' ')
38 .split(/\s+/)
39 .filter(word => !word.includes('/') && !word.includes('\\'))
40 .join(' ')
41 .replace(CODE_FILE, ' ')
42 .replace(/\s+/g, ' ')
43 .trim()
44 if (s.length > MAX_NAME) {
45 const cut = s.slice(0, MAX_NAME - 1)
46 const space = cut.lastIndexOf(' ')
47 s = (space > 0 ? cut.slice(0, space) : cut).replace(/[\s,.;:–-]+$/, '') + '…'
48 }
49 if (s === '' || s === '…') return 'Working on it'
50 return s.charAt(0).toUpperCase() + s.slice(1)
51}
52
53export function duration(ms: number): string {
54 const total = Math.max(0, Math.round(ms / 1000))
55 const h = Math.floor(total / 3600)
56 const m = Math.floor((total % 3600) / 60)
57 const s = total % 60
58 if (h > 0) return `${h}h ${m}m`
59 return m > 0 ? `${m}m ${s}s` : `${s}s`
60}
61
62function apiErrorSentence(kind: string | undefined, details = ''): string {
63 if (/too long|context|too many tokens/i.test(details)) return 'type /compact and try again'
64 if (/network|ECONN|ENOTFOUND|socket|fetch failed|offline|connection/i.test(details)) return 'the internet connection dropped'
65 if (kind === 'rate_limit' || kind === 'billing_error') return 'you hit your usage limit, try again a little later'
66 if (kind === 'overloaded' || kind === 'server_error') return "Claude's servers are busy, try again in a minute"
67 if (kind === 'authentication_failed' || kind === 'oauth_org_not_allowed' || kind === 'cloud_credential_error') return 'type /login'
68 return 'something went wrong, try again'
69}
70
71const USER_SAID_NO = /user (?:doesn't|does not) want to proceed|user rejected|rejected by the user|denied by the user|user declined/i
72
73// --- state edits ------------------------------------------------------------
74
75// Module timers die with a reload; session.start resyncs them.
76let timer: { cancel: () => void } | undefined
77let failures = 0
78let lastApiError: { kind?: string; details?: string } | null = null
79
80function syncTimer($: $, list: Checklist | null) {
81 const isLive = list !== null && (list.phase === 'working' || list.phase === 'needs-you')
82 if (isLive && timer === undefined) timer = $.clock.every(250, () => void update($, tickAtom, n => (n ?? 0) + 1))
83 if (!isLive && timer !== undefined) {
84 timer.cancel()
85 timer = undefined
86 }
87}
88
89async function edit($: $, fn: (list: Checklist | null) => Checklist | null) {
90 let out = null as Checklist | null
91 await update($, checklist, list => (out = fn(list ?? null)))
92 syncTimer($, out)
93 return out
94}
95
96function newList(now: number, title: string): Checklist {
97 return {
98 title,
99 phase: 'working',
100 tasks: [
101 { id: 'placeholder-1', name: 'Understand your request', status: 'active', percent: 0, hasReported: false },
102 { id: 'placeholder-2', name: 'Plan the steps', status: 'upcoming', percent: 0, hasReported: false },
103 ],
104 needsYouReason: null,
105 stuckReason: null,
106 startedAt: now,
107 finishedAt: null,
108 isCollapsed: false,
109 isPlanned: false,
110 }
111}
112
113function working(list: Checklist): Checklist {
114 return { ...list, phase: 'working', needsYouReason: null, stuckReason: null, finishedAt: null, isCollapsed: false }
115}
116
117/** Makes the first unfinished step active when none is. */
118function ensureActive(tasks: Task[]): Task[] {
119 if (tasks.some(t => t.status === 'active')) return tasks
120 const i = tasks.findIndex(t => t.status !== 'done')
121 return i < 0 ? tasks : tasks.map((t, j) => (j === i ? { ...t, status: 'active' } : t))
122}
123
124export function applyPlan(list: Checklist, steps: string[]): Checklist {
125 const tasks: Task[] = steps.map((step, i) => ({
126 id: `step-${i + 1}`,
127 name: cleanName(step),
128 status: i === 0 ? 'active' : 'upcoming',
129 percent: 0,
130 hasReported: false,
131 }))
132 return { ...working(list), tasks, isPlanned: true }
133}
134
135export function applyProgress(list: Checklist, rawName: string, rawPercent: number): Checklist {
136 const name = cleanName(rawName)
137 const percent = Math.min(100, Math.max(0, Math.round(Number.isFinite(rawPercent) ? rawPercent : 0)))
138 let tasks = list.isPlanned ? [...list.tasks] : []
139 let at = tasks.findIndex(t => t.name.toLowerCase() === name.toLowerCase())
140 if (at < 0) {
141 tasks.push({ id: `step-${tasks.length + 1}-${name}`, name, status: 'upcoming', percent: 0, hasReported: false })
142 at = tasks.length - 1
143 }
144 tasks = tasks.map((t, i): Task => {
145 if (i < at) return { ...t, status: 'done', percent: 100 }
146 if (i === at) return { ...t, status: percent >= 100 ? 'done' : 'active', percent, hasReported: true }
147 return t.status === 'active' ? { ...t, status: 'upcoming' } : t
148 })
149 if (percent >= 100) {
150 const nextAt = tasks.findIndex((t, i) => i > at && t.status !== 'done')
151 const following = tasks[nextAt]
152 if (following) tasks[nextAt] = { ...following, status: 'active', percent: 0, hasReported: false }
153 }
154 return { ...list, tasks }
155}
156
157type Todo = { content?: unknown; status?: unknown }
158
159function fromTodos(list: Checklist, todos: Todo[]): Checklist {
160 const old = new Map(list.tasks.map(t => [t.name, t]))
161 const tasks = todos.map((todo, i): Task => {
162 const name = cleanName(String(todo.content ?? ''))
163 const prior = old.get(name)
164 if (todo.status === 'completed') return { id: `todo-${i + 1}`, name, status: 'done', percent: 100, hasReported: true }
165 const status = todo.status === 'in_progress' ? 'active' : 'upcoming'
166 return { id: `todo-${i + 1}`, name, status, percent: prior?.percent ?? 0, hasReported: prior?.hasReported ?? false }
167 })
168 return { ...list, tasks: ensureActive(tasks), isPlanned: true }
169}
170
171// --- the job's life -----------------------------------------------------------
172
173async function nameJob($: $, text: string, startedAt: number) {
174 const prompt = `Name this job in 2 to 6 plain words a non-technical person understands, starting with a verb. No file names, code or punctuation. Reply with the name only.\n\nRequest: ${text.slice(0, 2000)}`
175 try {
176 let answer = await $.model.complete({ model: 'haiku', effort: 'low', maxTokens: 24, timeoutMs: 15000, prompt })
177 if (!answer.isAnswered && answer.reason === 'api-error') answer = await $.model.complete({ model: 'haiku', maxTokens: 24, timeoutMs: 15000, prompt })
178 if (!answer.isAnswered) return
179 const words = (answer.text.split('\n')[0] ?? '').replace(/["'*.]/g, '').trim().split(/\s+/).slice(0, 6).join(' ')
180 if (words === '') return
181 await edit($, list => (list !== null && list.startedAt === startedAt ? { ...list, title: cleanName(words) } : list))
182 } catch {
183 // The placeholder title stays.
184 }
185}
186
187async function onPrompt($: $, text: string) {
188 const now = await $.clock.now()
189 failures = 0
190 lastApiError = null
191 let startedAt: number | null = null
192 await edit($, list => {
193 // A reply to "Needs you" or "Stuck" carries the same job on.
194 if (list !== null && (list.phase === 'needs-you' || list.phase === 'stuck' || list.phase === 'working')) return working(list)
195 startedAt = now
196 return newList(now, cleanName(text.split(/\s+/).slice(0, 6).join(' ')))
197 })
198 if (startedAt !== null) void nameJob($, text, startedAt)
199}
200
201async function setNeedsYou($: $, reason: string) {
202 await edit($, list => (list === null || list.phase === 'done' || list.phase === 'stopped' ? list : { ...list, phase: 'needs-you', needsYouReason: reason }))
203}
204
205async function clearNeedsYou($: $) {
206 await edit($, list => (list?.phase === 'needs-you' ? { ...list, phase: 'working', needsYouReason: null } : list))
207}
208
209async function setStuck($: $, reason: string) {
210 await edit($, list => (list === null ? list : { ...list, phase: 'stuck', stuckReason: reason, needsYouReason: null }))
211}
212
213async function noteOutcome($: $, ran: { deny?: string; isError?: true; text?: string; result?: unknown }) {
214 if (ran.deny !== undefined) return
215 if (ran.isError) {
216 const said = `${ran.text ?? ''} ${typeof ran.result === 'string' ? ran.result : ''}`
217 if (USER_SAID_NO.test(said)) return setStuck($, 'you said no to a step, so Claude paused')
218 failures += 1
219 if (failures >= 3) await setStuck($, 'a step keeps failing, Claude is trying another way')
220 return
221 }
222 failures = 0
223 await edit($, list => (list?.phase === 'stuck' ? { ...list, phase: 'working', stuckReason: null } : list))
224}
225
226async function onTurnComplete($: $, reason: string) {
227 const now = await $.clock.now()
228 const error = lastApiError
229 lastApiError = null
230 let doneJob: number | null = null
231 await edit($, list => {
232 if (list === null) return list
233 if (reason === 'error') return { ...list, phase: 'stuck', stuckReason: apiErrorSentence(error?.kind, error?.details), needsYouReason: null }
234 if (reason === 'refusal') return { ...list, phase: 'stuck', stuckReason: "Claude couldn't help with that request", needsYouReason: null }
235 if (reason === 'aborted') return { ...list, phase: 'stopped', finishedAt: now, needsYouReason: null }
236 const isUnfinished = list.isPlanned && list.tasks.some(t => t.status !== 'done')
237 if (isUnfinished && list.phase === 'stuck') return list
238 if (isUnfinished) return { ...list, phase: 'needs-you', needsYouReason: 'Claude is waiting for your reply' }
239 doneJob = list.startedAt
240 const tasks = list.tasks.map((t): Task => ({ ...t, status: 'done', percent: 100 }))
241 return { ...list, tasks, phase: 'done', finishedAt: now, needsYouReason: null, stuckReason: null, isCollapsed: false }
242 })
243 if (doneJob !== null) {
244 const startedOf = doneJob
245 $.clock.after(5000, () => {
246 void edit($, list => (list !== null && list.phase === 'done' && list.startedAt === startedOf ? { ...list, isCollapsed: true } : list))
247 })
248 }
249}
250
251async function toggle($: $, to?: boolean) {
252 let now = true
253 await update($, enabled, was => (now = to ?? !(was ?? true)))
254 await $.store.set(STORE_KEY, now)
255 $.ui.toast(now ? 'Clean View is on: tool details are hidden' : 'Clean View is off: showing everything')
256 return now
257}
258
259// --- drawing ------------------------------------------------------------------
260
261function fit(text: string, width: number): string {
262 if (text.length > width) return text.slice(0, Math.max(0, width - 1)) + '…'
263 return text.padEnd(width)
264}
265
266// A soft pastel palette that reads well on dark terminals.
267const PALETTE = {
268 peach: '#FAB387',
269 pink: '#F38BA8',
270 mauve: '#CBA6F7',
271 blue: '#89B4FA',
272 green: '#A6E3A1',
273 yellow: '#F9E2AF',
274 red: '#EBA0AC',
275 text: '#CDD6F4',
276 subtle: '#7F849C',
277 track: '#45475A',
278}
279const TITLE_RAMP = [PALETTE.peach, PALETTE.pink, PALETTE.mauve, PALETTE.blue]
280const SWEEP_FADE = ['#3B3052', '#5B4A7A', '#8C7BD0', '#B4BEFE']
281const SPARKLE = ['✶', '✷', '✸', '✹', '✺', '✹', '✸', '✷']
282
283function hex(color: string): [number, number, number] {
284 const n = parseInt(color.slice(1), 16)
285 return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
286}
287
288/** The colour at `t` (0..1) along a ramp of hex stops. */
289function rampAt(stops: string[], t: number): string {
290 const x = Math.min(1, Math.max(0, t)) * (stops.length - 1)
291 const i = Math.min(stops.length - 2, Math.floor(x))
292 const a = hex(stops[i] ?? '#000000')
293 const b = hex(stops[i + 1] ?? '#000000')
294 const f = x - i
295 return '#' + a.map((v, k) => Math.round(v + ((b[k] ?? 0) - v) * f).toString(16).padStart(2, '0')).join('')
296}
297
298type Cell = { char: string; color: string }
299
300function meter(task: Task, tick: number, isAnimated: boolean): Cell[] {
301 const ramp = (i: number) => rampAt([PALETTE.pink, PALETTE.mauve, PALETTE.blue], i / (METER - 1))
302 const track = (): Cell => ({ char: '░', color: PALETTE.track })
303 if (task.status === 'done') return Array.from({ length: METER }, (_, i) => ({ char: '█', color: rampAt(['#40A02B', PALETTE.green], i / (METER - 1)) }))
304 if (task.status === 'upcoming') return Array.from({ length: METER }, track)
305 if (task.hasReported) {
306 const filled = Math.round(task.percent / 10)
307 return Array.from({ length: METER }, (_, i) => (i < filled ? { char: '█', color: ramp(i) } : track()))
308 }
309 // No percent yet: a fading four-cell comet bouncing across the meter.
310 const step = isAnimated ? tick % 12 : 0
311 const head = step < 7 ? step + 3 : 15 - step
312 const dir = step < 7 ? 1 : -1
313 return Array.from({ length: METER }, (_, i) => {
314 const behind = (head - i) * dir
315 const shade = SWEEP_FADE[SWEEP_FADE.length - 1 - behind]
316 return behind >= 0 && shade !== undefined ? { char: '█', color: shade } : track()
317 })
318}
319
320function label(task: Task, firstUpcoming: boolean): string {
321 if (task.status === 'done') return 'Done'
322 if (task.status === 'upcoming') return firstUpcoming ? 'Next' : 'Up next'
323 return task.hasReported ? `${task.percent}%` : 'Working'
324}
325
326// --- hooks ----------------------------------------------------------------------
327
328export function registerCleanView(on: On) {
329 on('session.start', async ($, e, next) => {
330 const saved = await $.store.get(STORE_KEY)
331 if (typeof saved === 'boolean') await update($, enabled, () => saved)
332 await $.tool.register({
333 name: 'plan_steps',
334 description:
335 '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 names, paths, commands or code. The first step starts right away.',
336 inputSchema: {
337 type: 'object',
338 properties: { steps: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8 } },
339 required: ['steps'],
340 },
341 isDeferred: false,
342 })
343 await $.tool.register({
344 name: 'report_progress',
345 description:
346 'Report progress on the current step, by its name from plan_steps, as real progress happens. Send percent 100 the moment a step finishes; the next step then starts. A name not in the plan becomes a new step.',
347 inputSchema: {
348 type: 'object',
349 properties: { task: { type: 'string' }, percent: { type: 'number', minimum: 0, maximum: 100 } },
350 required: ['task', 'percent'],
351 },
352 isDeferred: false,
353 })
354 await $.command.register({ name: 'simple', description: 'Turn Clean View on or off', argumentHint: 'on|off' })
355 syncTimer($, await read($, checklist))
356 return next(e)
357 })
358
359 on('command.run', { command: 'simple' }, async ($, e) => {
360 const arg = e.args.trim().toLowerCase()
361 const isOn = await toggle($, arg === 'on' ? true : arg === 'off' ? false : undefined)
362 return { text: isOn ? 'Clean View is on.' : 'Clean View is off.' }
363 })
364
365 on('prompt.compose', async ($, e, next) => {
366 const composed = await next(e)
367 if (!(await read($, enabled))) return composed
368 return { ...composed, sections: [...composed.sections, { id: 'CLAUDE-MOD-CLEAN-VIEWide', text: GUIDE, scope: 'session' as const }] }
369 })
370
371 on('turn.start', async ($, e, next) => {
372 const text = e.text.trim()
373 if (text !== '' && !text.startsWith('/')) await onPrompt($, text)
374 return next(e)
375 })
376
377 on('tool.call', async ($, e, next) => {
378 const tool = String(e.tool)
379 const input = e as unknown as Record<string, unknown>
380
381 if (tool === PLAN_TOOL) {
382 const steps = Array.isArray(input.steps) ? input.steps.filter((s): s is string => typeof s === 'string' && s.trim() !== '') : []
383 if (steps.length === 0) return { deny: 'plan_steps needs 2 to 8 short step names.' }
384 const now = await $.clock.now()
385 const list = await edit($, l => applyPlan(l ?? newList(now, 'Working on it'), steps.slice(0, 8)))
386 return { result: `Planned ${list?.tasks.length ?? 0} steps. The first one has started.` }
387 }
388 if (tool === PROGRESS_TOOL) {
389 const now = await $.clock.now()
390 const percent = Math.min(100, Math.max(0, Number(input.percent) || 0))
391 await edit($, l => applyProgress(l ?? newList(now, 'Working on it'), String(input.task ?? ''), percent))
392 return { result: `Progress noted: ${Math.round(percent)}%.` }
393 }
394 if (e.agentId !== undefined) return next(e)
395
396 if ((await read($, enabled)) && !ALWAYS_ALLOWED.has(tool) && !(await read($, checklist))?.isPlanned) {
397 return { deny: 'Clean View: call plan_steps first to lay out the steps of this job (load it with ToolSearch if it is deferred), then try again.' }
398 }
399
400 await clearNeedsYou($)
401 if (tool === 'AskUserQuestion') await setNeedsYou($, 'Claude has a question for you')
402 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
403 const now = await $.clock.now()
404 await edit($, l => fromTodos(l ?? newList(now, 'Working on it'), input.todos as Todo[]))
405 }
406 if (tool === 'TaskUpdate') {
407 await edit($, l => {
408 if (l === null) return l
409 const id = String(input.taskId)
410 if (input.status === 'deleted') return { ...l, tasks: ensureActive(l.tasks.filter(t => t.id !== id)) }
411 const tasks = l.tasks.map((t): Task => {
412 if (t.id !== id) return t
413 const named = typeof input.subject === 'string' ? { ...t, name: cleanName(input.subject) } : t
414 if (input.status === 'completed') return { ...named, status: 'done', percent: 100, hasReported: true }
415 if (input.status === 'in_progress') return { ...named, status: 'active' }
416 if (input.status === 'pending') return { ...named, status: 'upcoming' }
417 return named
418 })
419 return { ...l, tasks: ensureActive(tasks) }
420 })
421 }
422
423 const ran = await next(e)
424
425 if (tool === 'TaskCreate' && ran.deny === undefined && !ran.isError) {
426 const made = (ran.result as { task?: { id?: string } } | undefined)?.task
427 const now = await $.clock.now()
428 await edit($, l => {
429 const base = l ?? newList(now, 'Working on it')
430 const tasks = base.isPlanned ? base.tasks : []
431 const task: Task = { id: String(made?.id ?? `task-${tasks.length + 1}`), name: cleanName(String(input.subject ?? '')), status: 'upcoming', percent: 0, hasReported: false }
432 return { ...base, tasks: ensureActive([...tasks, task]), isPlanned: true }
433 })
434 }
435 await clearNeedsYou($)
436 await noteOutcome($, ran)
437 return ran
438 })
439
440 on('classic.Notification', async ($, e, next) => {
441 if (e.notification_type === 'permission_prompt') await setNeedsYou($, 'Claude needs your OK to continue')
442 if (e.notification_type === 'elicitation_dialog') await setNeedsYou($, 'Claude has a question for you')
443 return next(e)
444 })
445
446 on('classic.StopFailure', async ($, e, next) => {
447 lastApiError = { kind: e.error, details: e.error_details }
448 return next(e)
449 })
450
451 on('turn.complete', async ($, e, next) => {
452 if (e.agentId === undefined) await onTurnComplete($, e.reason)
453 return next(e)
454 })
455
456 // --- hide the technical rows ---
457
458 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
459 if (!(await read($, enabled))) return next(e)
460 const { Box } = $.ui.resolve(e)
461 return <Box display="none" />
462 })
463 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
464 if (!(await read($, enabled))) return next(e)
465 const { Box } = $.ui.resolve(e)
466 return <Box display="none" />
467 })
468 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
469 if (!(await read($, enabled))) return next(e)
470 const { Box } = $.ui.resolve(e)
471 return <Box display="none" />
472 })
473 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) =>
474 (await read($, enabled)) ? next({ ...e, props: { ...e.props, hint: '' } }) : next(e),
475 )
476
477 // --- the band ---
478
479 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
480 if (e.props.hasSurvey) return next(e)
481 const { Box, Text, Button } = $.ui.resolve(e)
482 const isOn = await read($, enabled)
483 const list = await read($, checklist)
484 const tick = await read($, tickAtom)
485
486 const button = (
487 <Button key="clean-view-toggle" label={isOn ? '● Clean View: ON' : '○ Clean View: OFF'} onPress={() => void toggle($)} />
488 )
489 const plainRow = (left: RenderChildren) => (
490 <Box flexDirection="row" justifyContent="space-between">
491 <Box flexShrink={1}>{left}</Box>
492 {button}
493 </Box>
494 )
495 if (!isOn || list === null) return plainRow(<Text> </Text>)
496
497 const now = await $.clock.now()
498 const took = duration((list.finishedAt ?? now) - list.startedAt)
499 const accent = {
500 'working': PALETTE.pink,
501 'needs-you': PALETTE.yellow,
502 'stuck': PALETTE.red,
503 'stopped': PALETTE.subtle,
504 'done': PALETTE.green,
505 }[list.phase]
506 const isAnimated = list.phase === 'working' || list.phase === 'needs-you'
507
508 const words = list.title.split(' ')
509 const title = words.map((word, i) => (
510 <Text bold color={rampAt(TITLE_RAMP, words.length === 1 ? 0 : i / (words.length - 1))}>
511 {i === 0 ? word : ` ${word}`}
512 </Text>
513 ))
514 const header = (() => {
515 switch (list.phase) {
516 case 'needs-you':
517 return (
518 <Text wrap="truncate-end">
519 <Text inverse bold color={PALETTE.yellow}> Needs you </Text>
520 <Text color={PALETTE.text}> {list.needsYouReason ?? ''}</Text>
521 </Text>
522 )
523 case 'stuck':
524 return <Text wrap="truncate-end" bold color={PALETTE.red}>⚠ Stuck: {list.stuckReason ?? ''}</Text>
525 case 'stopped':
526 return (
527 <Text wrap="truncate-end" color={PALETTE.subtle}>
528 ■ Stopped · {title} · you pressed Esc
529 </Text>
530 )
531 case 'done':
532 return (
533 <Text wrap="truncate-end">
534 <Text bold color={PALETTE.green}>✓ All done · </Text>
535 {title}
536 <Text color={PALETTE.subtle}> · took {took}</Text>
537 </Text>
538 )
539 default:
540 return (
541 <Text wrap="truncate-end">
542 <Text color={accent}>{SPARKLE[tick % SPARKLE.length]} </Text>
543 {title}
544 </Text>
545 )
546 }
547 })()
548 const headerRow = (
549 <Box flexDirection="row" justifyContent="space-between">
550 <Box flexShrink={1}>{header}</Box>
551 <Box flexDirection="row" gap={1} flexShrink={0}>
552 {list.phase === 'working' && <Text color={PALETTE.subtle}>{took}</Text>}
553 {button}
554 </Box>
555 </Box>
556 )
557 // Collapsed, the finished job keeps its frame: one line inside the green border.
558 if (list.phase === 'done' && list.isCollapsed) {
559 return (
560 <Box flexDirection="column" borderStyle="round" borderColor={accent} paddingX={1}>
561 {headerRow}
562 </Box>
563 )
564 }
565
566 const total = list.tasks.length
567 const doneCount = list.tasks.filter(t => t.status === 'done').length
568 const active = list.tasks.find(t => t.status === 'active')
569 const overall = total === 0 ? 0 : Math.round((doneCount * 100 + (active?.percent ?? 0)) / total)
570 const stepOf = Math.min(total, doneCount + (active ? 1 : 0))
571 const stepText = `Step ${Math.max(1, stepOf)} of ${total}`
572 const barWidth = METER
573 const barFilled = Math.round((overall / 100) * barWidth)
574 const barRamp = {
575 'working': [PALETTE.peach, PALETTE.pink],
576 'needs-you': [PALETTE.peach, PALETTE.yellow],
577 'stuck': [PALETTE.peach, PALETTE.red],
578 'stopped': [PALETTE.track, PALETTE.subtle],
579 'done': ['#40A02B', PALETTE.green],
580 }[list.phase]
581
582 // border 2 + padding 2, then glyph 2 + name + space + meter 10 + two spaces + label 7
583 const nameWidth = Math.max(6, e.props.bodyColumns - 4 - 2 - 1 - METER - 2 - 7)
584 const firstUpcoming = list.tasks.findIndex(t => t.status === 'upcoming')
585 const cells = (task: Task) => meter(task, tick, isAnimated).map(cell => <Text color={cell.color}>{cell.char}</Text>)
586
587 return (
588 <Box flexDirection="column" borderStyle="round" borderColor={accent} paddingX={1}>
589 {headerRow}
590 <Text wrap="truncate-end">
591 <Text color={PALETTE.subtle}>{stepText} </Text>
592 {Array.from({ length: barWidth }, (_, i) =>
593 // A space between cells so the slanted glyphs don't run together.
594 i < barFilled ? <Text color={rampAt(barRamp, i / (barWidth - 1))}>{i === 0 ? '▰' : ' ▰'}</Text> : <Text color={PALETTE.track}>{i === 0 ? '▱' : ' ▱'}</Text>,
595 )}
596 <Text color={accent}> {overall}%</Text>
597 </Text>
598 {list.tasks.map((task, i) => {
599 const name = fit(task.name, nameWidth)
600 if (task.status === 'done') {
601 return (
602 <Text wrap="truncate-end">
603 <Text color={PALETTE.green}>✓ </Text>
604 <Text color={PALETTE.subtle}>{name} </Text>
605 {cells(task)}
606 <Text color={PALETTE.green}> {label(task, false)}</Text>
607 </Text>
608 )
609 }
610 if (task.status === 'active') {
611 const isWaiting = list.phase === 'needs-you'
612 return (
613 <Text wrap="truncate-end">
614 <Text color={isWaiting ? PALETTE.yellow : PALETTE.pink}>{isWaiting ? '‖ ' : '▶ '}</Text>
615 <Text bold color={PALETTE.text}>{name} </Text>
616 {cells(task)}
617 <Text bold color={accent}> {label(task, false)}</Text>
618 </Text>
619 )
620 }
621 return (
622 <Text wrap="truncate-end" color={PALETTE.subtle}>
623 ○ {name} {cells(task)} {label(task, i === firstUpcoming)}
624 </Text>
625 )
626 })}
627 </Box>
628 )
629 })
630}
631types/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). */
23 isPlanned: boolean
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'oli-claude-mod-clean-view': {
29 cleanViewEnabled: boolean
30 checklist: CleanViewChecklist | null
31 tick: number
32 }
33 }
34}
35