SLOPSHOPPER

clean-view

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

newbandrowsguardcommandtoast
★ 3v0.1.0no licenseupdated 2026-10-16Integralist/agent-skills/.claude/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 off: showing everything │ ⎿ Read 6 lines ╰────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● 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

Hides tool calls and output while Claude works; shows a plain checklist above the prompt.

  • Turn it on or off: click the Clean View: ON/OFF button at the right of the band above the prompt, or run /simple on, /simple off or /simple (flips it). The setting is saved across sessions. It starts on.
  • What it does: while on, tool calls, diffs and command output are hidden. A checklist above the prompt shows the job's name, how long it has run, and each step with a 10-cell meter. The header switches to Needs you, Stuck, Stopped or All done as the job changes.
  • How the plan arrives: the mod gives Claude two tools, plan_steps and report_progress, and blocks every other tool until a plan exists. A to-do list (TodoWrite, TaskCreate) also counts as the plan.
  • Cost: each new job makes one small Haiku call to name it.

[!WARNING] Where an organization manages Claude Code, prompt.compose and classic.Notification hooks don't run in a mod you install. The system prompt section is then skipped; the tool descriptions and the plan-first gate still steer Claude to plan, and Needs you still shows for permission prompts through tool.check.

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