SLOPSHOPPER

oli-claude-mod-clean-view

A calm checklist above the prompt that hides tool calls, diffs and command output while Claude works.

newbandrowsguardcommandtoast
v0.1.0no licenseupdated 2026-10-14OliverRC/oli-claude-mods/plugins/clean-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · oli-claude-mod-clean-view
› fix the failing auth test and add an audit log call ╭───────────────────────────────────────╮ │ oli-claude-mod-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 oli-claude-mod-clean-view: Clean View: call plan_steps first to lay out the steps of this job (l ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /simple ⎿ oli-claude-mod-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 feel calm for non-technical people: tool calls, diffs and command output are hidden, and a simple checklist above the prompt shows the plan, the current step and how far along it is.

The idea and original prompt for Clean View came from Tristen O'Brien.

Use it

  • Click ● Clean View: ON at the right of the band above the prompt to switch it, or type /simple on, /simple off, or /simple to flip it. The choice is remembered.

Install

See the marketplace README.

Develop

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 9 lines
1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5// One entry point; add more mods beside Clean View here.
6export const register: Register = on => {
7  registerCleanView(on)
8}
9
hooks/clean-view.tsx 631 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderChildren } from 'claude-code'
3
4import type { CleanViewChecklist, CleanViewTask } from '../types'
5
6type $ = EngineInterface
7type Checklist = CleanViewChecklist
8type Task = CleanViewTask
9
10export const PLAN_TOOL = 'mcp__oli-claude-mod-clean-view__plan_steps'
11export const PROGRESS_TOOL = 'mcp__oli-claude-mod-clean-view__report_progress'
12
13const enabled = atom({ plugin: 'oli-claude-mod-clean-view', key: 'cleanViewEnabled' } as const, true)
14const checklist = atom({ plugin: 'oli-claude-mod-clean-view', key: 'checklist' } as const, null)
15const tickAtom = atom({ plugin: 'oli-claude-mod-clean-view', key: 'tick' } as const, 0)
16
17const ALWAYS_ALLOWED = new Set(['ToolSearch', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'AskUserQuestion'])
18const STORE_KEY = 'cleanViewEnabled'
19const MAX_NAME = 40
20const METER = 10
21
22const GUIDE = `# Clean View is on
23
24The person sees a simple checklist of your plan instead of tool calls, so keep it accurate and friendly.
25
26- For every request, even a quick question, call \`plan_steps\` first (load it with ToolSearch if it is deferred): 2 to 8 short steps, in order.
27- 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".
28- Never put file paths, file names, commands, code or tool names in a step name.
29- Call \`report_progress\` as real progress happens, and with percent 100 the moment a step finishes.
30- If this session has TodoWrite or TaskCreate, you may use your to-do list as the plan instead.`
31
32const CODE_FILE = /\b[\w.-]+\.(?:tsx?|jsx?|mjs|cjs|mts|cts|json|php|py|rb|cs|go|rs|java|kt|swift|c|cc|cpp|h|hpp|css|scss|less|html?|vue|svelte|md|ya?ml|toml|ini|sh|zsh|bash|sql|lock|env|xml|csproj|sln)\b/gi
33
34/** The one cleaner every step and job name passes through. */
35export function cleanName(raw: string): string {
36  let s = String(raw ?? '')
37    .replace(/`[^`]*`?/g, ' ')
38    .split(/\s+/)
39    .filter(word => !word.includes('/') && !word.includes('\\'))
40    .join(' ')
41    .replace(CODE_FILE, ' ')
42    .replace(/\s+/g, ' ')
43    .trim()
44  if (s.length > MAX_NAME) {
45    const cut = s.slice(0, MAX_NAME - 1)
46    const space = cut.lastIndexOf(' ')
47    s = (space > 0 ? cut.slice(0, space) : cut).replace(/[\s,.;:–-]+$/, '') + '…'
48  }
49  if (s === '' || s === '…') return 'Working on it'
50  return s.charAt(0).toUpperCase() + s.slice(1)
51}
52
53export function duration(ms: number): string {
54  const total = Math.max(0, Math.round(ms / 1000))
55  const h = Math.floor(total / 3600)
56  const m = Math.floor((total % 3600) / 60)
57  const s = total % 60
58  if (h > 0) return `${h}h ${m}m`
59  return m > 0 ? `${m}m ${s}s` : `${s}s`
60}
61
62function apiErrorSentence(kind: string | undefined, details = ''): string {
63  if (/too long|context|too many tokens/i.test(details)) return 'type /compact and try again'
64  if (/network|ECONN|ENOTFOUND|socket|fetch failed|offline|connection/i.test(details)) return 'the internet connection dropped'
65  if (kind === 'rate_limit' || kind === 'billing_error') return 'you hit your usage limit, try again a little later'
66  if (kind === 'overloaded' || kind === 'server_error') return "Claude's servers are busy, try again in a minute"
67  if (kind === 'authentication_failed' || kind === 'oauth_org_not_allowed' || kind === 'cloud_credential_error') return 'type /login'
68  return 'something went wrong, try again'
69}
70
71const USER_SAID_NO = /user (?:doesn't|does not) want to proceed|user rejected|rejected by the user|denied by the user|user declined/i
72
73// --- state edits ------------------------------------------------------------
74
75// Module timers die with a reload; session.start resyncs them.
76let timer: { cancel: () => void } | undefined
77let failures = 0
78let lastApiError: { kind?: string; details?: string } | null = null
79
80function syncTimer($: $, list: Checklist | null) {
81  const isLive = list !== null && (list.phase === 'working' || list.phase === 'needs-you')
82  if (isLive && timer === undefined) timer = $.clock.every(250, () => void update($, tickAtom, n => (n ?? 0) + 1))
83  if (!isLive && timer !== undefined) {
84    timer.cancel()
85    timer = undefined
86  }
87}
88
89async function edit($: $, fn: (list: Checklist | null) => Checklist | null) {
90  let out = null as Checklist | null
91  await update($, checklist, list => (out = fn(list ?? null)))
92  syncTimer($, out)
93  return out
94}
95
96function newList(now: number, title: string): Checklist {
97  return {
98    title,
99    phase: 'working',
100    tasks: [
101      { id: 'placeholder-1', name: 'Understand your request', status: 'active', percent: 0, hasReported: false },
102      { id: 'placeholder-2', name: 'Plan the steps', status: 'upcoming', percent: 0, hasReported: false },
103    ],
104    needsYouReason: null,
105    stuckReason: null,
106    startedAt: now,
107    finishedAt: null,
108    isCollapsed: false,
109    isPlanned: false,
110  }
111}
112
113function working(list: Checklist): Checklist {
114  return { ...list, phase: 'working', needsYouReason: null, stuckReason: null, finishedAt: null, isCollapsed: false }
115}
116
117/** Makes the first unfinished step active when none is. */
118function ensureActive(tasks: Task[]): Task[] {
119  if (tasks.some(t => t.status === 'active')) return tasks
120  const i = tasks.findIndex(t => t.status !== 'done')
121  return i < 0 ? tasks : tasks.map((t, j) => (j === i ? { ...t, status: 'active' } : t))
122}
123
124export function applyPlan(list: Checklist, steps: string[]): Checklist {
125  const tasks: Task[] = steps.map((step, i) => ({
126    id: `step-${i + 1}`,
127    name: cleanName(step),
128    status: i === 0 ? 'active' : 'upcoming',
129    percent: 0,
130    hasReported: false,
131  }))
132  return { ...working(list), tasks, isPlanned: true }
133}
134
135export function applyProgress(list: Checklist, rawName: string, rawPercent: number): Checklist {
136  const name = cleanName(rawName)
137  const percent = Math.min(100, Math.max(0, Math.round(Number.isFinite(rawPercent) ? rawPercent : 0)))
138  let tasks = list.isPlanned ? [...list.tasks] : []
139  let at = tasks.findIndex(t => t.name.toLowerCase() === name.toLowerCase())
140  if (at < 0) {
141    tasks.push({ id: `step-${tasks.length + 1}-${name}`, name, status: 'upcoming', percent: 0, hasReported: false })
142    at = tasks.length - 1
143  }
144  tasks = tasks.map((t, i): Task => {
145    if (i < at) return { ...t, status: 'done', percent: 100 }
146    if (i === at) return { ...t, status: percent >= 100 ? 'done' : 'active', percent, hasReported: true }
147    return t.status === 'active' ? { ...t, status: 'upcoming' } : t
148  })
149  if (percent >= 100) {
150    const nextAt = tasks.findIndex((t, i) => i > at && t.status !== 'done')
151    const following = tasks[nextAt]
152    if (following) tasks[nextAt] = { ...following, status: 'active', percent: 0, hasReported: false }
153  }
154  return { ...list, tasks }
155}
156
157type Todo = { content?: unknown; status?: unknown }
158
159function fromTodos(list: Checklist, todos: Todo[]): Checklist {
160  const old = new Map(list.tasks.map(t => [t.name, t]))
161  const tasks = todos.map((todo, i): Task => {
162    const name = cleanName(String(todo.content ?? ''))
163    const prior = old.get(name)
164    if (todo.status === 'completed') return { id: `todo-${i + 1}`, name, status: 'done', percent: 100, hasReported: true }
165    const status = todo.status === 'in_progress' ? 'active' : 'upcoming'
166    return { id: `todo-${i + 1}`, name, status, percent: prior?.percent ?? 0, hasReported: prior?.hasReported ?? false }
167  })
168  return { ...list, tasks: ensureActive(tasks), isPlanned: true }
169}
170
171// --- the job's life -----------------------------------------------------------
172
173async function nameJob($: $, text: string, startedAt: number) {
174  const prompt = `Name this job in 2 to 6 plain words a non-technical person understands, starting with a verb. No file names, code or punctuation. Reply with the name only.\n\nRequest: ${text.slice(0, 2000)}`
175  try {
176    let answer = await $.model.complete({ model: 'haiku', effort: 'low', maxTokens: 24, timeoutMs: 15000, prompt })
177    if (!answer.isAnswered && answer.reason === 'api-error') answer = await $.model.complete({ model: 'haiku', maxTokens: 24, timeoutMs: 15000, prompt })
178    if (!answer.isAnswered) return
179    const words = (answer.text.split('\n')[0] ?? '').replace(/["'*.]/g, '').trim().split(/\s+/).slice(0, 6).join(' ')
180    if (words === '') return
181    await edit($, list => (list !== null && list.startedAt === startedAt ? { ...list, title: cleanName(words) } : list))
182  } catch {
183    // The placeholder title stays.
184  }
185}
186
187async function onPrompt($: $, text: string) {
188  const now = await $.clock.now()
189  failures = 0
190  lastApiError = null
191  let startedAt: number | null = null
192  await edit($, list => {
193    // A reply to "Needs you" or "Stuck" carries the same job on.
194    if (list !== null && (list.phase === 'needs-you' || list.phase === 'stuck' || list.phase === 'working')) return working(list)
195    startedAt = now
196    return newList(now, cleanName(text.split(/\s+/).slice(0, 6).join(' ')))
197  })
198  if (startedAt !== null) void nameJob($, text, startedAt)
199}
200
201async function setNeedsYou($: $, reason: string) {
202  await edit($, list => (list === null || list.phase === 'done' || list.phase === 'stopped' ? list : { ...list, phase: 'needs-you', needsYouReason: reason }))
203}
204
205async function clearNeedsYou($: $) {
206  await edit($, list => (list?.phase === 'needs-you' ? { ...list, phase: 'working', needsYouReason: null } : list))
207}
208
209async function setStuck($: $, reason: string) {
210  await edit($, list => (list === null ? list : { ...list, phase: 'stuck', stuckReason: reason, needsYouReason: null }))
211}
212
213async function noteOutcome($: $, ran: { deny?: string; isError?: true; text?: string; result?: unknown }) {
214  if (ran.deny !== undefined) return
215  if (ran.isError) {
216    const said = `${ran.text ?? ''} ${typeof ran.result === 'string' ? ran.result : ''}`
217    if (USER_SAID_NO.test(said)) return setStuck($, 'you said no to a step, so Claude paused')
218    failures += 1
219    if (failures >= 3) await setStuck($, 'a step keeps failing, Claude is trying another way')
220    return
221  }
222  failures = 0
223  await edit($, list => (list?.phase === 'stuck' ? { ...list, phase: 'working', stuckReason: null } : list))
224}
225
226async function onTurnComplete($: $, reason: string) {
227  const now = await $.clock.now()
228  const error = lastApiError
229  lastApiError = null
230  let doneJob: number | null = null
231  await edit($, list => {
232    if (list === null) return list
233    if (reason === 'error') return { ...list, phase: 'stuck', stuckReason: apiErrorSentence(error?.kind, error?.details), needsYouReason: null }
234    if (reason === 'refusal') return { ...list, phase: 'stuck', stuckReason: "Claude couldn't help with that request", needsYouReason: null }
235    if (reason === 'aborted') return { ...list, phase: 'stopped', finishedAt: now, needsYouReason: null }
236    const isUnfinished = list.isPlanned && list.tasks.some(t => t.status !== 'done')
237    if (isUnfinished && list.phase === 'stuck') return list
238    if (isUnfinished) return { ...list, phase: 'needs-you', needsYouReason: 'Claude is waiting for your reply' }
239    doneJob = list.startedAt
240    const tasks = list.tasks.map((t): Task => ({ ...t, status: 'done', percent: 100 }))
241    return { ...list, tasks, phase: 'done', finishedAt: now, needsYouReason: null, stuckReason: null, isCollapsed: false }
242  })
243  if (doneJob !== null) {
244    const startedOf = doneJob
245    $.clock.after(5000, () => {
246      void edit($, list => (list !== null && list.phase === 'done' && list.startedAt === startedOf ? { ...list, isCollapsed: true } : list))
247    })
248  }
249}
250
251async function toggle($: $, to?: boolean) {
252  let now = true
253  await update($, enabled, was => (now = to ?? !(was ?? true)))
254  await $.store.set(STORE_KEY, now)
255  $.ui.toast(now ? 'Clean View is on: tool details are hidden' : 'Clean View is off: showing everything')
256  return now
257}
258
259// --- drawing ------------------------------------------------------------------
260
261function fit(text: string, width: number): string {
262  if (text.length > width) return text.slice(0, Math.max(0, width - 1)) + '…'
263  return text.padEnd(width)
264}
265
266// A soft pastel palette that reads well on dark terminals.
267const PALETTE = {
268  peach: '#FAB387',
269  pink: '#F38BA8',
270  mauve: '#CBA6F7',
271  blue: '#89B4FA',
272  green: '#A6E3A1',
273  yellow: '#F9E2AF',
274  red: '#EBA0AC',
275  text: '#CDD6F4',
276  subtle: '#7F849C',
277  track: '#45475A',
278}
279const TITLE_RAMP = [PALETTE.peach, PALETTE.pink, PALETTE.mauve, PALETTE.blue]
280const SWEEP_FADE = ['#3B3052', '#5B4A7A', '#8C7BD0', '#B4BEFE']
281const SPARKLE = ['✶', '✷', '✸', '✹', '✺', '✹', '✸', '✷']
282
283function hex(color: string): [number, number, number] {
284  const n = parseInt(color.slice(1), 16)
285  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
286}
287
288/** The colour at `t` (0..1) along a ramp of hex stops. */
289function rampAt(stops: string[], t: number): string {
290  const x = Math.min(1, Math.max(0, t)) * (stops.length - 1)
291  const i = Math.min(stops.length - 2, Math.floor(x))
292  const a = hex(stops[i] ?? '#000000')
293  const b = hex(stops[i + 1] ?? '#000000')
294  const f = x - i
295  return '#' + a.map((v, k) => Math.round(v + ((b[k] ?? 0) - v) * f).toString(16).padStart(2, '0')).join('')
296}
297
298type Cell = { char: string; color: string }
299
300function meter(task: Task, tick: number, isAnimated: boolean): Cell[] {
301  const ramp = (i: number) => rampAt([PALETTE.pink, PALETTE.mauve, PALETTE.blue], i / (METER - 1))
302  const track = (): Cell => ({ char: '░', color: PALETTE.track })
303  if (task.status === 'done') return Array.from({ length: METER }, (_, i) => ({ char: '█', color: rampAt(['#40A02B', PALETTE.green], i / (METER - 1)) }))
304  if (task.status === 'upcoming') return Array.from({ length: METER }, track)
305  if (task.hasReported) {
306    const filled = Math.round(task.percent / 10)
307    return Array.from({ length: METER }, (_, i) => (i < filled ? { char: '█', color: ramp(i) } : track()))
308  }
309  // No percent yet: a fading four-cell comet bouncing across the meter.
310  const step = isAnimated ? tick % 12 : 0
311  const head = step < 7 ? step + 3 : 15 - step
312  const dir = step < 7 ? 1 : -1
313  return Array.from({ length: METER }, (_, i) => {
314    const behind = (head - i) * dir
315    const shade = SWEEP_FADE[SWEEP_FADE.length - 1 - behind]
316    return behind >= 0 && shade !== undefined ? { char: '█', color: shade } : track()
317  })
318}
319
320function label(task: Task, firstUpcoming: boolean): string {
321  if (task.status === 'done') return 'Done'
322  if (task.status === 'upcoming') return firstUpcoming ? 'Next' : 'Up next'
323  return task.hasReported ? `${task.percent}%` : 'Working'
324}
325
326// --- hooks ----------------------------------------------------------------------
327
328export function registerCleanView(on: On) {
329  on('session.start', async ($, e, next) => {
330    const saved = await $.store.get(STORE_KEY)
331    if (typeof saved === 'boolean') await update($, enabled, () => saved)
332    await $.tool.register({
333      name: 'plan_steps',
334      description:
335        '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 and starting with a verb. No file names, paths, commands or code. The first step starts right away.',
336      inputSchema: {
337        type: 'object',
338        properties: { steps: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8 } },
339        required: ['steps'],
340      },
341      isDeferred: false,
342    })
343    await $.tool.register({
344      name: 'report_progress',
345      description:
346        'Report progress on the current step, by its name from plan_steps, as real progress happens. Send percent 100 the moment a step finishes; the next step then starts. A name not in the plan becomes a new step.',
347      inputSchema: {
348        type: 'object',
349        properties: { task: { type: 'string' }, percent: { type: 'number', minimum: 0, maximum: 100 } },
350        required: ['task', 'percent'],
351      },
352      isDeferred: false,
353    })
354    await $.command.register({ name: 'simple', description: 'Turn Clean View on or off', argumentHint: 'on|off' })
355    syncTimer($, await read($, checklist))
356    return next(e)
357  })
358
359  on('command.run', { command: 'simple' }, async ($, e) => {
360    const arg = e.args.trim().toLowerCase()
361    const isOn = await toggle($, arg === 'on' ? true : arg === 'off' ? false : undefined)
362    return { text: isOn ? 'Clean View is on.' : 'Clean View is off.' }
363  })
364
365  on('prompt.compose', async ($, e, next) => {
366    const composed = await next(e)
367    if (!(await read($, enabled))) return composed
368    return { ...composed, sections: [...composed.sections, { id: 'CLAUDE-MOD-CLEAN-VIEWide', text: GUIDE, scope: 'session' as const }] }
369  })
370
371  on('turn.start', async ($, e, next) => {
372    const text = e.text.trim()
373    if (text !== '' && !text.startsWith('/')) await onPrompt($, text)
374    return next(e)
375  })
376
377  on('tool.call', async ($, e, next) => {
378    const tool = String(e.tool)
379    const input = e as unknown as Record<string, unknown>
380
381    if (tool === PLAN_TOOL) {
382      const steps = Array.isArray(input.steps) ? input.steps.filter((s): s is string => typeof s === 'string' && s.trim() !== '') : []
383      if (steps.length === 0) return { deny: 'plan_steps needs 2 to 8 short step names.' }
384      const now = await $.clock.now()
385      const list = await edit($, l => applyPlan(l ?? newList(now, 'Working on it'), steps.slice(0, 8)))
386      return { result: `Planned ${list?.tasks.length ?? 0} steps. The first one has started.` }
387    }
388    if (tool === PROGRESS_TOOL) {
389      const now = await $.clock.now()
390      const percent = Math.min(100, Math.max(0, Number(input.percent) || 0))
391      await edit($, l => applyProgress(l ?? newList(now, 'Working on it'), String(input.task ?? ''), percent))
392      return { result: `Progress noted: ${Math.round(percent)}%.` }
393    }
394    if (e.agentId !== undefined) return next(e)
395
396    if ((await read($, enabled)) && !ALWAYS_ALLOWED.has(tool) && !(await read($, checklist))?.isPlanned) {
397      return { deny: 'Clean View: call plan_steps first to lay out the steps of this job (load it with ToolSearch if it is deferred), then try again.' }
398    }
399
400    await clearNeedsYou($)
401    if (tool === 'AskUserQuestion') await setNeedsYou($, 'Claude has a question for you')
402    if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
403      const now = await $.clock.now()
404      await edit($, l => fromTodos(l ?? newList(now, 'Working on it'), input.todos as Todo[]))
405    }
406    if (tool === 'TaskUpdate') {
407      await edit($, l => {
408        if (l === null) return l
409        const id = String(input.taskId)
410        if (input.status === 'deleted') return { ...l, tasks: ensureActive(l.tasks.filter(t => t.id !== id)) }
411        const tasks = l.tasks.map((t): Task => {
412          if (t.id !== id) return t
413          const named = typeof input.subject === 'string' ? { ...t, name: cleanName(input.subject) } : t
414          if (input.status === 'completed') return { ...named, status: 'done', percent: 100, hasReported: true }
415          if (input.status === 'in_progress') return { ...named, status: 'active' }
416          if (input.status === 'pending') return { ...named, status: 'upcoming' }
417          return named
418        })
419        return { ...l, tasks: ensureActive(tasks) }
420      })
421    }
422
423    const ran = await next(e)
424
425    if (tool === 'TaskCreate' && ran.deny === undefined && !ran.isError) {
426      const made = (ran.result as { task?: { id?: string } } | undefined)?.task
427      const now = await $.clock.now()
428      await edit($, l => {
429        const base = l ?? newList(now, 'Working on it')
430        const tasks = base.isPlanned ? base.tasks : []
431        const task: Task = { id: String(made?.id ?? `task-${tasks.length + 1}`), name: cleanName(String(input.subject ?? '')), status: 'upcoming', percent: 0, hasReported: false }
432        return { ...base, tasks: ensureActive([...tasks, task]), isPlanned: true }
433      })
434    }
435    await clearNeedsYou($)
436    await noteOutcome($, ran)
437    return ran
438  })
439
440  on('classic.Notification', async ($, e, next) => {
441    if (e.notification_type === 'permission_prompt') await setNeedsYou($, 'Claude needs your OK to continue')
442    if (e.notification_type === 'elicitation_dialog') await setNeedsYou($, 'Claude has a question for you')
443    return next(e)
444  })
445
446  on('classic.StopFailure', async ($, e, next) => {
447    lastApiError = { kind: e.error, details: e.error_details }
448    return next(e)
449  })
450
451  on('turn.complete', async ($, e, next) => {
452    if (e.agentId === undefined) await onTurnComplete($, e.reason)
453    return next(e)
454  })
455
456  // --- hide the technical rows ---
457
458  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
459    if (!(await read($, enabled))) return next(e)
460    const { Box } = $.ui.resolve(e)
461    return <Box display="none" />
462  })
463  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
464    if (!(await read($, enabled))) return next(e)
465    const { Box } = $.ui.resolve(e)
466    return <Box display="none" />
467  })
468  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
469    if (!(await read($, enabled))) return next(e)
470    const { Box } = $.ui.resolve(e)
471    return <Box display="none" />
472  })
473  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) =>
474    (await read($, enabled)) ? next({ ...e, props: { ...e.props, hint: '' } }) : next(e),
475  )
476
477  // --- the band ---
478
479  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
480    if (e.props.hasSurvey) return next(e)
481    const { Box, Text, Button } = $.ui.resolve(e)
482    const isOn = await read($, enabled)
483    const list = await read($, checklist)
484    const tick = await read($, tickAtom)
485
486    const button = (
487      <Button key="clean-view-toggle" label={isOn ? '● Clean View: ON' : '○ Clean View: OFF'} onPress={() => void toggle($)} />
488    )
489    const plainRow = (left: RenderChildren) => (
490      <Box flexDirection="row" justifyContent="space-between">
491        <Box flexShrink={1}>{left}</Box>
492        {button}
493      </Box>
494    )
495    if (!isOn || list === null) return plainRow(<Text> </Text>)
496
497    const now = await $.clock.now()
498    const took = duration((list.finishedAt ?? now) - list.startedAt)
499    const accent = {
500      'working': PALETTE.pink,
501      'needs-you': PALETTE.yellow,
502      'stuck': PALETTE.red,
503      'stopped': PALETTE.subtle,
504      'done': PALETTE.green,
505    }[list.phase]
506    const isAnimated = list.phase === 'working' || list.phase === 'needs-you'
507
508    const words = list.title.split(' ')
509    const title = words.map((word, i) => (
510      <Text bold color={rampAt(TITLE_RAMP, words.length === 1 ? 0 : i / (words.length - 1))}>
511        {i === 0 ? word : ` ${word}`}
512      </Text>
513    ))
514    const header = (() => {
515      switch (list.phase) {
516        case 'needs-you':
517          return (
518            <Text wrap="truncate-end">
519              <Text inverse bold color={PALETTE.yellow}> Needs you </Text>
520              <Text color={PALETTE.text}> {list.needsYouReason ?? ''}</Text>
521            </Text>
522          )
523        case 'stuck':
524          return <Text wrap="truncate-end" bold color={PALETTE.red}>⚠ Stuck: {list.stuckReason ?? ''}</Text>
525        case 'stopped':
526          return (
527            <Text wrap="truncate-end" color={PALETTE.subtle}>
528              ■ Stopped · {title} · you pressed Esc
529            </Text>
530          )
531        case 'done':
532          return (
533            <Text wrap="truncate-end">
534              <Text bold color={PALETTE.green}>✓ All done · </Text>
535              {title}
536              <Text color={PALETTE.subtle}> · took {took}</Text>
537            </Text>
538          )
539        default:
540          return (
541            <Text wrap="truncate-end">
542              <Text color={accent}>{SPARKLE[tick % SPARKLE.length]} </Text>
543              {title}
544            </Text>
545          )
546      }
547    })()
548    const headerRow = (
549      <Box flexDirection="row" justifyContent="space-between">
550        <Box flexShrink={1}>{header}</Box>
551        <Box flexDirection="row" gap={1} flexShrink={0}>
552          {list.phase === 'working' && <Text color={PALETTE.subtle}>{took}</Text>}
553          {button}
554        </Box>
555      </Box>
556    )
557    // Collapsed, the finished job keeps its frame: one line inside the green border.
558    if (list.phase === 'done' && list.isCollapsed) {
559      return (
560        <Box flexDirection="column" borderStyle="round" borderColor={accent} paddingX={1}>
561          {headerRow}
562        </Box>
563      )
564    }
565
566    const total = list.tasks.length
567    const doneCount = list.tasks.filter(t => t.status === 'done').length
568    const active = list.tasks.find(t => t.status === 'active')
569    const overall = total === 0 ? 0 : Math.round((doneCount * 100 + (active?.percent ?? 0)) / total)
570    const stepOf = Math.min(total, doneCount + (active ? 1 : 0))
571    const stepText = `Step ${Math.max(1, stepOf)} of ${total}`
572    const barWidth = METER
573    const barFilled = Math.round((overall / 100) * barWidth)
574    const barRamp = {
575      'working': [PALETTE.peach, PALETTE.pink],
576      'needs-you': [PALETTE.peach, PALETTE.yellow],
577      'stuck': [PALETTE.peach, PALETTE.red],
578      'stopped': [PALETTE.track, PALETTE.subtle],
579      'done': ['#40A02B', PALETTE.green],
580    }[list.phase]
581
582    // border 2 + padding 2, then glyph 2 + name + space + meter 10 + two spaces + label 7
583    const nameWidth = Math.max(6, e.props.bodyColumns - 4 - 2 - 1 - METER - 2 - 7)
584    const firstUpcoming = list.tasks.findIndex(t => t.status === 'upcoming')
585    const cells = (task: Task) => meter(task, tick, isAnimated).map(cell => <Text color={cell.color}>{cell.char}</Text>)
586
587    return (
588      <Box flexDirection="column" borderStyle="round" borderColor={accent} paddingX={1}>
589        {headerRow}
590        <Text wrap="truncate-end">
591          <Text color={PALETTE.subtle}>{stepText}  </Text>
592          {Array.from({ length: barWidth }, (_, i) =>
593            // A space between cells so the slanted glyphs don't run together.
594            i < barFilled ? <Text color={rampAt(barRamp, i / (barWidth - 1))}>{i === 0 ? '▰' : ' ▰'}</Text> : <Text color={PALETTE.track}>{i === 0 ? '▱' : ' ▱'}</Text>,
595          )}
596          <Text color={accent}>  {overall}%</Text>
597        </Text>
598        {list.tasks.map((task, i) => {
599          const name = fit(task.name, nameWidth)
600          if (task.status === 'done') {
601            return (
602              <Text wrap="truncate-end">
603                <Text color={PALETTE.green}>✓ </Text>
604                <Text color={PALETTE.subtle}>{name} </Text>
605                {cells(task)}
606                <Text color={PALETTE.green}>  {label(task, false)}</Text>
607              </Text>
608            )
609          }
610          if (task.status === 'active') {
611            const isWaiting = list.phase === 'needs-you'
612            return (
613              <Text wrap="truncate-end">
614                <Text color={isWaiting ? PALETTE.yellow : PALETTE.pink}>{isWaiting ? '‖ ' : '▶ '}</Text>
615                <Text bold color={PALETTE.text}>{name} </Text>
616                {cells(task)}
617                <Text bold color={accent}>  {label(task, false)}</Text>
618              </Text>
619            )
620          }
621          return (
622            <Text wrap="truncate-end" color={PALETTE.subtle}>
623              ○ {name} {cells(task)}  {label(task, i === firstUpcoming)}
624            </Text>
625          )
626        })}
627      </Box>
628    )
629  })
630}
631
types/index.d.ts 35 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 = 'working' | 'needs-you' | '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  /** True once Claude laid out a real plan (plan_steps or a to-do list). */
23  isPlanned: boolean
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    'oli-claude-mod-clean-view': {
29      cleanViewEnabled: boolean
30      checklist: CleanViewChecklist | null
31      tick: number
32    }
33  }
34}
35