SLOPSHOPPER

clean-view

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

newbandrowsguardcommandtoast
★ 2v0.1.0no licenseupdated 2026-10-08herman925/925-cc-plugins/clean-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clean-view
› fix the failing auth test and add an audit log call ╭─────────────────────────────────────────╮ │ clean-view │ ⏺ Read(src/auth.ts) │ Clean View is off: details are showing. │ ⎿ Read 6 lines ╰─────────────────────────────────────────╯ ⏺ Update(src/auth.ts) ╭────────────╮ ⎿ Added 2 lines, removed 1 line │ clean-view │ ⏺ Bash(bun test) │ Bar view │ ⎿ 3 pass, 1 fail ╰────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /askchoices ⎿ clean-view: ask_choices is on. ⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Clean View · off [ ⚙ Settings ▾ ] │ │ Tool calls are showing. Open Settings to hide them again. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Clean View · off [ ⚙ Settings ▾ ] │ │ Tool calls are showing. Open Settings to hide them again. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

clean-view

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.

  • Claude plans the job with plan_steps, reports progress with report_progress, and asks questions with ask_choices (numbered choices in the band).
  • Until a plan exists, other tools are refused for the main agent (subagents are never gated). If the gate hook ever fails, tools are allowed through.
  • /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.

Source 4 files
hooks/register.tsx 8 lines
1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5export const register: Register = (on, options) => {
6  registerCleanView(on, options)
7}
8
hooks/clean-view.tsx 881 lines
1import { 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}
881
hooks/logic.ts 365 lines
1import 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.`
365
types/index.d.ts 64 lines
1export 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