Hides tool calls and output while Claude works, and shows a plain-English checklist of the plan above the prompt.

Hides tool calls and output while Claude works; shows a plain checklist above the prompt.
Clean View: ON/OFF button at the right of the band above the prompt, or run /simple on, /simple off or /simple (flips it). The setting is saved across sessions. It starts on.plan_steps and report_progress, and blocks every other tool until a plan exists. A to-do list (TodoWrite, TaskCreate) also counts as the plan.[!WARNING] Where an organization manages Claude Code,
prompt.composeandclassic.Notificationhooks don't run in a mod you install. The system prompt section is then skipped; the tool descriptions and the plan-first gate still steer Claude to plan, and Needs you still shows for permission prompts throughtool.check.
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 415 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, Timer } from 'claude-code'
3
4import type { CleanViewChecklist, CleanViewTask } from '../types'
5import {
6 EMPTY,
7 METER_CELLS,
8 addTask,
9 clampPercent,
10 duration,
11 errorSentence,
12 fit,
13 fromTodos,
14 hasPlan,
15 isRunning,
16 isUserRejection,
17 label,
18 meter,
19 newJob,
20 planSteps,
21 reportProgress,
22 updateTask,
23} from './logic'
24
25const PLUGIN = 'clean-view'
26const COMMAND = 'simple'
27const STORE_KEY = 'cleanViewEnabled'
28const PLAN_TOOL = `mcp__${PLUGIN}__plan_steps`
29const PROGRESS_TOOL = `mcp__${PLUGIN}__report_progress`
30const FRAME_MS = 250
31const COLLAPSE_MS = 5000
32const FAILURES_TO_STUCK = 3
33const PLACEHOLDER_TITLE = 'Your request'
34const ALWAYS_ALLOWED = new Set(['ToolSearch', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'AskUserQuestion', PLAN_TOOL, PROGRESS_TOOL])
35
36const NEEDS_OK = 'Claude needs your OK to continue'
37const HAS_QUESTION = 'Claude has a question for you'
38const WAITING = 'Claude is waiting for your reply'
39const SAID_NO = 'you said no to a step, so Claude paused'
40const KEEPS_FAILING = 'a step keeps failing, Claude is trying another way'
41const REFUSED = "Claude couldn't help with that request"
42
43const enabled = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
44const checklist = atom({ plugin: 'clean-view', key: 'checklist' } as const, EMPTY)
45const tick = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
46
47const SECTION = `# Clean View checklist
48
49The person sees a plain checklist of your plan instead of your tool calls.
50
51- For every request, even a quick question, call \`${PLAN_TOOL}\` first with every step of the job, 2 to 8 short names in order. Load it with ToolSearch if it is deferred. If this session has TodoWrite or TaskCreate, you may use your to-do list as the plan instead.
52- Call \`${PROGRESS_TOOL}\` as real progress happens, and with percent 100 the moment a step finishes.
53- Write every step name in plain English a non-technical person understands, under 40 characters, starting with a verb, like "Build the pricing section".
54- Never put file paths, file names, commands, code or tool names in a step name.`
55
56type $ = EngineInterface
57
58let ticker: Timer | undefined
59let failures = 0
60let isNextTurnCommand = false
61
62function startTicker($: $) {
63 if (ticker !== undefined) return
64 ticker = $.clock.every(FRAME_MS, () => {
65 void update($, tick, n => (n ?? 0) + 1)
66 })
67}
68
69function stopTicker() {
70 ticker?.cancel()
71 ticker = undefined
72}
73
74async function isOn($: $) {
75 return read($, enabled)
76}
77
78async function edit($: $, fn: (list: CleanViewChecklist) => CleanViewChecklist) {
79 await update($, checklist, list => fn(list ?? EMPTY))
80}
81
82async function setEnabled($: $, value: boolean) {
83 await update($, enabled, () => value)
84 await $.store.set(STORE_KEY, value)
85 $.ui.toast(value ? 'Clean View on: showing the plan, hiding the technical details' : 'Clean View off: showing everything')
86}
87
88/** Asks Haiku for a short name for the job; drops the answer if a newer job started. */
89async function nameJob($: $, text: string, startedAt: number) {
90 const reply = await $.model.complete({
91 model: 'haiku',
92 effort: 'low',
93 maxTokens: 20,
94 timeoutMs: 8000,
95 system: 'You name jobs for a non-technical person. Reply with the name only: 2 to 6 plain words, starting with a verb. No code, file names or punctuation.',
96 prompt: text.slice(0, 2000),
97 })
98 if (!reply.isAnswered) return
99 const words = reply.text.replace(/["'.`*]/g, '').trim().split(/\s+/).slice(0, 6).join(' ')
100 if (words === '') return
101 const title = words[0].toUpperCase() + words.slice(1)
102 await edit($, list => (list.startedAt === startedAt ? { ...list, title } : list))
103}
104
105async function startJob($: $, text: string) {
106 const startedAt = await $.clock.now()
107 failures = 0
108 await edit($, () => newJob(startedAt, PLACEHOLDER_TITLE))
109 startTicker($)
110 if (text !== '') void nameJob($, text, startedAt).catch(() => undefined)
111}
112
113/** Starts a job for a plan that arrives while none runs (a skill, a slash command). */
114async function ensureJob($: $) {
115 if (!isRunning(await read($, checklist))) await startJob($, '')
116}
117
118function needsYou(reason: string) {
119 return (list: CleanViewChecklist): CleanViewChecklist =>
120 isRunning(list) ? { ...list, phase: 'needsYou', needsYouReason: reason } : list
121}
122
123function backToWork(list: CleanViewChecklist): CleanViewChecklist {
124 return list.phase === 'needsYou' && list.finishedAt === null ? { ...list, phase: 'working', needsYouReason: null } : list
125}
126
127function stuck(reason: string) {
128 return (list: CleanViewChecklist): CleanViewChecklist =>
129 isRunning(list) ? { ...list, phase: 'stuck', stuckReason: reason, needsYouReason: null } : list
130}
131
132function unstuck(list: CleanViewChecklist): CleanViewChecklist {
133 return list.phase === 'stuck' && list.finishedAt === null ? { ...list, phase: 'working', stuckReason: null } : list
134}
135
136async function finish($: $, fn: (list: CleanViewChecklist, now: number) => CleanViewChecklist) {
137 const now = await $.clock.now()
138 stopTicker()
139 await edit($, list => (list.phase === 'idle' ? list : fn({ ...list, finishedAt: now }, now)))
140}
141
142export function registerCleanView(on: On) {
143 on('session.start', async ($, e, next) => {
144 const saved = await $.store.get(STORE_KEY)
145 await update($, enabled, () => (typeof saved === 'boolean' ? saved : true))
146 await $.command.register({
147 name: COMMAND,
148 description: 'Clean View: hide tool details and show a plain checklist (on, off, or flip)',
149 argumentHint: 'on|off',
150 })
151 await $.tool.register({
152 name: 'plan_steps',
153 description:
154 'Lay out every step of the job up front so the person sees the plan: 2 to 8 short plain-English step names in order, each under 40 characters and starting with a verb. The first step starts right away.',
155 inputSchema: {
156 type: 'object',
157 properties: { steps: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8 } },
158 required: ['steps'],
159 },
160 isDeferred: false,
161 })
162 await $.tool.register({
163 name: 'report_progress',
164 description:
165 'Report progress on the current step by its plain-English name, with a percent from 0 to 100. Report 100 the moment a step finishes; the next one then starts.',
166 inputSchema: {
167 type: 'object',
168 properties: { task: { type: 'string' }, percent: { type: 'number', minimum: 0, maximum: 100 } },
169 required: ['task', 'percent'],
170 },
171 isDeferred: false,
172 })
173 if (isRunning(await read($, checklist))) startTicker($)
174 return next(e)
175 })
176
177 on('command.run', { command: COMMAND }, async ($, e) => {
178 const arg = e.args.trim().toLowerCase()
179 const value = arg === 'on' ? true : arg === 'off' ? false : !(await isOn($))
180 await setEnabled($, value)
181 return { text: value ? '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 isOn($))) return composed
187 return { sections: [...composed.sections, { id: `${PLUGIN}:checklist`, text: SECTION, scope: 'session' as const }] }
188 })
189
190 on('prompt.submit', async ($, e, next) => {
191 isNextTurnCommand = e.command !== undefined
192 return next(e)
193 }).catch(($, e, next) => next(e))
194
195 on('turn.start', async ($, e, next) => {
196 if (e.agentId === undefined) {
197 const isCommand = isNextTurnCommand
198 isNextTurnCommand = false
199 if (!isCommand && e.text.trim() !== '') await startJob($, e.text)
200 }
201 return next(e)
202 })
203
204 on('tool.call', async ($, e, next) => {
205 if (e.agentId !== undefined) return next(e)
206 const tool = String(e.tool)
207 const list = await read($, checklist)
208 const isEnabled = await isOn($)
209 if (isEnabled && isRunning(list) && !hasPlan(list) && !ALWAYS_ALLOWED.has(tool)) {
210 return { deny: `Clean View: call ${PLAN_TOOL} first to lay out the steps of this job (load it with ToolSearch if it is deferred), then try again.` }
211 }
212
213 await edit($, backToWork)
214 if (e.tool === 'TodoWrite') await edit($, current => fromTodos(current, e.todos))
215 if (e.tool === 'TaskUpdate') await edit($, current => updateTask(current, e.taskId, e.status, e.subject))
216 if (e.tool === 'AskUserQuestion') await edit($, needsYou(HAS_QUESTION))
217
218 const ran = await next(e)
219
220 await edit($, backToWork)
221 if (e.tool === 'TaskCreate' && ran.deny === undefined && ran.isError !== true) {
222 const made = ran.result as { task?: { id?: string; subject?: string } } | undefined
223 if (made?.task?.id !== undefined) await edit($, current => addTask(current, made.task!.id!, made.task!.subject ?? e.subject))
224 }
225 if (ran.deny !== undefined) return ran
226 if (ran.isError === true) {
227 if (isUserRejection(ran.text)) {
228 failures = 0
229 await edit($, stuck(SAID_NO))
230 } else if (++failures >= FAILURES_TO_STUCK) {
231 await edit($, stuck(KEEPS_FAILING))
232 }
233 } else {
234 failures = 0
235 await edit($, unstuck)
236 }
237 return ran
238 }).catch(($, e, next) => next(e))
239
240 // A permission dialog is about to open. classic.Notification says the same,
241 // but does not reach a mod where an organization manages Claude Code.
242 on('tool.check', async ($, e, next) => {
243 const verdict = await next(e)
244 if (verdict.decision === 'ask' && e.agentId === undefined && e.tool_use_id !== undefined) await edit($, needsYou(NEEDS_OK))
245 return verdict
246 }).catch(($, e, next) => next(e))
247
248 on('tool.call', { tool: PLAN_TOOL }, async ($, e) => {
249 const steps = (e as { steps?: unknown }).steps
250 const names = Array.isArray(steps) ? steps.filter((s): s is string => typeof s === 'string') : []
251 await ensureJob($)
252 await edit($, list => planSteps(list, names))
253 const count = (await read($, checklist)).tasks.length
254 return { result: `Planned ${count} steps. The first one has started.` }
255 })
256
257 on('tool.call', { tool: PROGRESS_TOOL }, async ($, e) => {
258 const input = e as { task?: unknown; percent?: unknown }
259 const name = typeof input.task === 'string' ? input.task : ''
260 const percent = typeof input.percent === 'number' ? input.percent : Number(input.percent)
261 await ensureJob($)
262 await edit($, list => reportProgress(list, name, percent))
263 return { result: `Progress noted: ${clampPercent(percent)}%.` }
264 })
265
266 on('classic.Notification', async ($, e, next) => {
267 if (e.notification_type === 'permission_prompt') await edit($, needsYou(NEEDS_OK))
268 if (e.notification_type === 'elicitation_dialog') await edit($, needsYou(HAS_QUESTION))
269 return next(e)
270 }).catch(($, e, next) => next(e))
271
272 on('turn.complete', async ($, e, next) => {
273 if (e.agentId !== undefined) return next(e)
274 if (e.reason === 'error') {
275 await finish($, list => ({ ...list, phase: 'stuck', stuckReason: errorSentence(e.answer), needsYouReason: null }))
276 } else if (e.reason === 'refusal') {
277 await finish($, list => ({ ...list, phase: 'stuck', stuckReason: REFUSED, needsYouReason: null }))
278 } else if (e.reason === 'aborted') {
279 await finish($, list => ({ ...list, phase: 'stopped', needsYouReason: null }))
280 } else {
281 await finish($, list => {
282 if (list.phase === 'stuck') return list
283 if (list.tasks.some(one => one.status !== 'done')) return { ...list, phase: 'needsYou', needsYouReason: WAITING }
284 return { ...list, phase: 'done', needsYouReason: null }
285 })
286 const done = await read($, checklist)
287 if (done.phase === 'done') {
288 $.clock.after(COLLAPSE_MS, () => {
289 void edit($, list => (list.startedAt === done.startedAt && list.phase === 'done' ? { ...list, isCollapsed: true } : list))
290 })
291 }
292 }
293 return next(e)
294 })
295
296 for (const component of ['ToolUse', 'ToolResult', 'ToolGroup'] as const) {
297 on('ui.render', { component }, async ($, e, next) => {
298 if (!(await isOn($))) return next(e)
299 const { Box } = $.ui.resolve(e)
300 return <Box display="none" />
301 })
302 }
303
304 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
305 if (!(await isOn($))) return next(e)
306 return next({ ...e, props: { ...e.props, hint: '' } })
307 })
308
309 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
310 if (e.props.hasSurvey) return next(e)
311 const { Box, Text, Button } = $.ui.resolve(e)
312 const isEnabled = await isOn($)
313 const button = (
314 <Button
315 key="clean-view-toggle"
316 label={isEnabled ? '● Clean View: ON' : '○ Clean View: OFF'}
317 onPress={() => setEnabled($, !isEnabled)}
318 />
319 )
320 const list = await read($, checklist)
321 if (!isEnabled || list.phase === 'idle') {
322 return (
323 <Box flexDirection="row" justifyContent="flex-end">
324 {button}
325 </Box>
326 )
327 }
328
329 const isLive = isRunning(list)
330 const frame = isLive ? await read($, tick) : 0
331 const now = isLive ? await $.clock.now() : (list.finishedAt ?? list.startedAt)
332 const took = duration(now - list.startedAt)
333 const header = headerOf(list, took, Text)
334
335 if (list.phase === 'done' && list.isCollapsed) {
336 return (
337 <Box flexDirection="row" justifyContent="space-between">
338 {header}
339 {button}
340 </Box>
341 )
342 }
343
344 // icon + space, name, space, 10-cell meter, two spaces, 7-cell label
345 const nameWidth = Math.max(8, e.props.bodyColumns - (2 + 1 + METER_CELLS + 2 + 7))
346 const firstUpcoming = list.tasks.findIndex(one => one.status === 'upcoming')
347 return (
348 <Box flexDirection="column">
349 <Box flexDirection="row" justifyContent="space-between">
350 {header}
351 {button}
352 </Box>
353 {list.tasks.map((one, i) => row(one, i === firstUpcoming, list.phase === 'needsYou', frame, nameWidth, Text))}
354 </Box>
355 )
356 })
357}
358
359type TextElement = ReturnType<EngineInterface['ui']['resolve']>['Text']
360
361function headerOf(list: CleanViewChecklist, took: string, Text: TextElement) {
362 switch (list.phase) {
363 case 'needsYou':
364 return (
365 <Text>
366 <Text bold color="black" backgroundColor="yellow">
367 {' Needs you '}
368 </Text>{' '}
369 {list.needsYouReason ?? WAITING}
370 </Text>
371 )
372 case 'stuck':
373 return <Text color="yellow">⚠ Stuck: {list.stuckReason}</Text>
374 case 'stopped':
375 return <Text>■ Stopped · {list.title} · you pressed Esc</Text>
376 case 'done':
377 return (
378 <Text>
379 <Text color="green">✓ All done</Text> · {list.title} · took {took}
380 </Text>
381 )
382 default:
383 return (
384 <Text>
385 <Text bold>{list.title}</Text> · {took}
386 </Text>
387 )
388 }
389}
390
391function row(one: CleanViewTask, isFirstUpcoming: boolean, isPaused: boolean, frame: number, width: number, Text: TextElement) {
392 const name = fit(one.name, width)
393 const bar = meter(one, frame)
394 const tag = label(one, isFirstUpcoming)
395 if (one.status === 'done') {
396 return (
397 <Text key={one.id}>
398 <Text color="green">✓</Text> <Text dimColor>{name}</Text> <Text color="green">{bar}</Text> <Text dimColor>{tag}</Text>
399 </Text>
400 )
401 }
402 if (one.status === 'active') {
403 return (
404 <Text key={one.id}>
405 <Text color="cyan">{isPaused ? '‖' : '▶'}</Text> <Text bold>{name}</Text> <Text color="cyan">{bar}</Text> {tag}
406 </Text>
407 )
408 }
409 return (
410 <Text key={one.id} dimColor>
411 ○ {name} {bar} {tag}
412 </Text>
413 )
414}
415hooks/logic.ts 210 lines1import type { CleanViewChecklist, CleanViewTask } from '../types'
2
3export const MAX_NAME = 40
4export const METER_CELLS = 10
5export const FALLBACK_NAME = 'Working on it'
6export const PLACEHOLDER_PREFIX = 'placeholder-'
7
8const CODE_FILE =
9 /\.(tsx?|jsx?|mjs|cjs|mts|cts|py|go|rs|rb|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|json|ya?ml|toml|md|html?|css|scss|sql|lock|xml|ini|env|vue|svelte)[)\].,:;!?'"]*$/i
10
11/** One plain name: no code, no paths, no file names, at most 40 characters. */
12export function cleanName(raw: string): string {
13 const words = raw
14 .replace(/`[^`]*`/g, ' ')
15 .replace(/`/g, ' ')
16 .split(/\s+/)
17 .filter(word => word !== '' && !/[/\\]/.test(word) && !CODE_FILE.test(word))
18 let name = words.join(' ').trim()
19 if (name === '') return FALLBACK_NAME
20 name = name[0].toUpperCase() + name.slice(1)
21 if (name.length <= MAX_NAME) return name
22 const cut = name.slice(0, MAX_NAME - 1)
23 const space = cut.lastIndexOf(' ')
24 return `${(space > 0 ? cut.slice(0, space) : cut).trimEnd()}…`
25}
26
27export function clampPercent(percent: number): number {
28 if (!Number.isFinite(percent)) return 0
29 return Math.round(Math.min(100, Math.max(0, percent)))
30}
31
32export const EMPTY: CleanViewChecklist = {
33 title: '',
34 phase: 'idle',
35 tasks: [],
36 needsYouReason: null,
37 stuckReason: null,
38 startedAt: 0,
39 finishedAt: null,
40 isCollapsed: false,
41}
42
43export function isRunning(list: CleanViewChecklist): boolean {
44 return list.phase === 'working' || list.phase === 'needsYou' || (list.phase === 'stuck' && list.finishedAt === null)
45}
46
47export function hasPlan(list: CleanViewChecklist): boolean {
48 return list.tasks.length > 0 && list.tasks.every(task => !task.id.startsWith(PLACEHOLDER_PREFIX))
49}
50
51function task(id: string, name: string, status: CleanViewTask['status']): CleanViewTask {
52 return { id, name, status, percent: status === 'done' ? 100 : 0, hasReported: false }
53}
54
55export function newJob(startedAt: number, title: string): CleanViewChecklist {
56 return {
57 ...EMPTY,
58 title,
59 phase: 'working',
60 startedAt,
61 tasks: [
62 task(`${PLACEHOLDER_PREFIX}understand`, 'Understand your request', 'active'),
63 task(`${PLACEHOLDER_PREFIX}plan`, 'Plan the steps', 'upcoming'),
64 ],
65 }
66}
67
68/** Lays out the plan: every step upcoming but the first, which starts now. */
69export function planSteps(list: CleanViewChecklist, steps: readonly string[]): CleanViewChecklist {
70 const names = steps.map(cleanName).slice(0, 8)
71 const tasks = (names.length > 0 ? names : [FALLBACK_NAME]).map((name, i) =>
72 task(`step-${i}-${name}`, name, i === 0 ? 'active' : 'upcoming'),
73 )
74 return { ...list, tasks }
75}
76
77function same(a: string, b: string): boolean {
78 return a.toLowerCase() === b.toLowerCase()
79}
80
81/** Every step from `index` on: the one at `index` active, the rest upcoming unless done. */
82function activate(tasks: CleanViewTask[], index: number, percent: number, hasReported: boolean): CleanViewTask[] {
83 return tasks.map((one, i) => {
84 if (i < index) return { ...one, status: 'done', percent: 100 }
85 if (i === index) return { ...one, status: 'active', percent, hasReported }
86 return one.status === 'active' ? { ...one, status: 'upcoming' } : one
87 })
88}
89
90/** Moves the plan forward for a report on `name`: earlier steps check off, 100 starts the next. */
91export function reportProgress(list: CleanViewChecklist, name: string, percent: number): CleanViewChecklist {
92 const clean = cleanName(name)
93 const p = clampPercent(percent)
94 let tasks = list.tasks.filter(one => !one.id.startsWith(PLACEHOLDER_PREFIX))
95 let index = tasks.findIndex(one => same(one.name, clean))
96 if (index === -1) {
97 const active = tasks.findIndex(one => one.status === 'active')
98 index = active === -1 ? tasks.length : active
99 tasks = [...tasks.slice(0, index), task(`step-x${tasks.length}-${clean}`, clean, 'upcoming'), ...tasks.slice(index)]
100 }
101 tasks = activate(tasks, index, p, true)
102 if (p >= 100) {
103 tasks = tasks.map((one, i) => (i === index ? { ...one, status: 'done', percent: 100 } : one))
104 const next = tasks.findIndex((one, i) => i > index && one.status !== 'done')
105 if (next !== -1) tasks = activate(tasks, next, 0, false)
106 }
107 return { ...list, tasks }
108}
109
110type TodoItem = { content: string; status: 'pending' | 'in_progress' | 'completed'; activeForm?: string }
111
112/** A to-do list as the plan: completed rows done, the in-progress one active. */
113export function fromTodos(list: CleanViewChecklist, todos: readonly TodoItem[]): CleanViewChecklist {
114 const kept = new Map(list.tasks.map(one => [one.name.toLowerCase(), one]))
115 let tasks = todos.map((todo, i): CleanViewTask => {
116 const name = cleanName(todo.content)
117 const before = kept.get(name.toLowerCase())
118 if (todo.status === 'completed') return { ...task(`todo-${i}-${name}`, name, 'done') }
119 if (todo.status === 'in_progress') {
120 return { id: `todo-${i}-${name}`, name, status: 'active', percent: before?.percent ?? 0, hasReported: before?.hasReported ?? false }
121 }
122 return task(`todo-${i}-${name}`, name, 'upcoming')
123 })
124 if (!tasks.some(one => one.status === 'active')) {
125 const first = tasks.findIndex(one => one.status === 'upcoming')
126 if (first !== -1) tasks = tasks.map((one, i) => (i === first ? { ...one, status: 'active' } : one))
127 }
128 return { ...list, tasks }
129}
130
131/** A task the TaskCreate tool made, added as an upcoming row (or the active one if none is). */
132export function addTask(list: CleanViewChecklist, id: string, subject: string): CleanViewChecklist {
133 const tasks = list.tasks.filter(one => !one.id.startsWith(PLACEHOLDER_PREFIX))
134 const status = tasks.some(one => one.status === 'active') ? 'upcoming' : 'active'
135 return { ...list, tasks: [...tasks, task(`task-${id}`, cleanName(subject), status)] }
136}
137
138/** A TaskUpdate: its new status and subject on the matching row. */
139export function updateTask(
140 list: CleanViewChecklist,
141 id: string,
142 status: 'pending' | 'in_progress' | 'completed' | 'deleted' | undefined,
143 subject: string | undefined,
144): CleanViewChecklist {
145 const key = `task-${id}`
146 const index = list.tasks.findIndex(one => one.id === key)
147 if (index === -1) return list
148 if (status === 'deleted') return { ...list, tasks: list.tasks.filter(one => one.id !== key) }
149 let tasks = list.tasks.map(one => (one.id === key && subject !== undefined ? { ...one, name: cleanName(subject) } : one))
150 if (status === 'in_progress') {
151 tasks = tasks.map((one, i) =>
152 i === index ? { ...one, status: 'active' } : one.status === 'active' ? { ...one, status: 'upcoming' } : one,
153 )
154 } else if (status === 'completed') {
155 tasks = tasks.map((one, i) => (i === index ? { ...one, status: 'done', percent: 100 } : one))
156 if (!tasks.some(one => one.status === 'active')) {
157 const next = tasks.findIndex(one => one.status === 'upcoming')
158 if (next !== -1) tasks = tasks.map((one, i) => (i === next ? { ...one, status: 'active' } : one))
159 }
160 } else if (status === 'pending') {
161 tasks = tasks.map((one, i) => (i === index ? { ...one, status: 'upcoming', percent: 0 } : one))
162 }
163 return { ...list, tasks }
164}
165
166/** The calm sentence for a turn an API error ended. */
167export function errorSentence(answer: string): string {
168 if (/rate.?limit|usage limit|\b429\b|quota/i.test(answer)) return 'you hit your usage limit, try again a little later'
169 if (/overload|\b529\b|capacity/i.test(answer)) return "Claude's servers are busy, try again in a minute"
170 if (/too long|context (window|length|limit)|maximum context/i.test(answer)) return 'type /compact and try again'
171 if (/network|connection|econn|fetch failed|socket|offline|timed? ?out|dns/i.test(answer)) return 'the internet connection dropped'
172 if (/auth|\b401\b|\b403\b|log ?in|api key|oauth|credential/i.test(answer)) return 'type /login'
173 return 'something went wrong talking to Claude, try again'
174}
175
176export function isUserRejection(text: string | undefined): boolean {
177 return text !== undefined && /doesn.t want to proceed|was rejected|user (denied|rejected|declined)/i.test(text)
178}
179
180export function duration(ms: number): string {
181 const seconds = Math.max(0, Math.floor(ms / 1000))
182 const m = Math.floor(seconds / 60)
183 const s = seconds % 60
184 return m > 0 ? `${m}m ${s}s` : `${s}s`
185}
186
187/** Ten cells: full, filled to the percent, a moving sweep, or empty. */
188export function meter(one: CleanViewTask, frame: number): string {
189 if (one.status === 'done') return '█'.repeat(METER_CELLS)
190 if (one.status === 'upcoming') return '░'.repeat(METER_CELLS)
191 if (!one.hasReported) {
192 const at = frame % METER_CELLS
193 return Array.from({ length: METER_CELLS }, (_, i) => ((i - at + METER_CELLS) % METER_CELLS < 3 ? '█' : '░')).join('')
194 }
195 const filled = Math.round(one.percent / 10)
196 return '█'.repeat(filled) + '░'.repeat(METER_CELLS - filled)
197}
198
199export function label(one: CleanViewTask, isFirstUpcoming: boolean): string {
200 if (one.status === 'done') return 'Done'
201 if (one.status === 'active') return one.hasReported ? `${one.percent}%` : 'Working'
202 return isFirstUpcoming ? 'Next' : 'Up next'
203}
204
205/** A name sized to the column: cut with "…" when it would wrap, padded otherwise. */
206export function fit(name: string, width: number): string {
207 if (name.length <= width) return name.padEnd(width)
208 return `${name.slice(0, Math.max(0, width - 1))}…`
209}
210types/index.d.ts 33 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 = 'idle' | 'working' | 'needsYou' | '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}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'clean-view': {
27 cleanViewEnabled: boolean
28 checklist: CleanViewChecklist
29 tick: number
30 }
31 }
32}
33