SLOPSHOPPER

clean-view

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

bandrowsguardcommandtoastprompt
v0.1.0no licenseupdated 2026-10-12anunes9/claude-plugins/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: showing everything │ ⎿ Read 6 lines ╰───────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Read(/work/app/src/auth.ts) ⎿ Denied by clean-view: Clean View: call mcp__clean-view__plan_steps first to lay out the plan in plain Engl ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /simple ⎿ clean-view: Clean View is off. [ ○ Clean View: OFF ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ ○ Clean View: OFF ]
README

clean-view

A Claude Code mod that makes Claude Code calm and friendly for people who aren't technical. While Claude works, tool calls, file diffs and command output are hidden, and one plain-English checklist sits above the prompt:

Build my landing page · 1m 12s                     [ ● Clean View: ON ]
✓ Read your brand notes            ██████████  Done
▶ Build the pricing section        ██████░░░░  60%
○ Add the contact form             ░░░░░░░░░░  Next
○ Polish the footer                ░░░░░░░░░░  Up next
  • The header shows the job's name and how long it has run, or Needs you, ⚠ Stuck, ■ Stopped or ✓ All done (which shrinks to one line after 5 seconds).
  • Claude lays out the plan with a plan_steps tool and reports progress with report_progress; its to-do list (TodoWrite, TaskCreate, TaskUpdate) fills the checklist too.
  • While Clean View is on, Claude must make a plan before using other tools (subagents are not affected).
  • The Clean View button, or /simple on|off (no argument flips it), turns it on and off. The choice is remembered across restarts. Turned off, every hidden row comes back.

Each new job's name comes from a small, low-effort Haiku request, which uses a little of your usage.

Install

From a terminal Claude Code session:

/plugin install clean-view --marketplace anunes9/claude-plugins

Answer y to add the marketplace, then pick the scope (user scope loads it in every session). To try it from a local checkout without installing: claude --plugin-dir plugins/clean-view.

Develop

claude plugin validate .
claude plugin test .

hooks/register.tsx calls registerCleanView(on) from hooks/clean-view.tsx; more mods can be registered beside it. Other mods can read the checklist from state under clean-view (cleanViewEnabled, checklist, tick), typed in types/index.d.ts.

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