Clean View: hides tool calls and shows a calm step checklist above the prompt, with a Hide/Show details button.

Clean View hides tool calls, diffs and command output while Claude works, and shows a calm step checklist above the prompt, with a Hide details / Show details button. Made for people who do not read code.
plan_steps, reports progress with report_progress, and asks questions with ask_choices (numbered choices in the band)./simple on|off (or the button) toggles it. The setting is kept between sessions. It starts on.NO_COLOR is honoured. NO_MOTION, REDUCE_MOTION or PREFERS_REDUCED_MOTION gives a static meter.Check it: claude plugin validate clean-view and claude plugin test clean-view.
hooks/register.tsx 8 lines1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5export const register: Register = (on, options) => {
6 registerCleanView(on, options)
7}
8hooks/clean-view.tsx 881 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderChildren, RenderElement, Timer } from 'claude-code'
3
4import type { CleanChecklist, CleanFinished, CleanStyle } from '../types'
5import {
6 GATE_MESSAGE,
7 SECTION_TEXT,
8 actionLabel,
9 applyTaskCreate,
10 applyTaskUpdate,
11 applyTodos,
12 asksUser,
13 barSegments,
14 cleanName,
15 cleanTitle,
16 clampPercent,
17 easeStep,
18 fitName,
19 formatDuration,
20 friendlyError,
21 isGateExempt,
22 looksLikeUserDenial,
23 meter,
24 newChecklist,
25 planSteps,
26 reportProgress,
27 shortName,
28 stepsOf,
29 startJob,
30 stepNumber,
31 sweep,
32 visibleRows,
33} from './logic'
34
35type Hooked = EngineInterface
36
37const enabledA = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
38const askEnabledA = atom({ plugin: 'clean-view', key: 'askChoicesEnabled' } as const, false)
39const settingsOpenA = atom({ plugin: 'clean-view', key: 'settingsOpen' } as const, false)
40const checklistA = atom({ plugin: 'clean-view', key: 'checklist' } as const, newChecklist())
41const tickA = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
42const styleA = atom({ plugin: 'clean-view', key: 'viewStyle' } as const, 'checklist' as CleanStyle)
43const finishedA = atom({ plugin: 'clean-view', key: 'finished' } as const, [] as CleanFinished[])
44
45const PLAN = 'mcp__clean-view__plan_steps'
46const REPORT = 'mcp__clean-view__report_progress'
47const ASK = 'mcp__clean-view__ask_choices'
48
49let timer: Timer | undefined
50let doneTimer: Timer | undefined
51let lastInputAt = 0
52
53function patch($: Hooked, fn: (c: CleanChecklist) => CleanChecklist) {
54 return update($, checklistA, fn)
55}
56
57function runClock($: Hooked) {
58 if (timer) return
59 timer = $.clock.every(250, () => {
60 void tickOnce($)
61 })
62}
63
64async function tickOnce($: Hooked) {
65 const c = await read($, checklistA)
66 if (c.phase !== 'working' && c.phase !== 'needs-you') return
67 await update($, tickA, n => (n ?? 0) + 1)
68 await patch($, easeStep)
69}
70
71function stopClock() {
72 timer?.cancel()
73 timer = undefined
74}
75
76async function bell($: Hooked) {
77 const now = await $.clock.now()
78 // No focus API: ring only when the person has been quiet for a while.
79 if (now - lastInputAt < 15000) return
80 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
81 const argv = isWindows
82 ? ['powershell', '-NoProfile', '-Command', '[console]::Beep(880,200)']
83 : ['afplay', '/System/Library/Sounds/Ping.aiff']
84 $.process.run(argv, { timeoutMs: 5000 }).catch(() => undefined)
85}
86
87async function needsYou($: Hooked, reason: string, question: CleanChecklist['question'] = null) {
88 const before = await read($, checklistA)
89 const isNew = before.phase !== 'needs-you' || before.isBelled === false
90 await patch($, c => ({
91 ...c,
92 phase: 'needs-you',
93 needsYouReason: reason,
94 question,
95 isBelled: true,
96 finishedAt: 0,
97 }))
98 runClock($)
99 if (isNew) await bell($)
100}
101
102function resume($: Hooked) {
103 return patch($, c =>
104 c.phase === 'needs-you' || c.phase === 'stuck'
105 ? { ...c, phase: 'working', needsYouReason: '', stuckReason: '', question: null, isBelled: false }
106 : c,
107 )
108}
109
110/** Keeps the /config row in step with a button or slash command; a session without the row ignores the call. */
111async function syncRow($: Hooked, field: string, value: string) {
112 await $.config.set({ key: `clean-view.${field}`, value }).catch(() => undefined)
113}
114
115async function setStyle($: Hooked, value: CleanStyle) {
116 await update($, styleA, () => value)
117 await $.store.set('cleanViewStyle', value)
118 $.ui.toast(value === 'bars' ? 'Bar view' : 'List view')
119 await syncRow($, 'view', value === 'bars' ? 'bars' : 'list')
120}
121
122async function setEnabled($: Hooked, value: boolean) {
123 await update($, enabledA, () => value)
124 await $.store.set('cleanViewEnabled', value)
125 $.ui.toast(value ? 'Clean View is on: details are hidden.' : 'Clean View is off: details are showing.')
126 await syncRow($, 'cleanView', value ? 'on' : 'off')
127}
128
129async function setAskEnabled($: Hooked, value: boolean) {
130 await update($, askEnabledA, () => value)
131 await $.store.set('askChoicesEnabled', value)
132 $.ui.toast(value ? 'ask_choices is on.' : 'ask_choices is off: Claude uses AskUserQuestion.')
133 await syncRow($, 'askChoices', value ? 'on' : 'off')
134}
135
136function title0(cl: CleanChecklist): string {
137 return cl.title === '' ? 'Working on it' : cl.title
138}
139
140export function registerCleanView(on: On, options: Record<string, unknown> = {}) {
141 // ---------- session start: tools, command, saved setting ----------
142 // The /config menu rows (options) win; where a row is unset, the setting kept by a button or command applies.
143 on('session.start', async ($, e, next) => {
144 const saved = options.cleanView === 'on' ? true : options.cleanView === 'off' ? false : await $.store.get('cleanViewEnabled')
145 if (typeof saved === 'boolean') await update($, enabledA, () => saved)
146 const savedStyle = options.view === 'bars' ? 'bars' : options.view === 'list' ? 'checklist' : await $.store.get('cleanViewStyle')
147 if (savedStyle === 'bars' || savedStyle === 'checklist') await update($, styleA, () => savedStyle)
148 const savedAsk = options.askChoices === 'on' ? true : options.askChoices === 'off' ? false : await $.store.get('askChoicesEnabled')
149 if (typeof savedAsk === 'boolean') await update($, askEnabledA, () => savedAsk)
150 await $.tool.register({
151 name: 'plan_steps',
152 description:
153 'Clean View: list the steps of the job in order, 1 to 8 short plain-English names starting with a verb (no paths, file names, commands or code). The first step starts at once. Call this first for every request.',
154 inputSchema: {
155 type: 'object',
156 properties: { steps: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8 } },
157 required: ['steps'],
158 },
159 })
160 await $.tool.register({
161 name: 'report_progress',
162 description:
163 'Clean View: report progress on a planned step by its name and a percent from 0 to 100. Use 100 the moment the step is finished.',
164 inputSchema: {
165 type: 'object',
166 properties: { task: { type: 'string' }, percent: { type: 'number' } },
167 required: ['task', 'percent'],
168 },
169 })
170 await $.tool.register({
171 name: 'ask_choices',
172 description:
173 'Off by default: use AskUserQuestion to ask the person. Call this only if the person has said they turned on ask_choices. When on: ask a question with 2 to 4 options, the recommended option first, then stop and wait for their reply.',
174 inputSchema: {
175 type: 'object',
176 properties: {
177 question: { type: 'string' },
178 options: { type: 'array', items: { type: 'string' }, minItems: 2, maxItems: 4 },
179 },
180 required: ['question', 'options'],
181 },
182 })
183 await $.command.register({
184 name: 'askchoices',
185 description: 'Turn the ask_choices question tool on or off (no argument flips it). Off by default.',
186 argumentHint: 'on|off',
187 })
188 await $.command.register({
189 name: 'simple',
190 description: 'Turn Clean View on or off (no argument flips it), or pick the look: bars or list.',
191 argumentHint: 'on|off|bars|list',
192 })
193 await $.command.register({ name: 'progress', description: 'Flip between the bar view and the list view.' })
194 await $.command.register({ name: 'progress-clear', description: 'Remove the finished bars.' })
195 const c = await read($, checklistA)
196 if (c.phase === 'working' || c.phase === 'needs-you') runClock($)
197 return next(e)
198 })
199
200 // ---------- /simple ----------
201 on('command.run', { command: 'simple' }, async ($, e) => {
202 const arg = e.args.trim().toLowerCase()
203 if (arg === 'bars' || arg === 'list') {
204 await setStyle($, arg === 'bars' ? 'bars' : 'checklist')
205 return { text: arg === 'bars' ? 'Bar view.' : 'List view.' }
206 }
207 const current = await read($, enabledA)
208 const value = arg === 'on' ? true : arg === 'off' ? false : !current
209 await setEnabled($, value)
210 return { text: value ? 'Clean View is on.' : 'Clean View is off.' }
211 })
212
213 on('command.run', { command: 'askchoices' }, async ($, e) => {
214 const arg = e.args.trim().toLowerCase()
215 const current = await read($, askEnabledA)
216 const value = arg === 'on' ? true : arg === 'off' ? false : !current
217 await setAskEnabled($, value)
218 return { text: value ? 'ask_choices is on.' : 'ask_choices is off.' }
219 })
220
221 on('command.run', { command: 'progress' }, async ($) => {
222 const now = await read($, styleA)
223 await setStyle($, now === 'bars' ? 'checklist' : 'bars')
224 return { text: now === 'bars' ? 'List view.' : 'Bar view.' }
225 })
226 on('command.run', { command: 'progress-clear' }, async ($) => {
227 await update($, finishedA, () => [])
228 return { text: 'Finished bars removed.' }
229 })
230
231 // ---------- system prompt ----------
232 on('prompt.compose', async ($, e, next) => {
233 const result = await next(e)
234 if (!(await read($, enabledA))) return result
235 const text = (await read($, askEnabledA))
236 ? SECTION_TEXT
237 : SECTION_TEXT.split('\n').filter(l => !l.includes('ask_choices')).join('\n')
238 return { sections: [...result.sections, { id: 'clean-view:rules', text, scope: 'session' as const }] }
239 })
240
241 // ---------- a prompt starts a job ----------
242 on('prompt.submit', async ($, e, next) => {
243 lastInputAt = await $.clock.now()
244 if ((await read($, enabledA)) && (await read($, checklistA)).phase === 'needs-you') await resume($)
245 return next(e)
246 })
247
248 on('turn.start', async ($, e, next) => {
249 const result = await next(e)
250 if (!(await read($, enabledA))) return result
251 const text = e.text.trim()
252 if (text === '' || text.startsWith('/')) return result
253 const now = await $.clock.now()
254 const before = await read($, checklistA)
255 const isContinue = before.phase === 'needs-you' || before.phase === 'working'
256 if (isContinue) {
257 await resume($)
258 runClock($)
259 return result
260 }
261 doneTimer?.cancel()
262 await patch($, c => startJob(c, now))
263 const jobId = (await read($, checklistA)).jobId
264 runClock($)
265 void (async () => {
266 const r = await $.model.complete({
267 model: 'haiku',
268 effort: 'low',
269 maxTokens: 30,
270 timeoutMs: 15000,
271 prompt: `Name this job in 2 to 6 plain words, starting with a verb. Reply with the name only: no quotes, no file names, no punctuation.\n\nRequest: ${text.slice(0, 600)}`,
272 })
273 if (!r.isAnswered) return
274 const title = cleanTitle(r.text)
275 await patch($, c => (c.jobId === jobId ? { ...c, title } : c))
276 })().catch(() => undefined)
277 return result
278 })
279
280 // ---------- permission prompts ----------
281 on('classic.Notification', async ($, e, next) => {
282 if ((await read($, enabledA)) && /permission|elicitation/i.test(e.notification_type)) {
283 await needsYou($, 'Claude needs your OK to continue')
284 }
285 return next(e)
286 })
287
288 // ---------- serve our tools ----------
289 on('tool.call', { tool: 'mcp__clean-view__plan_steps' }, async ($, e) => {
290 if (!(await read($, enabledA))) return { result: 'Clean View is off. No plan needed. Continue the work.' }
291 const steps = (e as { steps?: unknown }).steps
292 // A subagent's plan is its own business: answer it, leave the member's checklist alone.
293 if (e.agentId !== undefined) {
294 const n = Array.isArray(steps) ? Math.min(Array.isArray(steps) ? steps.length : 0, 8) : 0
295 return { result: n < 1 ? 'Give 1 to 8 short step names as an array of strings.' : `Planned ${n} steps. The first one has started.` }
296 }
297 const c = await read($, checklistA)
298 const planned = planSteps(c, steps)
299 if (planned === null) return { result: 'Give 1 to 8 short step names as an array of strings.' }
300 await patch($, () => planned)
301 runClock($)
302 return { result: `Planned ${planned.tasks.length} steps. The first one has started.` }
303 })
304
305 on('tool.call', { tool: 'mcp__clean-view__report_progress' }, async ($, e) => {
306 if (!(await read($, enabledA))) return { result: 'Clean View is off. No progress report needed. Continue the work.' }
307 const args = e as { task?: unknown; percent?: unknown }
308 // A subagent's report never touches the member's checklist (the dock may read it per agent).
309 if (e.agentId !== undefined) return { result: `Progress noted: ${clampPercent(args.percent)}%.` }
310 let pct = 0
311 await patch($, c => {
312 const r = reportProgress(c, args.task, args.percent)
313 pct = r.pct
314 return r.cl.phase === 'stuck' ? { ...r.cl, phase: 'working', stuckReason: '', failStreak: 0 } : r.cl
315 })
316 runClock($)
317 return { result: `Progress noted: ${pct}%.` }
318 })
319
320 on('tool.call', { tool: 'mcp__clean-view__ask_choices' }, async ($, e) => {
321 if (!(await read($, enabledA)) || !(await read($, askEnabledA))) return { result: 'ask_choices is off. Ask this question with AskUserQuestion instead.' }
322 const args = e as { question?: unknown; options?: unknown }
323 // A helper cannot put a question on the person's screen: no Needs you, no bell, no picker.
324 if (e.agentId !== undefined) {
325 return { result: "Helpers can't ask the user. Put the question and its options in your final result so the agent that launched you can ask." }
326 }
327 const question = cleanName(typeof args.question === 'string' ? args.question.replace(/`[^`]*`/g, '') : '').replace(/…$/, '')
328 const options = Array.isArray(args.options) ? args.options.filter((o): o is string => typeof o === 'string').slice(0, 4) : []
329 if (options.length < 2) return { result: 'Give a question and 2 to 4 options.' }
330 const q = typeof args.question === 'string' && args.question.trim() !== '' ? args.question.trim().slice(0, 160) : question
331 await needsYou($, q, { question: q, options })
332 return { result: 'The question is on the person’s screen. Stop now and wait for their reply.' }
333 })
334
335 // ---------- the gate and the bookkeeping for every other tool ----------
336 on('tool.call', async ($, e, next) => {
337 if (!(await read($, enabledA)) || e.agentId !== undefined) return next(e)
338 const tool = String(e.tool)
339 const c = await read($, checklistA)
340 const isInJob = c.phase === 'working' || c.phase === 'needs-you' || c.phase === 'stuck'
341 if (isInJob && !c.hasPlan && !isGateExempt(tool)) return { deny: GATE_MESSAGE }
342
343 const isOurs = tool === PLAN || tool === REPORT || tool === ASK
344 if (!isOurs) {
345 await patch($, cl => ({
346 ...cl,
347 action: tool === 'ToolSearch' || tool === 'TodoWrite' || tool.startsWith('Task') ? cl.action : actionLabel(tool),
348 }))
349 }
350 if (tool === 'AskUserQuestion') {
351 const qs = (e as { questions?: Array<{ question?: string }> }).questions
352 await needsYou($, qs?.[0]?.question ?? 'Claude has a question for you')
353 }
354
355 const res = await next(e)
356 if (isOurs) return res
357
358 const isFailure = res.deny === undefined && res.isError === true
359 if (res.deny === undefined && !isFailure) {
360 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') {
361 const file = String((e as { file_path?: string; notebook_path?: string }).file_path ?? (e as { notebook_path?: string }).notebook_path ?? '')
362 if (file !== '') await patch($, cl => (cl.changedFiles.includes(file) ? cl : { ...cl, changedFiles: [...cl.changedFiles, file] }))
363 }
364 if (tool === 'TodoWrite') await patch($, cl => applyTodos(cl, (e as { todos?: unknown }).todos))
365 if (tool === 'TaskCreate') {
366 const m = /#?(\d+)/.exec(res.text ?? '')
367 await patch($, cl => applyTaskCreate(cl, (e as { subject?: unknown }).subject, m?.[1] ?? String(cl.tasks.length + 1)))
368 }
369 if (tool === 'TaskUpdate') await patch($, cl => applyTaskUpdate(cl, e as { taskId?: unknown; status?: unknown; subject?: unknown }))
370 }
371 await patch($, cl => {
372 let out: CleanChecklist = cl
373 if (tool === 'AskUserQuestion' || (cl.phase === 'needs-you' && cl.question === null)) {
374 out = { ...out, phase: 'working', needsYouReason: '', isBelled: false }
375 }
376 if (res.deny !== undefined) return out
377 if (!isFailure) {
378 return out.phase === 'stuck' ? { ...out, phase: 'working', stuckReason: '', failStreak: 0 } : { ...out, failStreak: 0 }
379 }
380 if (looksLikeUserDenial(res.text ?? '')) {
381 return { ...out, phase: 'stuck', stuckReason: 'you said no to a step, so Claude paused' }
382 }
383 const streak = out.failStreak + 1
384 return streak >= 3
385 ? { ...out, failStreak: streak, phase: 'stuck', stuckReason: 'a step keeps failing, Claude is trying another way' }
386 : { ...out, failStreak: streak }
387 })
388 return res
389 })
390
391 // ---------- the end of a turn ----------
392 on('turn.complete', async ($, e, next) => {
393 const result = await next(e)
394 if (e.agentId !== undefined || !(await read($, enabledA))) return result
395 const now = await $.clock.now()
396 const c = await read($, checklistA)
397 if (c.phase === 'idle') return result
398 if (e.reason === 'aborted') {
399 stopClock()
400 await patch($, cl => ({ ...cl, phase: 'stopped', finishedAt: now, action: '', question: null }))
401 } else if (e.reason === 'error') {
402 stopClock()
403 await patch($, cl => ({ ...cl, phase: 'stuck', stuckReason: friendlyError(e.answer), finishedAt: now, action: '' }))
404 } else if (e.reason === 'refusal') {
405 stopClock()
406 await patch($, cl => ({ ...cl, phase: 'stuck', stuckReason: "Claude couldn't help with that request", finishedAt: now, action: '' }))
407 } else if (c.question !== null) {
408 await needsYou($, c.question.question, c.question)
409 } else if (c.hasPlan && c.tasks.some(t => t.status !== 'done') && asksUser(e.answer)) {
410 // Unfinished steps only mean "waiting for you" when Claude actually asked something; a final answer with no
411 // question means the job is done, even if the last step was never reported at 100.
412 await needsYou($, 'Claude is waiting for your reply')
413 } else {
414 stopClock()
415 await patch($, cl => ({
416 ...cl,
417 phase: 'done',
418 finishedAt: now,
419 action: '',
420 isCollapsed: false,
421 tasks: cl.tasks.map(t => ({ ...t, status: 'done' as const, percent: 100, hasReported: true })),
422 }))
423 const seconds = Math.max(0, Math.round((now - c.startedAt) / 1000))
424 await update($, finishedA, f => [...f, { id: `j${c.jobId}`, title: c.title === '' ? 'Working on it' : c.title, seconds, steps: c.tasks.map(t => t.name) }].slice(-3))
425 doneTimer?.cancel()
426 doneTimer = $.clock.after(5000, () => {
427 void patch($, cl => (cl.phase === 'done' ? { ...cl, isCollapsed: true } : cl))
428 })
429 }
430 return result
431 })
432
433 // ---------- hide the noise ----------
434 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
435 if (!(await read($, enabledA))) return next(e)
436 const { Box } = $.ui.resolve(e)
437 return <Box display="none" />
438 })
439 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
440 if (!(await read($, enabledA))) return next(e)
441 const { Box } = $.ui.resolve(e)
442 return <Box display="none" />
443 })
444 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
445 if (!(await read($, enabledA))) return next(e)
446 const { Box } = $.ui.resolve(e)
447 return <Box display="none" />
448 })
449 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
450 if (!(await read($, enabledA))) return next(e)
451 return next({ ...e, props: { ...e.props, hint: '' } })
452 })
453
454 // ---------- the band ----------
455 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
456 if (e.props.hasSurvey) return next(e)
457 const { Box, Text, Button } = $.ui.resolve(e)
458 // Another mod's band (below us in the chain) stays on screen: it goes above ours, in a column.
459 let rest: Awaited<ReturnType<typeof next>> | undefined
460 try {
461 rest = await next(e)
462 } catch {
463 rest = undefined
464 }
465 const stack = (own: RenderElement): RenderElement =>
466 rest ? (
467 <Box flexDirection="column">
468 {rest}
469 {own}
470 </Box>
471 ) : (
472 own
473 )
474 try {
475 const enabled = await read($, enabledA)
476 const cl = await read($, checklistA)
477 const tick = await read($, tickA)
478 const now = await $.clock.now()
479 const isPlain = ((await $.env.get('NO_COLOR')) ?? '') !== ''
480 const isStill =
481 ((await $.env.get('NO_MOTION')) ?? '') !== '' ||
482 ((await $.env.get('REDUCE_MOTION')) ?? '') !== '' ||
483 ((await $.env.get('PREFERS_REDUCED_MOTION')) ?? '') !== ''
484 const color = (c: string): string | undefined => (isPlain ? undefined : c)
485 // The whole mod sits in one rounded frame so it never blends into another mod's band. Inside it, `width` is the
486 // room left after the two border cells and one cell of padding on each side.
487 const outer = e.props.bodyColumns
488 const width = Math.max(20, outer - 4)
489 const files = cl.changedFiles.length
490 const elapsed = formatDuration((cl.finishedAt > 0 ? cl.finishedAt : now) - cl.startedAt)
491 const style = await read($, styleA)
492 const finished = await read($, finishedA)
493 const askOn = await read($, askEnabledA)
494 const settingsOpen = await read($, settingsOpenA)
495
496 // Coloured words that carry the state: a filled pill for what is on or chosen, plain dim words for the rest.
497 const pillOf = (label: string, bg: string) => (
498 <Text bold inverse={isPlain} backgroundColor={isPlain ? undefined : bg} color={isPlain ? undefined : 'white'}>
499 {` ${label} `}
500 </Text>
501 )
502
503 // Settings are data: add an entry here and it shows in the dropdown. A toggle is on or off; a choice picks one value.
504 type Setting =
505 | { id: string; label: string; hint: string; kind: 'toggle'; isOn: boolean; set: (v: boolean) => void }
506 | { id: string; label: string; hint: string; kind: 'choice'; value: string; options: Array<{ value: string; label: string }>; set: (v: string) => void }
507 const settings: Setting[] = [
508 {
509 id: 'details',
510 label: 'Hide tool details',
511 hint: 'Show a step checklist instead of every tool call',
512 kind: 'toggle',
513 isOn: enabled,
514 set: v => void setEnabled($, v),
515 },
516 {
517 id: 'ask',
518 label: 'Ask choices',
519 hint: 'Let Claude ask with the picker in this box',
520 kind: 'toggle',
521 isOn: askOn,
522 set: v => void setAskEnabled($, v),
523 },
524 {
525 id: 'look',
526 label: 'Progress look',
527 hint: 'A checklist or one bar for each job',
528 kind: 'choice',
529 value: style === 'bars' ? 'bars' : 'list',
530 options: [
531 { value: 'list', label: 'List' },
532 { value: 'bars', label: 'Bars' },
533 ],
534 set: v => void setStyle($, v === 'bars' ? 'bars' : 'checklist'),
535 },
536 ]
537 const labelW = Math.max(...settings.map(s => s.label.length))
538 const settingRows = settings.map(s => {
539 const name = <Text bold>{s.label.padEnd(labelW, ' ')}</Text>
540 if (s.kind === 'toggle') {
541 return (
542 <Box key={`row-${s.id}`} flexDirection="row">
543 {s.isOn ? pillOf(' ON ', 'green') : pillOf(' OFF', 'gray')}
544 <Text> </Text>
545 <Button key={`set-${s.id}`} plain label={s.label.padEnd(labelW, ' ')} onPress={() => s.set(!s.isOn)} />
546 <Text dimColor>{` ${s.hint}`}</Text>
547 </Box>
548 )
549 }
550 return (
551 <Box key={`row-${s.id}`} flexDirection="row">
552 {name}
553 <Text> </Text>
554 {s.options.map(o =>
555 o.value === s.value ? (
556 <Box key={`opt-${s.id}-${o.value}`} flexDirection="row">
557 {pillOf(o.label, 'magenta')}
558 <Text> </Text>
559 </Box>
560 ) : (
561 <Box key={`opt-${s.id}-${o.value}`} flexDirection="row">
562 <Button key={`set-${s.id}-${o.value}`} plain dimColor label={` ${o.label} `} onPress={() => s.set(o.value)} />
563 <Text> </Text>
564 </Box>
565 ),
566 )}
567 <Text dimColor>{` ${s.hint}`}</Text>
568 </Box>
569 )
570 })
571 const settingsButton = (
572 <Button
573 key="settings"
574 variant={settingsOpen ? 'primary' : 'secondary'}
575 label={settingsOpen ? '⚙ Settings ▴' : '⚙ Settings ▾'}
576 onPress={() => void update($, settingsOpenA, v => !v)}
577 />
578 )
579 // Frame: a title tab and the Settings button on top, the dropdown under them, then the body.
580 const frame = (body: RenderChildren): RenderElement =>
581 stack(
582 <Box flexDirection="column" borderStyle="round" borderColor={color('cyan')} paddingX={1} width={outer}>
583 <Box flexDirection="row" justifyContent="space-between" width={width}>
584 <Text bold inverse={isPlain} backgroundColor={isPlain ? undefined : 'cyan'} color={isPlain ? undefined : 'black'}>
585 {enabled ? ' Clean View ' : ' Clean View · off '}
586 </Text>
587 {settingsButton}
588 </Box>
589 {settingsOpen ? (
590 <Box flexDirection="row" justifyContent="flex-end" width={width}>
591 <Box flexDirection="column" borderStyle="round" borderColor={color('magenta')} paddingX={1}>
592 <Text bold>Settings</Text>
593 {settingRows}
594 </Box>
595 </Box>
596 ) : null}
597 {body}
598 </Box>,
599 )
600
601 if (!enabled) {
602 return frame(
603 <Text dimColor wrap="truncate">
604 {cl.phase === 'done' && files > 0
605 ? `Changed: ${cl.changedFiles.map(f => f.split(/[\\/]/).pop()).join(', ')}`
606 : 'Tool calls are showing. Open Settings to hide them again.'}
607 </Text>,
608 )
609 }
610
611 if (style === 'bars') {
612 const live = cl.phase === 'working' || cl.phase === 'needs-you' || cl.phase === 'stuck' || cl.phase === 'stopped'
613 const active = cl.tasks.find(t => t.status === 'active')
614 const upcoming = cl.tasks.find(t => t.status === 'upcoming')
615 let pillText = shortName(active ? active.name : 'Working', 32)
616 let pillBg = 'magenta'
617 if (cl.phase === 'needs-you') {
618 pillText = 'Needs you'
619 pillBg = 'yellow'
620 } else if (cl.phase === 'stuck') {
621 pillText = 'Stuck'
622 pillBg = 'red'
623 } else if (cl.phase === 'stopped') {
624 pillText = 'Stopped'
625 pillBg = 'gray'
626 }
627 // The words inside the bar: what Claude is doing now, else the step that comes next.
628 const liveLabel = cl.phase === 'working' ? (cl.action !== '' ? `${cl.action}…` : upcoming ? upcoming.name : '') : ''
629 type Item = {
630 id: string
631 name: string
632 pillText: string
633 bg: string
634 pct: number
635 isLive: boolean
636 label: string
637 onDismiss: () => void
638 }
639 const items: Item[] = finished.map(f => ({
640 id: f.id,
641 name: f.title,
642 pillText: `✓ ${formatDuration(f.seconds * 1000)}`,
643 bg: 'green',
644 pct: 100,
645 isLive: false,
646 label: '',
647 onDismiss: () => {
648 void update($, finishedA, list => list.filter(x => x.id !== f.id))
649 },
650 }))
651 if (live) {
652 items.push({
653 id: 'now',
654 name: title0(cl),
655 pillText,
656 bg: pillBg,
657 pct: Math.round(cl.bar ?? 0),
658 isLive: cl.phase === 'working',
659 label: liveLabel,
660 onDismiss: () => {
661 stopClock()
662 void patch($, c => ({ ...newChecklist(), jobId: c.jobId }))
663 },
664 })
665 }
666 // Text takes at least 35% of the width (more if a name or stage needs it); the bar, percent and x take
667 // everything that is left, so the row always runs to the right edge. The pill sits against the bar, and
668 // every bar starts at the same column.
669 // Every pill is the same width, so pills and bars line up on the left: short ones are padded with their own colour.
670 const pillW = Math.min(34, Math.max(9, ...items.map(it => it.pillText.length + 2)))
671 const maxPill = pillW
672 const maxName = Math.max(12, ...items.map(it => it.name.length))
673 const textBlock = Math.min(Math.floor(width * 0.6), Math.max(Math.floor(width * 0.35), 2 + maxName + 1 + maxPill))
674 // Two cells of slack: if a terminal counts a symbol as wide, the bar gives way, never the name.
675 const barW = Math.max(10, width - textBlock - (6 + 5) - 2)
676 const rows: RenderChildren[] = items.map(it => (
677 <Box key={`bar-${it.id}`} flexDirection="row">
678 <Box width={textBlock - pillW} flexShrink={0} flexDirection="row">
679 <Text color={color(it.bg)}>● </Text>
680 <Text bold={it.isLive} wrap="truncate">{shortName(it.name, Math.max(8, textBlock - 3 - pillW))}</Text>
681 </Box>
682 <Box flexShrink={0}>{pillOf(shortName(it.pillText, pillW - 2).padEnd(pillW - 2, ' '), it.bg)}</Box>
683 {barSegments(it.pct, barW, tick, it.isLive && !isStill, it.label).map((seg, k) =>
684 seg.kind === 'label' ? (
685 <Text key={`seg-${k}`} bold inverse={isPlain} backgroundColor={isPlain ? undefined : it.bg} color={isPlain ? undefined : 'white'}>
686 {seg.text}
687 </Text>
688 ) : seg.kind === 'fill' ? (
689 <Text key={`seg-${k}`} color={color(it.bg)}>{seg.text}</Text>
690 ) : (
691 <Text key={`seg-${k}`} dimColor>{seg.text}</Text>
692 ),
693 )}
694 <Text>{` ${String(it.pct).padStart(3, ' ')}% `}</Text>
695 <Button key={`x-${it.id}`} label="×" onPress={it.onDismiss} />
696 </Box>
697 ))
698 const nextList =
699 cl.phase === 'needs-you' && cl.question !== null ? (
700 <Box flexDirection="column">
701 <Text dimColor>next: {cl.question.question}</Text>
702 {cl.question.options.map((o, i) => (
703 <Button
704 key={`choice${i + 1}`}
705 hotkey={String(i + 1)}
706 plain
707 label={o}
708 onPress={() => {
709 void patch($, c => ({ ...c, phase: 'working', needsYouReason: '', question: null, isBelled: false }))
710 void $.prompt.submit({ text: o, asUser: true })
711 }}
712 />
713 ))}
714 <Button
715 key="dismiss-next"
716 hotkey="0"
717 plain
718 label="dismiss"
719 onPress={() => {
720 void patch($, c => ({ ...c, phase: 'done', needsYouReason: '', question: null, isBelled: false }))
721 }}
722 />
723 </Box>
724 ) : null
725 return frame(
726 <Box flexDirection="column" width={width}>
727 {rows.length === 0 ? <Text dimColor>Waiting for your next request.</Text> : null}
728 {rows}
729 {cl.phase === 'stuck' ? (
730 <Text dimColor>{`⚠ ${cl.stuckReason}. Press Esc to stop, or type a message to steer.`}</Text>
731 ) : null}
732 {cl.phase === 'needs-you' && cl.question === null ? <Text dimColor>{`${cl.needsYouReason} ↓ Answer below`}</Text> : null}
733 {nextList}
734 </Box>
735 )
736 }
737
738 const title = title0(cl)
739 const isSingle = cl.hasPlan && cl.tasks.length <= 1
740 const isLive = cl.phase === 'working' || cl.phase === 'needs-you' || cl.phase === 'stuck' || cl.phase === 'stopped'
741 const phasePill =
742 cl.phase === 'needs-you'
743 ? pillOf('Needs you', 'yellow')
744 : cl.phase === 'stuck'
745 ? pillOf('Stuck', 'red')
746 : cl.phase === 'stopped'
747 ? pillOf('Stopped', 'gray')
748 : pillOf('Working', 'magenta')
749 const stepNote = isSingle || cl.tasks.length === 0 ? '' : ` · Step ${stepNumber(cl)} of ${cl.tasks.length}`
750 const detail =
751 cl.phase === 'needs-you'
752 ? `${cl.needsYouReason} ↓ Answer below`
753 : cl.phase === 'stuck'
754 ? `${cl.stuckReason}. Press Esc to stop, or type a message to steer.`
755 : cl.phase === 'stopped'
756 ? 'You pressed Esc.'
757 : cl.action !== ''
758 ? `${cl.action}…`
759 : ''
760 const liveHead = isLive ? (
761 <Box flexDirection="column">
762 <Box flexDirection="row">
763 {phasePill}
764 <Text bold wrap="truncate">{` ${title}`}</Text>
765 <Text dimColor>{`${stepNote} · ${elapsed}`}</Text>
766 </Box>
767 {detail !== '' ? <Text dimColor wrap="truncate">{` ${detail}`}</Text> : null}
768 </Box>
769 ) : null
770
771 const showRows = isLive && !isSingle && cl.tasks.length > 0 && (cl.phase === 'working' || cl.phase === 'needs-you')
772 const nameCol = Math.max(12, width - 24)
773 const { doneCount, rows } = visibleRows(cl)
774 const firstUpcoming = rows.findIndex(t => t.status === 'upcoming')
775 const pct = (n: number) => `${String(Math.round(n)).padStart(3, ' ')}%`
776
777 const rowEls = showRows
778 ? rows.map((t, i) => {
779 if (t.status === 'done') {
780 return (
781 <Box key={`r-${t.id}`} flexDirection="row">
782 <Text color={color('success')}>✓ </Text>
783 <Text dimColor>{fitName(t.name, nameCol)} </Text>
784 <Text color={color('success')}>{meter(100)}</Text>
785 <Text dimColor> Done</Text>
786 </Box>
787 )
788 }
789 if (t.status === 'active') {
790 const waiting = cl.phase === 'needs-you'
791 return (
792 <Box key={`r-${t.id}`} flexDirection="row">
793 <Text bold color={color(waiting ? 'yellow' : 'magenta')}>{waiting ? '‖ ' : '▶ '}</Text>
794 <Text bold>{fitName(t.name, nameCol)} </Text>
795 <Text color={color(waiting ? 'yellow' : 'magenta')}>{t.hasReported ? meter(t.shown) : sweep(tick, isStill || waiting)}</Text>
796 <Text bold>{t.hasReported ? ` ${pct(t.percent)}` : waiting ? ' Waiting' : ' Working'}</Text>
797 </Box>
798 )
799 }
800 return (
801 <Box key={`r-${t.id}`} flexDirection="row">
802 <Text dimColor>○ {fitName(t.name, nameCol)} </Text>
803 <Text dimColor>{meter(0)}</Text>
804 <Text dimColor>{i === firstUpcoming ? ' Next' : ' Up next'}</Text>
805 </Box>
806 )
807 })
808 : null
809
810 const picker =
811 cl.phase === 'needs-you' && cl.question !== null
812 ? (
813 <Box flexDirection="column">
814 {cl.question.options.map((o, i) => (
815 <Button
816 key={`choice${i + 1}`}
817 hotkey={String(i + 1)}
818 plain
819 label={o}
820 onPress={() => {
821 void patch($, c => ({ ...c, phase: 'working', needsYouReason: '', question: null, isBelled: false }))
822 void $.prompt.submit({ text: o, asUser: true })
823 }}
824 />
825 ))}
826 <Text dimColor>…or type your own answer below</Text>
827 </Box>
828 )
829 : null
830
831 // Finished jobs keep themselves, as in the bar view: the last three stay as one calm row each, with a × to clear
832 // one. The newest also shows its steps ticked.
833 const lastId = finished.length > 0 ? finished[finished.length - 1]!.id : ''
834 const historyEls = finished.map(f => {
835 const isJustDone = cl.phase === 'done' && f.id === `j${cl.jobId}`
836 const changed = isJustDone && files > 0 ? ` · changed ${files} ${files === 1 ? 'file' : 'files'}` : ''
837 return (
838 <Box key={`old-${f.id}`} flexDirection="column">
839 <Box flexDirection="row" justifyContent="space-between" width={width}>
840 <Text wrap="truncate">
841 <Text bold color={color('success')}>{'✓ '}</Text>
842 <Text>{f.title}</Text>
843 <Text dimColor>{` · took ${formatDuration(f.seconds * 1000)}${changed}`}</Text>
844 </Text>
845 <Button
846 key={`x-${f.id}`}
847 label="×"
848 onPress={() => {
849 void update($, finishedA, list => list.filter(x => x.id !== f.id))
850 }}
851 />
852 </Box>
853 {f.id === lastId && stepsOf(f).length > 1
854 ? stepsOf(f).map((n, i) => <Text key={`${f.id}-${i}`} dimColor>{` ✓ ${n}`}</Text>)
855 : null}
856 </Box>
857 )
858 })
859 return frame(
860 <Box flexDirection="column" width={width}>
861 {historyEls}
862 {liveHead}
863 {picker}
864 {showRows && doneCount > 0 ? <Text dimColor color={color('success')}>{`✓ ${doneCount} steps done`}</Text> : null}
865 {rowEls}
866 {!isLive && finished.length === 0 ? <Text dimColor>Waiting for your next request.</Text> : null}
867 </Box>
868 )
869 } catch (err) {
870 // Never let the band vanish silently: say what went wrong, and how to carry on.
871 const why = err instanceof Error ? err.message : String(err)
872 return stack(
873 <Box flexDirection="column">
874 <Text color="red" wrap="truncate">{`Clean View hit a problem: ${why.slice(0, 80)}`}</Text>
875 <Text dimColor>Type /simple off to hide Clean View, or /simple bars or /simple list to try the other look.</Text>
876 </Box>,
877 )
878 }
879 })
880}
881hooks/logic.ts 365 lines1import type { CleanChecklist, CleanTask } from '../types'
2
3// Pure helpers for Clean View: no engine calls here, so tests can use them directly.
4
5export const MAX_NAME = 40
6export const MAX_STEPS = 8
7export const METER_CELLS = 10
8
9const CODE_EXT =
10 /\.(?:tsx?|jsx?|mjs|cjs|py|rb|go|rs|java|cs|cpp|c|h|php|sh|ps1|json|ya?ml|toml|md|css|scss|html?|xml|sql|txt|csv|lock|docx?|xlsx?|pptx?)$/i
11
12/** One cleaner for every name: no code, no paths, no file names, 40 characters at most. */
13export function cleanName(raw: unknown): string {
14 const text = typeof raw === 'string' ? raw : ''
15 const withoutCode = text.replace(/`[^`]*`/g, ' ')
16 const words = withoutCode
17 .split(/\s+/)
18 .filter(w => w !== '' && !/[\\/]/.test(w) && !CODE_EXT.test(w.replace(/[.,;:!?)"'\]]+$/, '')))
19 let name = words.join(' ').trim()
20 if (name === '') return 'Working on it'
21 name = name.charAt(0).toUpperCase() + name.slice(1)
22 if (name.length > MAX_NAME) {
23 let cut = name.slice(0, MAX_NAME - 1)
24 const space = cut.lastIndexOf(' ')
25 if (space > 8) cut = cut.slice(0, space)
26 name = cut.trimEnd().replace(/[,.;:\-]+$/, '') + '…'
27 }
28 return name
29}
30
31/** A 2 to 6 word job name from the model's answer. */
32export function cleanTitle(raw: unknown): string {
33 const first = String(raw ?? '').split('\n')[0] ?? ''
34 const words = cleanName(first.replace(/["“”]/g, '')).replace(/…$/, '').split(' ').slice(0, 6)
35 return cleanName(words.join(' '))
36}
37
38export function formatDuration(ms: number): string {
39 const total = Math.max(0, Math.round(ms / 1000))
40 const m = Math.floor(total / 60)
41 const s = total % 60
42 return m > 0 ? `${m}m ${s}s` : `${s}s`
43}
44
45export function meter(percent: number, cells = METER_CELLS): string {
46 const filled = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
47 return '█'.repeat(filled) + '░'.repeat(cells - filled)
48}
49
50/** A moving three-cell block, or a static bar when motion is off. */
51export function sweep(frame: number, isStill: boolean, cells = METER_CELLS): string {
52 if (isStill) return '▒'.repeat(cells)
53 const span = cells + 2
54 const pos = (frame % span) - 2
55 let out = ''
56 for (let i = 0; i < cells; i++) out += i >= pos && i < pos + 3 ? '▓' : '░'
57 return out
58}
59
60export function actionLabel(tool: string): string {
61 if (tool === 'Read') return 'Reading a file'
62 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return 'Making changes'
63 if (tool === 'Bash' || tool === 'PowerShell') return 'Running a check'
64 if (tool === 'Grep' || tool === 'Glob') return 'Looking through files'
65 if (tool === 'WebSearch' || tool === 'WebFetch') return 'Searching the web'
66 if (tool === 'Agent') return 'Getting help from a helper'
67 return 'Working'
68}
69
70/** One calm sentence for a failed turn, from the text the engine ended it with. */
71export function friendlyError(text: string): string {
72 const t = text.toLowerCase()
73 if (/rate.?limit|usage limit|429|quota/.test(t)) return 'you hit your usage limit, try again a little later'
74 if (/overload|529|servers? (are )?busy|503/.test(t)) return "Claude's servers are busy, try again in a minute"
75 if (/context|too long|prompt is too long|token limit/.test(t)) return 'type /compact and try again'
76 if (/network|connection|econn|enotfound|timed? ?out|offline|fetch failed/.test(t)) return 'the internet connection dropped'
77 if (/auth|login|api key|401|unauthor|forbidden|403/.test(t)) return 'type /login'
78 return 'something went wrong, try again in a moment'
79}
80
81export function newChecklist(): CleanChecklist {
82 return {
83 title: '',
84 phase: 'idle',
85 tasks: [],
86 needsYouReason: '',
87 stuckReason: '',
88 startedAt: 0,
89 finishedAt: 0,
90 isCollapsed: false,
91 jobId: 0,
92 hasPlan: false,
93 action: '',
94 question: null,
95 changedFiles: [],
96 failStreak: 0,
97 isBelled: false,
98 bar: 0,
99 }
100}
101
102function task(id: string, name: string, status: CleanTask['status']): CleanTask {
103 return { id, name: cleanName(name), status, percent: 0, hasReported: false, shown: 0 }
104}
105
106export function startJob(prev: CleanChecklist, now: number): CleanChecklist {
107 return {
108 ...newChecklist(),
109 title: 'Working on it',
110 phase: 'working',
111 tasks: [task('s1', 'Understand your request', 'active'), task('s2', 'Plan the steps', 'upcoming')],
112 startedAt: now,
113 jobId: prev.jobId + 1,
114 }
115}
116
117/** plan_steps: the first step starts at once. */
118export function planSteps(cl: CleanChecklist, names: unknown): CleanChecklist | null {
119 if (!Array.isArray(names)) return null
120 const list = names.filter((n): n is string => typeof n === 'string').slice(0, MAX_STEPS)
121 if (list.length < 1) return null
122 const tasks = list.map((n, i) => task(`p${i + 1}`, n, i === 0 ? 'active' : 'upcoming'))
123 return { ...cl, tasks, hasPlan: true, phase: cl.phase === 'idle' ? 'working' : cl.phase, isCollapsed: false }
124}
125
126function norm(s: string): string {
127 return s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
128}
129
130/** A reported percent as 0 to 100: a number, rounded, clamped; anything else counts as 0. */
131export function clampPercent(percent: unknown): number {
132 const raw = typeof percent === 'number' && Number.isFinite(percent) ? percent : Number(percent)
133 return Math.max(0, Math.min(100, Number.isFinite(raw) ? Math.round(raw) : 0))
134}
135
136/** report_progress: checks off earlier steps, holds percent, starts the next step at 100. */
137export function reportProgress(cl: CleanChecklist, name: unknown, percent: unknown): { cl: CleanChecklist; pct: number } {
138 const pct = clampPercent(percent)
139 const wanted = norm(cleanName(name))
140 const tasks: CleanTask[] = cl.tasks.map(t => ({ ...t }))
141 let idx = tasks.findIndex(t => norm(t.name) === wanted)
142 if (idx < 0) idx = tasks.findIndex(t => norm(t.name).startsWith(wanted) || wanted.startsWith(norm(t.name)))
143 if (idx < 0) {
144 // An unknown name ticks nothing off: it goes in right after the current step and becomes the active one.
145 const was = tasks.findIndex(t => t.status === 'active')
146 const fresh = task(`n${tasks.length + 1}`, String(name ?? ''), 'active')
147 fresh.hasReported = true
148 fresh.percent = pct
149 if (was >= 0) tasks[was]!.status = 'upcoming'
150 if (pct >= 100) {
151 fresh.status = 'done'
152 if (was >= 0) tasks[was]!.status = 'active'
153 }
154 tasks.splice(was >= 0 ? was + 1 : tasks.length, 0, fresh)
155 return { cl: { ...cl, tasks, hasPlan: true, phase: cl.phase === 'idle' ? 'working' : cl.phase }, pct }
156 }
157 for (let i = 0; i < idx; i++) {
158 const t = tasks[i]!
159 t.status = 'done'
160 t.percent = 100
161 t.hasReported = true
162 }
163 const cur = tasks[idx]!
164 cur.hasReported = true
165 cur.percent = Math.max(cur.percent, pct)
166 if (cur.percent >= 100) {
167 cur.status = 'done'
168 const next = tasks.slice(idx + 1).find(t => t.status !== 'done')
169 if (next) next.status = 'active'
170 } else {
171 cur.status = 'active'
172 for (let i = idx + 1; i < tasks.length; i++) if (tasks[i]!.status === 'active') tasks[i]!.status = 'upcoming'
173 }
174 return {
175 cl: { ...cl, tasks, hasPlan: true, phase: cl.phase === 'idle' ? 'working' : cl.phase },
176 pct,
177 }
178}
179
180type Todo = { content?: unknown; status?: unknown }
181
182/** TodoWrite as checklist rows. */
183export function applyTodos(cl: CleanChecklist, todos: unknown): CleanChecklist {
184 if (!Array.isArray(todos) || todos.length === 0) return cl
185 const old = new Map(cl.tasks.map(t => [norm(t.name), t]))
186 let sawActive = false
187 const tasks = (todos as Todo[]).map((t, i) => {
188 const status: CleanTask['status'] =
189 t.status === 'completed' ? 'done' : t.status === 'in_progress' && !sawActive ? 'active' : 'upcoming'
190 if (status === 'active') sawActive = true
191 const row = task(`t${i + 1}`, String(t.content ?? ''), status)
192 const before = old.get(norm(row.name))
193 if (before) {
194 row.percent = status === 'done' ? 100 : before.percent
195 row.hasReported = before.hasReported || status === 'done'
196 row.shown = before.shown
197 } else if (status === 'done') {
198 row.percent = 100
199 row.hasReported = true
200 }
201 return row
202 })
203 if (!sawActive) {
204 const first = tasks.find(t => t.status === 'upcoming')
205 if (first) first.status = 'active'
206 }
207 return { ...cl, tasks, hasPlan: true }
208}
209
210export function applyTaskCreate(cl: CleanChecklist, subject: unknown, id: string): CleanChecklist {
211 const fresh = cl.hasPlan ? cl.tasks : []
212 const t = task(id, String(subject ?? ''), fresh.some(x => x.status === 'active') ? 'upcoming' : 'active')
213 return { ...cl, tasks: [...fresh, t], hasPlan: true }
214}
215
216export function applyTaskUpdate(cl: CleanChecklist, args: { taskId?: unknown; status?: unknown; subject?: unknown }): CleanChecklist {
217 const id = String(args.taskId ?? '')
218 let tasks = cl.tasks.map(t => ({ ...t }))
219 const t = tasks.find(x => x.id === id)
220 if (!t) return cl
221 if (typeof args.subject === 'string') t.name = cleanName(args.subject)
222 if (args.status === 'completed') {
223 t.status = 'done'
224 t.percent = 100
225 t.hasReported = true
226 if (!tasks.some(x => x.status === 'active')) {
227 const next = tasks.find(x => x.status === 'upcoming')
228 if (next) next.status = 'active'
229 }
230 } else if (args.status === 'in_progress') {
231 tasks.forEach(x => {
232 if (x.status === 'active') x.status = 'upcoming'
233 })
234 t.status = 'active'
235 } else if (args.status === 'deleted') {
236 tasks = tasks.filter(x => x.id !== id)
237 }
238 return { ...cl, tasks }
239}
240
241/** The whole job as one percent: finished steps count full, the others by what was reported. */
242export function jobPercent(cl: CleanChecklist): number {
243 if (cl.phase === 'done') return 100
244 if (cl.tasks.length === 0) return 0
245 const sum = cl.tasks.reduce((n, t) => n + (t.status === 'done' ? 100 : t.percent), 0)
246 return Math.max(0, Math.min(100, Math.round(sum / cl.tasks.length)))
247}
248
249/** One 250 ms step of easing: meters move toward their percent and never back. */
250export function easeStep(cl: CleanChecklist): CleanChecklist {
251 let changed = false
252 const tasks = cl.tasks.map(t => {
253 const target = t.status === 'done' ? 100 : t.percent
254 if (t.shown >= target) return t
255 changed = true
256 const step = Math.max(2, Math.ceil((target - t.shown) * 0.35))
257 return { ...t, shown: Math.min(target, t.shown + step) }
258 })
259 let bar = cl.bar
260 const goal = jobPercent({ ...cl, tasks })
261 if (bar < goal) {
262 changed = true
263 bar = Math.min(goal, bar + Math.max(1, Math.ceil((goal - bar) * 0.35)))
264 }
265 return changed ? { ...cl, tasks, bar } : cl
266}
267
268/** The bar style's cells: a dotted fill that drifts while live, and an empty rest. */
269export function barText(percent: number, cells: number, frame: number, isLive: boolean): { filled: string; rest: string } {
270 const n = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
271 let filled = ''
272 for (let i = 0; i < n; i++) filled += (i + (isLive ? frame : 0)) % 2 === 0 ? '▓' : '▒'
273 return { filled, rest: '░'.repeat(cells - n) }
274}
275
276export type BarSegment = { text: string; kind: 'fill' | 'rest' | 'label' }
277
278/** The bar with its words inside: dotted fill, empty rest, and a label that rides the edge of the fill. */
279export function barSegments(percent: number, cells: number, frame: number, isLive: boolean, label: string): BarSegment[] {
280 const base = barText(percent, cells, frame, isLive)
281 const chars = [...base.filled, ...base.rest]
282 const n = base.filled.length
283 const text = label.trim() === '' ? '' : ` ${shortName(label.trim(), Math.max(1, cells - 4))} `
284 const len = text.length
285 const at = len === 0 || len >= cells ? -1 : Math.max(2, Math.min(cells - len - 1, n - len))
286 const out: BarSegment[] = []
287 const push = (t: string, kind: BarSegment['kind']) => {
288 const last = out[out.length - 1]
289 if (last && last.kind === kind) last.text += t
290 else out.push({ text: t, kind })
291 }
292 for (let i = 0; i < chars.length; i++) {
293 if (at >= 0 && i === at) {
294 push(text, 'label')
295 i += len - 1
296 } else push(chars[i]!, i < n ? 'fill' : 'rest')
297 }
298 return out
299}
300
301/** The steps of a finished job; jobs saved by older versions have none. */
302export function stepsOf(f: { steps?: string[] }): string[] {
303 return Array.isArray(f.steps) ? f.steps : []
304}
305
306export function shortName(name: string, max: number): string {
307 return name.length <= max ? name : name.slice(0, Math.max(1, max - 1)).trimEnd() + '…'
308}
309
310/** True when the end of Claude's answer asks the person something. */
311export function asksUser(answer: unknown): boolean {
312 const tail = String(answer ?? '').trim().slice(-300)
313 if (tail === '') return false
314 if (/[??]/.test(tail)) return true
315 return /\b(let me know|shall i|should i|would you like|do you want|want me to|which (one|option)|please confirm|waiting for your)\b/i.test(tail)
316}
317
318export function stepNumber(cl: CleanChecklist): number {
319 const i = cl.tasks.findIndex(t => t.status === 'active')
320 if (i >= 0) return i + 1
321 return Math.min(cl.tasks.length, cl.tasks.filter(t => t.status === 'done').length + 1)
322}
323
324/** Stable height: past 5 rows, finished steps fold into one line. */
325export function visibleRows(cl: CleanChecklist): { doneCount: number; rows: CleanTask[] } {
326 if (cl.tasks.length <= 5) return { doneCount: 0, rows: cl.tasks }
327 const done = cl.tasks.filter(t => t.status === 'done')
328 return { doneCount: done.length, rows: cl.tasks.filter(t => t.status !== 'done') }
329}
330
331export function fitName(name: string, width: number): string {
332 if (name.length <= width) return name.padEnd(width, ' ')
333 return name.slice(0, Math.max(1, width - 1)) + '…'
334}
335
336const EXEMPT = new Set([
337 'ToolSearch',
338 'TodoWrite',
339 'TaskCreate',
340 'TaskUpdate',
341 'AskUserQuestion',
342 'mcp__clean-view__plan_steps',
343 'mcp__clean-view__report_progress',
344 'mcp__clean-view__ask_choices',
345])
346
347export function isGateExempt(tool: string): boolean {
348 return EXEMPT.has(tool)
349}
350
351export const GATE_MESSAGE =
352 'Clean View is on. Before any other tool, call plan_steps with 1 to 8 short plain-English step names (use ToolSearch to load plan_steps if it is not listed). Then continue.'
353
354export function looksLikeUserDenial(text: string): boolean {
355 return /doesn.?t want to proceed|rejected|permission (to use|for) .* (was )?denied|user denied|declined to/i.test(text)
356}
357
358export const SECTION_TEXT = `Clean View is on for this session. The person you are helping is not technical and sees a step checklist instead of tool calls.
359- For EVERY request, even a quick question, call plan_steps first (load it with ToolSearch if it is deferred). Use 1 step for a quick question, 2 to 8 for bigger work.
360- Step names are plain English, under 40 characters, and start with a verb, for example "Build the pricing section". Never put file paths, file names, commands, code or tool names in a step name.
361- Then call report_progress as real progress happens, with the same step name, and call it with percent 100 the moment a step finishes.
362- If you have TodoWrite or TaskCreate you may use that to-do list as the plan instead.
363- Whenever you need the person's input, including quick questions, call ask_choices with 2 to 4 options, the recommended option first, instead of asking in prose. Then stop and wait for their reply.
364- Keep written replies short, warm and free of jargon.`
365types/index.d.ts 64 lines1export type CleanTaskStatus = 'done' | 'active' | 'upcoming'
2
3export type CleanTask = {
4 id: string
5 name: string
6 status: CleanTaskStatus
7 /** Highest percent reported so far, 0 to 100. Never goes down. */
8 percent: number
9 /** True once Claude reported a percent for this step. */
10 hasReported: boolean
11 /** The eased value the meter draws; moves toward percent on each tick. */
12 shown: number
13}
14
15export type CleanPhase = 'idle' | 'working' | 'needs-you' | 'stuck' | 'stopped' | 'done'
16
17export type CleanQuestion = { question: string; options: string[] }
18
19export type CleanChecklist = {
20 title: string
21 phase: CleanPhase
22 tasks: CleanTask[]
23 needsYouReason: string
24 stuckReason: string
25 startedAt: number
26 finishedAt: number
27 isCollapsed: boolean
28 /** Counts jobs, so a slow job name from an old job is ignored. */
29 jobId: number
30 /** True once plan_steps or a to-do list made a real plan for this job. */
31 hasPlan: boolean
32 /** The plain verb line under the current step, "" for none. */
33 action: string
34 /** The ask_choices question being shown, or null. */
35 question: CleanQuestion | null
36 /** Distinct files changed by Edit, Write or NotebookEdit during the job. */
37 changedFiles: string[]
38 /** Consecutive failed tool calls. */
39 failStreak: number
40 /** True once the bell rang for the current Needs-you event. */
41 isBelled: boolean
42 /** The eased whole-job percent the bar style draws; never goes down. */
43 bar: number
44}
45
46export type CleanStyle = 'checklist' | 'bars'
47
48/** A finished job kept as a dismissible bar. */
49export type CleanFinished = { id: string; title: string; seconds: number; steps: string[] }
50
51declare module 'claude-code' {
52 interface PluginState {
53 'clean-view': {
54 cleanViewEnabled: boolean
55 askChoicesEnabled: boolean
56 settingsOpen: boolean
57 checklist: CleanChecklist
58 tick: number
59 viewStyle: CleanStyle
60 finished: CleanFinished[]
61 }
62 }
63}
64