A calm, friendly view: hides tool rows and shows one plain-English checklist of the plan above the prompt

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, and one plain-English checklist sits above the prompt:
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
plan_steps tool and reports progress with report_progress; its to-do list (TodoWrite, TaskCreate, TaskUpdate) fills the checklist too./simple on|off (no argument flips it), turns it on and off. The choice is remembered across restarts. Turned off, every hidden row comes back.Each new job's name comes from a small, low-effort Haiku request, which uses a little of your usage.
From a terminal Claude Code session:
/plugin install clean-view --marketplace anunes9/claude-plugins
Answer y to add the marketplace, then pick the scope (user scope loads it in every session). To try it from a local checkout without installing: claude --plugin-dir plugins/clean-view.
claude plugin validate .
claude plugin test .
hooks/register.tsx calls registerCleanView(on) from hooks/clean-view.tsx; more mods can be registered beside it. Other mods can read the checklist from state under clean-view (cleanViewEnabled, checklist, tick), typed in types/index.d.ts.
hooks/register.tsx 8 lines1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5export const register: Register = on => {
6 registerCleanView(on)
7}
8hooks/clean-view.tsx 482 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import type { Checklist, ChecklistTask } from '../types'
5import {
6 addTask,
7 apiErrorReason,
8 applyPlan,
9 applyProgress,
10 applyTodos,
11 cleanName,
12 fit,
13 formatDuration,
14 hasUnfinished,
15 isUserRefusal,
16 meter,
17 newJob,
18 sweep,
19 titleFromPrompt,
20 updateTask,
21} from './checklist'
22
23const PLUGIN = 'clean-view'
24const STORE_KEY = 'cleanViewEnabled'
25const PLAN_TOOL = `mcp__${PLUGIN}__plan_steps`
26const PROGRESS_TOOL = `mcp__${PLUGIN}__report_progress`
27const ALWAYS_ALLOWED = new Set(['ToolSearch', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'AskUserQuestion', PLAN_TOOL])
28const FRAME_MS = 250
29const COLLAPSE_MS = 5000
30const FAILURES_BEFORE_STUCK = 3
31
32const NEEDS_OK = 'Claude needs your OK to continue'
33const HAS_QUESTION = 'Claude has a question for you'
34const WAITING = 'Claude is waiting for your reply'
35const SAID_NO = 'you said no to a step, so Claude paused'
36const KEEPS_FAILING = 'a step keeps failing, Claude is trying another way'
37const REFUSED = "Claude couldn't help with that request"
38
39const enabled = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
40const checklist = atom({ plugin: 'clean-view', key: 'checklist' } as const, null)
41const tick = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
42
43const PROMPT_SECTION = `# Clean View: show the person a plain-English plan
44
45The person is not technical. They see a checklist of your steps instead of your tool calls.
46
47- For every request, even a quick question, call \`${PLAN_TOOL}\` first, with every step of the job in order (2 to 8 steps). If it is deferred, load it with ToolSearch first.
48- 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".
49- Never put file paths, file names, commands, code or tool names in a step name.
50- Call \`${PROGRESS_TOOL}\` with the step's exact name and a percent as real progress happens, and with 100 the moment a step finishes.
51- If you have TodoWrite or TaskCreate, you may use your to-do list as the plan instead; the same naming rules apply.`
52
53type Engine = EngineInterface
54
55// Module variables reset on a hot reload; everything drawn lives in $.state.
56let ticker: { cancel: () => void } | undefined
57let collapseTimer: { cancel: () => void } | undefined
58let failuresInARow = 0
59let lastApiError: { kind: string; details: string } | null = null
60
61
62async function syncTimers($: Engine) {
63 const list = await read($, checklist)
64 const isAnimating = list !== null && (list.phase === 'working' || list.phase === 'needs-you')
65 if (isAnimating && ticker === undefined) {
66 ticker = $.clock.every(FRAME_MS, () => {
67 void update($, tick, n => n + 1)
68 })
69 } else if (!isAnimating && ticker !== undefined) {
70 ticker.cancel()
71 ticker = undefined
72 }
73}
74
75async function settle($: Engine, fn: (list: Checklist) => Checklist) {
76 await update($, checklist, list => (list === null ? null : fn(list)))
77 await syncTimers($)
78}
79
80async function startJob($: Engine, text: string) {
81 const startedAt = await $.clock.now()
82 const jobId = `job-${startedAt}-${Math.random().toString(36).slice(2, 8)}`
83 collapseTimer?.cancel()
84 collapseTimer = undefined
85 failuresInARow = 0
86 await update($, checklist, () => newJob(jobId, titleFromPrompt(text), startedAt))
87 await syncTimers($)
88 void nameJob($, jobId, text)
89
90 return jobId
91}
92
93async function nameJob($: Engine, jobId: string, text: string) {
94 try {
95 const reply = await $.model.complete({
96 model: 'haiku',
97 effort: 'low',
98 maxTokens: 30,
99 system:
100 'Name the job in the request in 2 to 6 plain English words that start with a verb, like "Build my landing page". No code, file names or punctuation. Reply with the name alone.',
101 prompt: text.slice(0, 2000),
102 })
103 if (!reply.isAnswered) {
104 return
105 }
106 const words = reply.text.replace(/["'.]/g, '').trim().split(/\s+/).slice(0, 6).join(' ')
107 const title = cleanName(words)
108 await update($, checklist, list => (list === null || list.jobId !== jobId ? list : { ...list, title }))
109 } catch {
110 // The header keeps the name taken from the prompt.
111 }
112}
113
114function needsYou(reason: string) {
115 return (list: Checklist): Checklist =>
116 list.finishedAt !== null ? list : { ...list, phase: 'needs-you', needsYouReason: reason }
117}
118
119const backToWork = (list: Checklist): Checklist =>
120 list.phase === 'needs-you' ? { ...list, phase: 'working', needsYouReason: null } : list
121
122const stuck = (reason: string) => (list: Checklist): Checklist => ({ ...list, phase: 'stuck', stuckReason: reason })
123
124async function setEnabled($: Engine, isOn: boolean) {
125 await update($, enabled, () => isOn)
126 await $.store.set(STORE_KEY, isOn)
127 $.ui.toast(isOn ? 'Clean View is on: tool details are hidden' : 'Clean View is off: showing everything')
128}
129
130export function registerCleanView(on: On): void {
131 on('session.start', async ($, e, next) => {
132 const saved = await $.store.get(STORE_KEY)
133 if (typeof saved === 'boolean') {
134 await update($, enabled, () => saved)
135 }
136 await $.tool.register({
137 name: 'plan_steps',
138 description:
139 '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, starting with a verb, with no file names, paths, commands or code. The first step starts right away.',
140 inputSchema: {
141 type: 'object',
142 properties: {
143 steps: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8 },
144 },
145 required: ['steps'],
146 },
147 isDeferred: false,
148 })
149 await $.tool.register({
150 name: 'report_progress',
151 description:
152 'Report progress on the current step of the plan, by its exact name, with a percent from 0 to 100. Report 100 the moment a step finishes; the next step then starts by itself.',
153 inputSchema: {
154 type: 'object',
155 properties: {
156 task: { type: 'string' },
157 percent: { type: 'number', minimum: 0, maximum: 100 },
158 },
159 required: ['task', 'percent'],
160 },
161 isDeferred: false,
162 })
163 await $.command.register({
164 name: 'simple',
165 description: 'Turn Clean View on or off (on | off; no argument flips it)',
166 argumentHint: 'on|off',
167 })
168 await syncTimers($)
169
170 return next(e)
171 })
172
173 on('command.run', { command: 'simple' }, async ($, e) => {
174 const arg = e.args.trim().toLowerCase()
175 if (arg !== '' && arg !== 'on' && arg !== 'off') {
176 return { text: 'Use /simple on, /simple off, or /simple to flip it.' }
177 }
178 const isOn = arg === '' ? !(await read($, enabled)) : arg === 'on'
179 await setEnabled($, isOn)
180
181 return { text: isOn ? 'Clean View is on.' : 'Clean View is off.' }
182 })
183
184 on('prompt.compose', async ($, e, next) => {
185 const composed = await next(e)
186 if (!(await read($, enabled))) {
187 return composed
188 }
189
190 return {
191 sections: [
192 ...composed.sections.filter(one => one.id !== 'clean-view:plan'),
193 { id: 'clean-view:plan', text: PROMPT_SECTION, scope: 'session' as const },
194 ],
195 }
196 })
197
198 on('turn.start', async ($, e, next) => {
199 lastApiError = null
200 const text = e.text.trim()
201 if (text !== '' && !text.startsWith('/')) {
202 const list = await read($, checklist)
203 if (list === null || list.finishedAt !== null) {
204 await startJob($, text)
205 } else {
206 await settle($, list => ({ ...backToWork(list), isCollapsed: false }))
207 }
208 }
209
210 return next(e)
211 })
212
213 on('tool.call', { tool: PLAN_TOOL }, async ($, e) => {
214 const input = e as unknown as { steps?: unknown }
215 const steps = Array.isArray(input.steps) ? input.steps.filter((s): s is string => typeof s === 'string') : []
216 if (steps.length === 0) {
217 return { deny: 'plan_steps needs a list of 2 to 8 short step names.' }
218 }
219 if ((await read($, checklist)) === null) {
220 await startJob($, steps[0] ?? '')
221 }
222 await settle($, list => applyPlan(backToWork(list), steps))
223 const count = Math.min(steps.length, 8)
224
225 return { result: `Planned ${count} steps. The first one has started.` }
226 })
227
228 on('tool.call', { tool: PROGRESS_TOOL }, async ($, e) => {
229 const input = e as unknown as { task?: unknown; percent?: unknown }
230 const name = typeof input.task === 'string' ? input.task : ''
231 const raw = typeof input.percent === 'number' ? input.percent : Number(input.percent)
232 const percent = Number.isFinite(raw) ? Math.min(100, Math.max(0, Math.round(raw))) : 0
233 await settle($, list => applyProgress(backToWork(list), name, percent))
234
235 return { result: `Progress noted: ${percent}%.` }
236 })
237
238 // The main agent's tools: plan first while Clean View is on, then fill the
239 // checklist from what Claude already does and notice trouble.
240 on('tool.call', async ($, e, next) => {
241 if (e.agentId !== undefined || e.tool === PLAN_TOOL || e.tool === PROGRESS_TOOL) {
242 return next(e)
243 }
244 if (!ALWAYS_ALLOWED.has(e.tool) && (await read($, enabled))) {
245 const list = await read($, checklist)
246 if (list === null || !list.hasPlan) {
247 return {
248 deny: `Clean View: call ${PLAN_TOOL} first to lay out the plan in plain English (load it with ToolSearch if it is deferred), then try again.`,
249 }
250 }
251 }
252 const isQuestion = e.tool === 'AskUserQuestion'
253 await settle($, isQuestion ? needsYou(HAS_QUESTION) : backToWork)
254 const ran = await next(e)
255 if (ran.deny !== undefined || ran.isError === true) {
256 const text = ran.deny ?? ran.text ?? ''
257 failuresInARow += 1
258 if (isUserRefusal(text)) {
259 await settle($, stuck(SAID_NO))
260 } else if (failuresInARow >= FAILURES_BEFORE_STUCK) {
261 await settle($, stuck(KEEPS_FAILING))
262 } else if (isQuestion) {
263 await settle($, backToWork)
264 }
265
266 return ran
267 }
268 failuresInARow = 0
269 await settle($, list => {
270 let updated = list.phase === 'stuck' ? { ...list, phase: 'working' as const, stuckReason: null } : backToWork(list)
271 if (e.tool === 'TodoWrite') {
272 updated = applyTodos(updated, e.todos)
273 } else if (e.tool === 'TaskCreate') {
274 const made = ran.result as { task?: { id?: string } } | undefined
275 if (made?.task?.id !== undefined) {
276 updated = addTask(updated, made.task.id, e.subject)
277 }
278 } else if (e.tool === 'TaskUpdate') {
279 updated = updateTask(updated, e)
280 }
281
282 return updated
283 })
284
285 return ran
286 }).catch(($, e, next) =>
287 // The plan-first rule is a nudge, not a safety guard: if this hook fails, let the tool run.
288 next(e),
289 )
290
291 on('classic.Notification', async ($, e, next) => {
292 const kind = `${e.notification_type} ${e.message}`.toLowerCase()
293 if (/permission|approv/.test(kind)) {
294 await settle($, needsYou(NEEDS_OK))
295 } else if (/elicitation|question/.test(kind)) {
296 await settle($, needsYou(HAS_QUESTION))
297 }
298
299 return next(e)
300 })
301
302 on('classic.StopFailure', async ($, e, next) => {
303 lastApiError = { kind: e.error, details: e.error_details ?? '' }
304
305 return next(e)
306 })
307
308 on('turn.complete', async ($, e, next) => {
309 if (e.agentId !== undefined) {
310 return next(e)
311 }
312 const list = await read($, checklist)
313 if (list !== null && list.finishedAt === null) {
314 const now = await $.clock.now()
315 const finish = (one: Checklist): Checklist => ({ ...one, finishedAt: now, needsYouReason: null })
316 if (e.reason === 'error') {
317 const reason = apiErrorReason(lastApiError?.kind ?? null, lastApiError?.details ?? e.answer)
318 await settle($, one => finish(stuck(reason)(one)))
319 } else if (e.reason === 'refusal') {
320 await settle($, one => finish(stuck(REFUSED)(one)))
321 } else if (e.reason === 'aborted' || e.isAborted) {
322 if (list.phase !== 'stuck' || list.stuckReason !== SAID_NO) {
323 await settle($, one => ({ ...finish(one), phase: 'stopped' }))
324 }
325 } else if (list.hasPlan && hasUnfinished(list)) {
326 await settle($, needsYou(WAITING))
327 } else {
328 const jobId = list.jobId
329 await settle($, one => ({
330 ...finish(one),
331 phase: 'done',
332 stuckReason: null,
333 tasks: one.hasPlan ? one.tasks : [],
334 }))
335 collapseTimer?.cancel()
336 collapseTimer = $.clock.after(COLLAPSE_MS, () => {
337 void update($, checklist, one =>
338 one !== null && one.jobId === jobId && one.phase === 'done' ? { ...one, isCollapsed: true } : one,
339 )
340 })
341 }
342 }
343 lastApiError = null
344
345 return next(e)
346 })
347
348 // Hide the technical rows while Clean View is on.
349 on('ui.render', { component: ['ToolUse', 'ToolResult', 'ToolGroup'] }, async ($, e, next) => {
350 if (!(await read($, enabled))) {
351 return next(e)
352 }
353 const { Box } = $.ui.resolve(e)
354
355 return <Box display="none" />
356 })
357
358 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) =>
359 (await read($, enabled)) ? next({ ...e, props: { ...e.props, hint: '' } }) : next(e),
360 )
361
362 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
363 if (e.props.hasSurvey) {
364 return next(e)
365 }
366 const { Box, Button, Text } = $.ui.resolve(e)
367 const isOn = await read($, enabled)
368 const list = isOn ? await read($, checklist) : null
369 const frame = await read($, tick)
370 const columns = Math.max(20, e.props.bodyColumns)
371
372 const toggle = (
373 <Button
374 key="clean-view-toggle"
375 label={isOn ? '● Clean View: ON' : '○ Clean View: OFF'}
376 variant={isOn ? 'primary' : 'secondary'}
377 onPress={() => setEnabled($, !isOn)}
378 />
379 )
380 const headerRow = (left: unknown) => (
381 <Box key="header" flexDirection="row" justifyContent="space-between" width={columns}>
382 <Box flexShrink={1}>{left as never}</Box>
383 {toggle}
384 </Box>
385 )
386 if (list === null) {
387 return <Box flexDirection="column">{headerRow(<Text> </Text>)}</Box>
388 }
389
390 const now = await $.clock.now()
391 const took = formatDuration((list.finishedAt ?? now) - list.startedAt)
392 const header = (() => {
393 switch (list.phase) {
394 case 'needs-you':
395 return (
396 <Text wrap="truncate-end">
397 <Text backgroundColor="warning" color="inverseText" bold>
398 {' Needs you '}
399 </Text>
400 {` ${list.needsYouReason ?? NEEDS_OK}`}
401 </Text>
402 )
403 case 'stuck':
404 return (
405 <Text wrap="truncate-end" color="warning">
406 {`⚠ Stuck: ${list.stuckReason ?? KEEPS_FAILING}`}
407 </Text>
408 )
409 case 'stopped':
410 return <Text wrap="truncate-end">{`■ Stopped · ${list.title} · you pressed Esc`}</Text>
411 case 'done':
412 return (
413 <Text wrap="truncate-end">
414 <Text color="success">✓ All done</Text>
415 {` · ${list.title} · took ${took}`}
416 </Text>
417 )
418 default:
419 return (
420 <Text wrap="truncate-end">
421 <Text bold>{list.title}</Text>
422 <Text dimColor>{` · ${took}`}</Text>
423 </Text>
424 )
425 }
426 })()
427
428 if (list.phase === 'done' && (list.isCollapsed || list.tasks.length === 0)) {
429 return <Box flexDirection="column">{headerRow(header)}</Box>
430 }
431
432 // glyph + space, meter, two spaces, a 7-cell label: the name gets the rest.
433 const nameWidth = Math.max(6, Math.min(40, columns - 2 - 1 - 10 - 2 - 7))
434 let isFirstUpcoming = true
435 const rows = list.tasks.map((one: ChecklistTask) => {
436 const name = fit(one.name, nameWidth)
437 if (one.status === 'done') {
438 return (
439 <Box key={`row-${one.id}`}>
440 <Text wrap="truncate-end">
441 <Text color="success">✓</Text>
442 <Text dimColor>{` ${name} `}</Text>
443 <Text color="success">{meter(100)}</Text>
444 <Text dimColor>{' Done'}</Text>
445 </Text>
446 </Box>
447 )
448 }
449 if (one.status === 'active') {
450 const isPaused = list.phase === 'needs-you'
451 const hasPercent = one.hasReported
452 return (
453 <Box key={`row-${one.id}`}>
454 <Text wrap="truncate-end">
455 <Text color="claude">{isPaused ? '‖' : '▶'}</Text>
456 <Text bold>{` ${name} `}</Text>
457 <Text color="claude">{hasPercent ? meter(one.percent) : sweep(frame)}</Text>
458 <Text>{hasPercent ? ` ${one.percent}%` : ' Working'}</Text>
459 </Text>
460 </Box>
461 )
462 }
463 const label = isFirstUpcoming ? 'Next' : 'Up next'
464 isFirstUpcoming = false
465 return (
466 <Box key={`row-${one.id}`}>
467 <Text wrap="truncate-end" dimColor>
468 {`○ ${name} ${meter(0)} ${label}`}
469 </Text>
470 </Box>
471 )
472 })
473
474 return (
475 <Box flexDirection="column">
476 {headerRow(header)}
477 {rows}
478 </Box>
479 )
480 })
481}
482hooks/checklist.ts 246 lines1import type { Checklist, ChecklistTask } from '../types'
2
3export const MAX_NAME = 40
4export const FALLBACK_NAME = 'Working on it'
5export const PLACEHOLDERS = ['Understand your request', 'Plan the steps']
6
7const CODE_FILE =
8 /\.(tsx?|jsx?|mjs|cjs|py|rb|go|rs|java|kt|swift|c|h|cpp|hpp|cs|php|sh|zsh|bash|json|ya?ml|toml|css|scss|html|md|sql|lock|env|vue|svelte)\b/i
9
10/** Turns any name into plain words: no code, paths or file names; at most 40 characters. */
11export function cleanName(raw: string): string {
12 const words = raw
13 .replace(/`[^`]*`/g, ' ')
14 .replace(/`/g, ' ')
15 .split(/\s+/)
16 .filter(word => word !== '' && !word.includes('/') && !CODE_FILE.test(word))
17 let name = words.join(' ').trim()
18 if (name.length > MAX_NAME) {
19 const cut = name.slice(0, MAX_NAME - 1)
20 const space = cut.lastIndexOf(' ')
21 name = `${(space > 0 ? cut.slice(0, space) : cut).replace(/[\s,.;:–-]+$/, '')}…`
22 }
23 if (name === '' || name === '…') {
24 return FALLBACK_NAME
25 }
26
27 return name.charAt(0).toUpperCase() + name.slice(1)
28}
29
30/** A short header name from the prompt itself, used until Haiku answers. */
31export function titleFromPrompt(text: string): string {
32 return cleanName(text.split(/\s+/).slice(0, 6).join(' '))
33}
34
35export function clampPercent(percent: number): number {
36 return Number.isFinite(percent) ? Math.min(100, Math.max(0, Math.round(percent))) : 0
37}
38
39function task(id: string, name: string, status: ChecklistTask['status']): ChecklistTask {
40 return { id, name, status, percent: status === 'done' ? 100 : 0, hasReported: false }
41}
42
43export function newJob(jobId: string, title: string, startedAt: number): Checklist {
44 return {
45 jobId,
46 title,
47 phase: 'working',
48 tasks: PLACEHOLDERS.map((name, i) => task(`placeholder-${i}`, name, i === 0 ? 'active' : 'upcoming')),
49 hasPlan: false,
50 needsYouReason: null,
51 stuckReason: null,
52 startedAt,
53 finishedAt: null,
54 isCollapsed: false,
55 }
56}
57
58export function applyPlan(list: Checklist, steps: readonly string[]): Checklist {
59 const tasks = steps
60 .slice(0, 8)
61 .map((step, i) => task(`step-${i}`, cleanName(step), i === 0 ? 'active' : 'upcoming'))
62
63 return { ...list, tasks, hasPlan: true }
64}
65
66function activate(tasks: ChecklistTask[], index: number): ChecklistTask[] {
67 return tasks.map((one, i): ChecklistTask => (i === index ? { ...one, status: 'active' } : one))
68}
69
70const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
71
72/** Checks off everything before the reported step, and moves on when it reaches 100. */
73export function applyProgress(list: Checklist, name: string, rawPercent: number): Checklist {
74 const percent = clampPercent(rawPercent)
75 const clean = cleanName(name)
76 let tasks = [...list.tasks]
77 let index = tasks.findIndex(one => same(one.name, clean))
78 if (index === -1) {
79 const firstOpen = tasks.findIndex(one => one.status !== 'done')
80 index = firstOpen === -1 ? tasks.length : firstOpen
81 tasks.splice(index, 0, task(`extra-${tasks.length}-${clean}`, clean, 'active'))
82 }
83 const isFinished = percent >= 100
84 tasks = tasks.map((one, i) => {
85 if (i < index) {
86 return { ...one, status: 'done', percent: 100 }
87 }
88 if (i === index) {
89 return isFinished
90 ? { ...one, status: 'done', percent: 100, hasReported: true }
91 : { ...one, status: 'active', percent, hasReported: true }
92 }
93
94 return one.status === 'active' ? { ...one, status: 'upcoming' } : one
95 })
96 if (isFinished) {
97 tasks = activate(tasks, tasks.findIndex((one, i) => i > index && one.status === 'upcoming'))
98 }
99
100 return { ...list, tasks }
101}
102
103type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed' }
104
105export function applyTodos(list: Checklist, todos: readonly Todo[]): Checklist {
106 if (todos.length === 0) {
107 return list
108 }
109 const tasks = todos.map((todo, i): ChecklistTask => {
110 const name = cleanName(todo.content)
111 const before = list.tasks.find(one => same(one.name, name))
112 if (todo.status === 'completed') {
113 return { id: `todo-${i}`, name, status: 'done', percent: 100, hasReported: true }
114 }
115 if (todo.status === 'in_progress') {
116 return {
117 id: `todo-${i}`,
118 name,
119 status: 'active',
120 percent: before?.hasReported ? before.percent : 0,
121 hasReported: before?.hasReported ?? false,
122 }
123 }
124
125 return task(`todo-${i}`, name, 'upcoming')
126 })
127
128 return { ...list, tasks, hasPlan: true }
129}
130
131export function addTask(list: Checklist, id: string, subject: string): Checklist {
132 const kept = list.hasPlan ? list.tasks : []
133 let tasks = [...kept, task(`task-${id}`, cleanName(subject), 'upcoming')]
134 if (!tasks.some(one => one.status === 'active')) {
135 tasks = activate(tasks, tasks.findIndex(one => one.status === 'upcoming'))
136 }
137
138 return { ...list, tasks, hasPlan: true }
139}
140
141type TaskChange = {
142 taskId: string
143 subject?: string
144 status?: 'pending' | 'in_progress' | 'completed' | 'deleted'
145}
146
147export function updateTask(list: Checklist, change: TaskChange): Checklist {
148 const id = `task-${change.taskId}`
149 if (change.status === 'deleted') {
150 return { ...list, tasks: list.tasks.filter(one => one.id !== id) }
151 }
152 let tasks = list.tasks.map((one): ChecklistTask => {
153 if (one.id !== id) {
154 return change.status === 'in_progress' && one.status === 'active' ? { ...one, status: 'upcoming' } : one
155 }
156 const name = change.subject === undefined ? one.name : cleanName(change.subject)
157 if (change.status === 'completed') {
158 return { ...one, name, status: 'done', percent: 100 }
159 }
160 if (change.status === 'in_progress') {
161 return { ...one, name, status: 'active' }
162 }
163 if (change.status === 'pending') {
164 return { ...one, name, status: 'upcoming' }
165 }
166
167 return { ...one, name }
168 })
169 if (change.status === 'completed' && !tasks.some(one => one.status === 'active')) {
170 tasks = activate(tasks, tasks.findIndex(one => one.status === 'upcoming'))
171 }
172
173 return { ...list, tasks }
174}
175
176export function hasUnfinished(list: Checklist): boolean {
177 return list.tasks.some(one => one.status !== 'done')
178}
179
180export function formatDuration(ms: number): string {
181 const seconds = Math.max(0, Math.floor(ms / 1000))
182 if (seconds < 60) {
183 return `${seconds}s`
184 }
185 const minutes = Math.floor(seconds / 60)
186 if (minutes < 60) {
187 return `${minutes}m ${seconds % 60}s`
188 }
189
190 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
191}
192
193export const METER_CELLS = 10
194
195export function meter(percent: number): string {
196 const filled = Math.round(clampPercent(percent) / 10)
197
198 return '█'.repeat(filled) + '░'.repeat(METER_CELLS - filled)
199}
200
201/** A three-cell block bouncing across the meter, for a step with no percent yet. */
202export function sweep(frame: number): string {
203 const span = METER_CELLS - 3
204 const step = frame % (span * 2)
205 const at = step <= span ? step : span * 2 - step
206
207 return '░'.repeat(at) + '▓▓▓' + '░'.repeat(METER_CELLS - 3 - at)
208}
209
210/** Fits a name into its column, cut with an ellipsis, padded so the meters line up. */
211export function fit(name: string, width: number): string {
212 const fitted = name.length > width ? `${name.slice(0, Math.max(1, width - 1)).trimEnd()}…` : name
213
214 return fitted.padEnd(width)
215}
216
217type ApiErrorKind = string | null
218
219/** One calm sentence for an API error that ended the turn. */
220export function apiErrorReason(kind: ApiErrorKind, details: string): string {
221 const text = `${kind ?? ''} ${details}`.toLowerCase()
222 if (/rate_limit|rate limit|usage limit|429/.test(text)) {
223 return 'you hit your usage limit, try again a little later'
224 }
225 if (/overloaded|server_error|529|503|500/.test(text)) {
226 return "Claude's servers are busy, try again in a minute"
227 }
228 if (/too long|context|max_tokens|prompt_too_long/.test(text)) {
229 return 'the conversation got too long, type /compact and try again'
230 }
231 if (/authentication|oauth|login|401|403|credential|account_on_hold/.test(text)) {
232 return "you're signed out, type /login"
233 }
234 if (/network|connect|econn|fetch failed|timed? ?out|socket|offline|dns/.test(text)) {
235 return 'the internet connection dropped'
236 }
237
238 return 'something went wrong, try again in a moment'
239}
240
241export function isUserRefusal(text: string): boolean {
242 return /user (doesn't|does not) want|user (rejected|denied|declined)|permission (was )?denied|rejected by the user/i.test(
243 text,
244 )
245}
246types/index.d.ts 35 lines1export type ChecklistTaskStatus = 'done' | 'active' | 'upcoming'
2
3export type ChecklistTask = {
4 id: string
5 name: string
6 status: ChecklistTaskStatus
7 percent: number
8 hasReported: boolean
9}
10
11export type ChecklistPhase = 'working' | 'needs-you' | 'stuck' | 'stopped' | 'done'
12
13export type Checklist = {
14 jobId: string
15 title: string
16 phase: ChecklistPhase
17 tasks: ChecklistTask[]
18 hasPlan: boolean
19 needsYouReason: string | null
20 stuckReason: string | null
21 startedAt: number
22 finishedAt: number | null
23 isCollapsed: boolean
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'clean-view': {
29 cleanViewEnabled: boolean
30 checklist: Checklist | null
31 tick: number
32 }
33 }
34}
35