SLOPSHOPPER

clean-view

Hides tool rows and shows a plain-English checklist above the prompt. Start with /simple on.

newbandrowsguardcommandtoast
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 on │ ⎿ Read 6 lines ╰───────────────╯ ⟨undefined⟩ ⏺ 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 now on. ⟨Claude Code's own drawing⟩ [ ● Clean View: ON ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ [ ● Clean View: ON ]
Tool row
⟨undefined⟩
Source 3 files
hooks/register.tsx 7 lines
1import type { Register } from 'claude-code'
2import { registerCleanView } from './clean-view'
3
4export const register: Register = on => {
5  registerCleanView(on)
6}
7
hooks/clean-view.tsx 774 lines
1// Clean View: hides tool rows and puts a plain-English checklist above the
2// prompt. Start with /simple on. The button on the right of the band also
3// toggles it. Off by default so it only activates on explicit request.
4
5import { atom, read, update } from 'claude-code'
6import type { On } from 'claude-code'
7import type { Checklist, Phase, Task, TaskStatus } from '../types'
8
9const enabledAtom = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, false)
10const checklistAtom = atom({ plugin: 'clean-view', key: 'checklist' } as const, null as Checklist | null)
11const tickAtom = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
12
13// Module-level runtime state — resets on reload, fine since it's per-turn
14let hasPlan = false
15let consecutiveFailures = 0
16let titleReqId = 0
17let doneCollapseCancel: (() => void) | undefined
18
19// Tools that bypass the plan gate
20const PLAN_EXEMPT = new Set([
21  'ToolSearch',
22  'TodoWrite',
23  'TaskCreate',
24  'TaskUpdate',
25  'AskUserQuestion',
26  'mcp__clean-view__plan_steps',
27  'mcp__clean-view__report_progress',
28])
29
30// ── Name cleaner ──────────────────────────────────────────────────────────────
31
32export function cleanName(raw: string): string {
33  let s = raw
34  s = s.replace(/`[^`]*`/g, '')                                                         // strip backtick spans
35  s = s.replace(/\S*\/\S*/g, '')                                                        // remove paths
36  s = s.replace(/\b\S+\.(tsx?|jsx?|py|rs|go|java|c|cpp|h|css|html?|json|ya?ml|md|sh|rb|php)\b/gi, '') // code filenames
37  s = s.replace(/\s+/g, ' ').trim()
38  if (s.length > 0) s = s[0]!.toUpperCase() + s.slice(1)
39  if (s.length > 40) {
40    let cut = s.slice(0, 40)
41    const sp = cut.lastIndexOf(' ')
42    if (sp > 0) cut = cut.slice(0, sp)
43    s = cut + '…'
44  }
45  return s || 'Working on it'
46}
47
48// ── Meters ────────────────────────────────────────────────────────────────────
49
50function meter(percent: number): string {
51  const filled = Math.round(Math.max(0, Math.min(100, percent)) / 10)
52  return '█'.repeat(filled) + '░'.repeat(10 - filled)
53}
54
55function sweepMeter(tick: number): string {
56  const pos = tick % 8
57  return Array.from({ length: 10 }, (_, i) => (i >= pos && i < pos + 3 ? '▓' : '░')).join('')
58}
59
60function elapsed(startMs: number, endMs: number): string {
61  const s = Math.floor((endMs - startMs) / 1000)
62  if (s < 60) return `${s}s`
63  const m = Math.floor(s / 60)
64  const rem = s % 60
65  return rem > 0 ? `${m}m ${rem}s` : `${m}m`
66}
67
68// ── Task helpers ──────────────────────────────────────────────────────────────
69
70function makeTask(id: string, name: string, status: TaskStatus = 'upcoming'): Task {
71  return { id, name, status, percent: 0, hasReported: false }
72}
73
74function applyProgress(tasks: Task[], reportedName: string, percent: number): Task[] {
75  const cleaned = cleanName(reportedName)
76  // Find by exact name first, then fall back to current active
77  let idx = tasks.findIndex(t => t.name === cleaned || t.name === reportedName)
78  if (idx === -1) idx = tasks.findIndex(t => t.status === 'active')
79
80  if (idx === -1) {
81    // Not in plan — append as new active step, closing the current active
82    const newTask: Task = { id: `dyn-${Date.now()}`, name: cleaned, status: 'active', percent, hasReported: true }
83    return [
84      ...tasks.map(t => t.status === 'active' ? { ...t, status: 'done' as TaskStatus, percent: 100 } : t),
85      newTask,
86    ]
87  }
88
89  // Check off everything before this step
90  let updated = tasks.map((t, i): Task => i < idx ? { ...t, status: 'done', percent: 100 } : t)
91
92  if (percent >= 100) {
93    updated = updated.map((t, i): Task => {
94      if (i === idx) return { ...t, status: 'done', percent: 100, hasReported: true }
95      if (i === idx + 1) return { ...t, status: 'active' }
96      return t
97    })
98  } else {
99    updated = updated.map((t, i): Task =>
100      i === idx ? { ...t, status: 'active', percent, hasReported: true } : t,
101    )
102  }
103
104  return updated
105}
106
107// ── Main export ───────────────────────────────────────────────────────────────
108
109export function registerCleanView(on: On): void {
110  // ── Session start: load persisted toggle, register tools & command, start tick ──
111
112  on('session.start', async ($, e, next) => {
113    const stored = await $.store.get('cleanViewEnabled')
114    await update($, enabledAtom, () => stored === true) // default OFF
115
116    await $.tool.register({
117      name: 'plan_steps',
118      description:
119        'Lay out every step of this job up front, in order. Call this FIRST before any other tool. Step names must be plain English that a non-technical person understands — no file names, no code, no slashes.',
120      inputSchema: {
121        type: 'object',
122        properties: {
123          steps: {
124            type: 'array',
125            items: { type: 'string' },
126            description: '2–8 step names. Each starts with a verb, under 40 characters, no technical terms.',
127            minItems: 1,
128            maxItems: 8,
129          },
130        },
131        required: ['steps'],
132      },
133    })
134
135    await $.tool.register({
136      name: 'report_progress',
137      description:
138        'Report how far along the current step is. Call with percent=100 the moment a step finishes.',
139      inputSchema: {
140        type: 'object',
141        properties: {
142          task: { type: 'string', description: 'The step name being reported on.' },
143          percent: { type: 'number', description: 'How far along, 0–100. Use 100 when this step is done.' },
144        },
145        required: ['task', 'percent'],
146      },
147    })
148
149    try {
150      await $.command.register({
151        name: 'simple',
152        description: 'Turn Clean View on or off (/simple on | off, or /simple to toggle)',
153        argumentHint: '[on|off]',
154        immediate: true,
155      })
156    } catch (err) {
157      $.ui.log(`clean-view: /simple register failed (${String(err)})`, { to: 'debug' })
158    }
159
160    // 250ms tick drives the sweep animation while a job is in progress
161    $.clock.every(250, async () => {
162      const cl = await read($, checklistAtom)
163      if (cl !== null && (cl.phase === 'working' || cl.phase === 'needs-you')) {
164        await update($, tickAtom, t => ((t ?? 0) + 1) % 100)
165      }
166    })
167
168    return next(e)
169  })
170
171  // ── plan_steps tool ────────────────────────────────────────────────────────
172
173  on('tool.call', { tool: 'mcp__clean-view__plan_steps' }, async ($, e) => {
174    const input = e as unknown as { steps?: unknown }
175    const raw = Array.isArray(input.steps) ? input.steps : []
176    if (raw.length === 0) return { result: 'Pass at least one step name.' }
177
178    const steps: Task[] = raw.slice(0, 8).map((s, i) =>
179      makeTask(`step-${i}`, cleanName(String(s)), i === 0 ? 'active' : 'upcoming'),
180    )
181    hasPlan = true
182    const now = await $.clock.now()
183
184    await update($, checklistAtom, cl => ({
185      title: cl?.title ?? 'Working on it',
186      phase: 'working' as Phase,
187      tasks: steps,
188      needsYouReason: null,
189      stuckReason: null,
190      startedAt: cl?.startedAt ?? now,
191      finishedAt: null,
192      isCollapsed: false,
193    }))
194
195    const lines = steps.map((t, i) => `  ${i === 0 ? '▶' : '○'} ${t.name}`).join('\n')
196    return { result: `Planned ${steps.length} steps. The first one has started.\n\n${lines}` }
197  })
198
199  // ── report_progress tool ───────────────────────────────────────────────────
200
201  on('tool.call', { tool: 'mcp__clean-view__report_progress' }, async ($, e, next) => {
202    if (e.agentId !== undefined) return next(e)  // agent-dock handles helper progress
203    const input = e as unknown as { task?: unknown; percent?: unknown }
204    const taskName = String(input.task ?? '')
205    const raw = Number(input.percent ?? 0)
206    const pct = Math.max(0, Math.min(100, Number.isFinite(raw) ? raw : 0))
207
208    await update($, checklistAtom, cl => {
209      if (cl === null) return cl
210      return { ...cl, tasks: applyProgress(cl.tasks, taskName, pct), phase: 'working' as Phase }
211    })
212
213    const cl2 = await read($, checklistAtom)
214    let statusLines = ''
215    if (cl2 !== null) {
216      statusLines = '\n\n' + cl2.tasks.map(t => {
217        const icon = t.status === 'done' ? '✓' : t.status === 'active' ? '▶' : '○'
218        const label = t.status === 'done' ? '[done]' : t.status === 'active' ? `[${t.hasReported ? `${t.percent}%` : 'working'}]` : '[up next]'
219        return `  ${icon} ${t.name}  ${label}`
220      }).join('\n')
221    }
222    return { result: `Progress noted: ${pct}%.${statusLines}` }
223  })
224
225  // ── General tool.call: plan gate + failure tracking + needs-you clearing ──
226
227  on('tool.call', async ($, e, next) => {
228    const toolName = String(e.tool)
229
230    // Never gate subagents
231    if (e.agentId !== undefined) return next(e)
232
233    const enabled = await read($, enabledAtom)
234    if (!enabled) return next(e)
235
236    // Clear "needs you" the moment any non-question tool fires
237    if (toolName !== 'AskUserQuestion') {
238      const cl = await read($, checklistAtom)
239      if (cl?.phase === 'needs-you') {
240        await update($, checklistAtom, c =>
241          c === null ? c : { ...c, phase: 'working' as Phase, needsYouReason: null },
242        )
243      }
244    }
245
246    // Plan gate
247    if (!hasPlan && !PLAN_EXEMPT.has(toolName)) {
248      return {
249        deny: 'Call mcp__clean-view__plan_steps first to lay out the steps. Use ToolSearch to load it if it is deferred.',
250      }
251    }
252
253    const result = await next(e)
254
255    // Track consecutive tool errors (distinct from user-permission denials)
256    if (result.isError === true) {
257      consecutiveFailures++
258      if (consecutiveFailures >= 3) {
259        await update($, checklistAtom, cl =>
260          cl === null ? cl : {
261            ...cl,
262            phase: 'stuck' as Phase,
263            stuckReason: 'A step keeps failing, Claude is trying another way',
264          },
265        )
266      }
267    } else if (result.deny === undefined) {
268      consecutiveFailures = 0
269      const cl = await read($, checklistAtom)
270      if (cl?.phase === 'stuck' && cl.stuckReason !== 'You said no to a step, so Claude paused') {
271        await update($, checklistAtom, c =>
272          c === null ? c : { ...c, phase: 'working' as Phase, stuckReason: null },
273        )
274      }
275    }
276
277    return result
278  })
279
280  // ── AskUserQuestion → Needs you ───────────────────────────────────────────
281
282  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
283    const enabled = await read($, enabledAtom)
284    if (enabled) {
285      await update($, checklistAtom, cl =>
286        cl === null ? cl : {
287          ...cl,
288          phase: 'needs-you' as Phase,
289          needsYouReason: 'Claude needs your answer to continue',
290        },
291      )
292    }
293    return next(e)
294  })
295
296  // ── classic.Notification → Needs you ─────────────────────────────────────
297
298  on('classic.Notification', async ($, e, next) => {
299    const enabled = await read($, enabledAtom)
300    if (enabled) {
301      await update($, checklistAtom, cl =>
302        cl === null ? cl : {
303          ...cl,
304          phase: 'needs-you' as Phase,
305          needsYouReason: 'Claude needs your OK to continue',
306        },
307      )
308    }
309    return next(e)
310  })
311
312  // ── classic.PermissionDenied → Stuck ─────────────────────────────────────
313
314  on('classic.PermissionDenied' as 'classic.PermissionDenied', async ($, e, next) => {
315    const enabled = await read($, enabledAtom)
316    if (enabled) {
317      await update($, checklistAtom, cl =>
318        cl === null ? cl : {
319          ...cl,
320          phase: 'stuck' as Phase,
321          stuckReason: 'You said no to a step, so Claude paused',
322        },
323      )
324    }
325    return next(e)
326  })
327
328  // ── classic.StopFailure → Stuck with plain-language reason ───────────────
329
330  on('classic.StopFailure', async ($, e, next) => {
331    const enabled = await read($, enabledAtom)
332    if (!enabled) return next(e)
333
334    const ev = e as unknown as { error?: string }
335    let reason: string
336    switch (ev.error) {
337      case 'rate_limit':
338        reason = 'You hit your usage limit, try again a little later'
339        break
340      case 'overloaded':
341        reason = "Claude's servers are busy, try again in a minute"
342        break
343      case 'authentication_failed':
344      case 'oauth_org_not_allowed':
345      case 'billing_error':
346        reason = 'Type /login to reconnect'
347        break
348      default:
349        reason = 'Something went wrong, try again in a moment'
350    }
351
352    await update($, checklistAtom, cl =>
353      cl === null ? cl : { ...cl, phase: 'stuck' as Phase, stuckReason: reason },
354    )
355    return next(e)
356  })
357
358  // ── TodoWrite → replace checklist tasks ───────────────────────────────────
359
360  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
361    const result = await next(e)
362    const enabled = await read($, enabledAtom)
363    if (!enabled) return result
364
365    const input = e as unknown as { todos?: unknown }
366    const todos = Array.isArray(input.todos) ? input.todos : []
367    hasPlan = true
368
369    let foundActive = false
370    const tasks: Task[] = todos.map((todo: unknown, i: number) => {
371      const t = (todo ?? {}) as Record<string, unknown>
372      const name = cleanName(String(t['content'] ?? 'Working on it'))
373      let status: TaskStatus =
374        t['status'] === 'completed' ? 'done' :
375        t['status'] === 'in_progress' ? 'active' : 'upcoming'
376      if (status === 'active') {
377        if (foundActive) status = 'upcoming'
378        else foundActive = true
379      }
380      return { id: `todo-${i}`, name, status, percent: status === 'done' ? 100 : 0, hasReported: false }
381    })
382
383    await update($, checklistAtom, cl => ({
384      title: cl?.title ?? 'Working on it',
385      phase: 'working' as Phase,
386      tasks,
387      needsYouReason: null,
388      stuckReason: cl?.stuckReason ?? null,
389      startedAt: cl?.startedAt ?? null,
390      finishedAt: null,
391      isCollapsed: false,
392    }))
393
394    return result
395  })
396
397  // ── TaskCreate → append checklist row ─────────────────────────────────────
398
399  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
400    const result = await next(e)
401    const enabled = await read($, enabledAtom)
402    if (!enabled) return result
403
404    const input = e as unknown as { subject?: unknown }
405    const name = cleanName(String(input.subject ?? 'Working on it'))
406    hasPlan = true
407
408    await update($, checklistAtom, cl => {
409      const tasks = cl?.tasks ?? []
410      return {
411        title: cl?.title ?? 'Working on it',
412        phase: 'working' as Phase,
413        tasks: [...tasks, makeTask(`tc-${Date.now()}`, name, 'upcoming')],
414        needsYouReason: cl?.needsYouReason ?? null,
415        stuckReason: cl?.stuckReason ?? null,
416        startedAt: cl?.startedAt ?? null,
417        finishedAt: null,
418        isCollapsed: false,
419      }
420    })
421
422    return result
423  })
424
425  // ── TaskUpdate → update matching checklist row ────────────────────────────
426
427  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
428    const result = await next(e)
429    const enabled = await read($, enabledAtom)
430    if (!enabled) return result
431
432    const input = e as unknown as { subject?: unknown; status?: unknown }
433    if (input.subject === undefined && input.status === undefined) return result
434
435    const newName = input.subject !== undefined ? cleanName(String(input.subject)) : undefined
436    const newStatus = String(input.status ?? '')
437
438    await update($, checklistAtom, cl => {
439      if (cl === null) return cl
440      const tasks = cl.tasks.map(t => {
441        if (t.status !== 'active') return t
442        const updated = { ...t }
443        if (newName !== undefined) updated.name = newName
444        if (newStatus === 'completed') { updated.status = 'done'; updated.percent = 100 }
445        else if (newStatus === 'in_progress') updated.status = 'active'
446        else if (newStatus === 'pending') updated.status = 'upcoming'
447        return updated
448      })
449      return { ...cl, tasks }
450    })
451
452    return result
453  })
454
455  // ── turn.start → new job with placeholder steps + async title ─────────────
456
457  on('turn.start', async ($, e, next) => {
458    if (e.agentId !== undefined) return next(e)
459
460    hasPlan = false
461    consecutiveFailures = 0
462    doneCollapseCancel?.()
463    doneCollapseCancel = undefined
464
465    const enabled = await read($, enabledAtom)
466    if (!enabled) return next(e)
467
468    const now = await $.clock.now()
469    const reqId = ++titleReqId
470
471    await update($, checklistAtom, () => ({
472      title: 'Working on it',
473      phase: 'working' as Phase,
474      tasks: [
475        makeTask('ph-1', 'Understand your request', 'active'),
476        makeTask('ph-2', 'Plan the steps', 'upcoming'),
477      ],
478      needsYouReason: null,
479      stuckReason: null,
480      startedAt: now,
481      finishedAt: null,
482      isCollapsed: false,
483    }))
484
485    // Background: ask Haiku for a 2–6 word verb-first title
486    const promptText = e.text
487    void (async () => {
488      try {
489        const r = await $.model.complete({
490          model: 'claude-haiku-4-5-20251001',
491          prompt: `Give a job title for this request in 2-6 plain English words. Start with a verb. No quotes, no punctuation at the end. Never use technical terms, file names, code or slashes. Examples: "Build the pricing section", "Fix the login bug", "Write a short poem". Only output the title itself, nothing else.\n\nRequest: ${promptText.slice(0, 400)}`,
492        })
493        if (!r.isAnswered) return
494        if (reqId !== titleReqId) return // newer job started
495        const title = cleanName(r.text.trim().split('\n')[0]?.trim() ?? '')
496        if (title && title !== 'Working on it') {
497          await update($, checklistAtom, cl => (cl === null ? cl : { ...cl, title }))
498        }
499      } catch {
500        // ignore — placeholder title stays
501      }
502    })()
503
504    return next(e)
505  })
506
507  // ── turn.complete → final state ────────────────────────────────────────────
508
509  on('turn.complete', async ($, e, next) => {
510    const result = await next(e)
511    if (e.agentId !== undefined) return result
512
513    const enabled = await read($, enabledAtom)
514    if (!enabled) return result
515
516    const now = await $.clock.now()
517
518    if (e.isAborted) {
519      await update($, checklistAtom, cl =>
520        cl === null ? cl : { ...cl, phase: 'stopped' as Phase, finishedAt: now },
521      )
522      return result
523    }
524
525    if (e.reason === 'refusal') {
526      await update($, checklistAtom, cl =>
527        cl === null ? cl : {
528          ...cl,
529          phase: 'stuck' as Phase,
530          stuckReason: "Claude couldn't help with that request",
531          finishedAt: now,
532        },
533      )
534      return result
535    }
536
537    if (e.reason === 'error') {
538      // StopFailure already set a message; only set a fallback if nothing set
539      const cl = await read($, checklistAtom)
540      if (cl?.phase !== 'stuck') {
541        await update($, checklistAtom, c =>
542          c === null ? c : {
543            ...c,
544            phase: 'stuck' as Phase,
545            stuckReason: 'Something went wrong, try again in a moment',
546            finishedAt: now,
547          },
548        )
549      }
550      return result
551    }
552
553    // Normal answer — mark all tasks done
554    await update($, checklistAtom, cl =>
555      cl === null ? cl : {
556        ...cl,
557        phase: 'done' as Phase,
558        finishedAt: now,
559        tasks: cl.tasks.map(t => ({ ...t, status: 'done' as TaskStatus, percent: 100 })),
560      },
561    )
562
563    // Collapse to one line after 5 seconds
564    doneCollapseCancel = $.clock.after(5_000, async () => {
565      await update($, checklistAtom, cl => (cl === null ? cl : { ...cl, isCollapsed: true }))
566      doneCollapseCancel = undefined
567    })
568
569    return result
570  })
571
572  // ── prompt.compose → inject Clean View instructions ───────────────────────
573
574  on('prompt.compose', async ($, e, next) => {
575    const base = await next(e)
576    const enabled = await read($, enabledAtom)
577    if (!enabled) return base
578    return {
579      sections: [
580        ...base.sections,
581        {
582          id: 'clean-view:instructions',
583          text: `## Clean View — task reporting (follow strictly)
584
585FIRST action for EVERY request (even a quick question): call the tool \`mcp__clean-view__plan_steps\`. If it is not listed in your tools, call ToolSearch first with query "select:mcp__clean-view__plan_steps".
586
587Step name rules (enforced strictly):
588- Plain English only. A non-technical person must understand every step name.
589- Start each name with a verb: "Build the pricing section", "Check your settings", "Write the intro".
590- Under 40 characters. No file paths, file names, slashes, backticks, code, or tool names.
591- 2 to 8 steps per job.
592
593Then call \`mcp__clean-view__report_progress\` as work progresses, and with percent=100 the moment each step finishes.
594
595If this session uses TodoWrite or TaskCreate, those can serve as the plan — call them before other tools.`,
596          scope: 'session' as const,
597        },
598      ],
599    }
600  })
601
602  // ── /simple command ────────────────────────────────────────────────────────
603
604  on('command.run', { command: 'simple' }, async ($, e) => {
605    const args = e.args.trim().toLowerCase()
606    const current = await read($, enabledAtom)
607    const next = args === 'on' ? true : args === 'off' ? false : !current
608
609    await update($, enabledAtom, () => next)
610    await $.store.set('cleanViewEnabled', next)
611    $.ui.toast(`Clean View ${next ? 'on' : 'off'}`)
612
613    return { text: `Clean View is now ${next ? 'on' : 'off'}.` }
614  })
615
616  // ── Hide tool rows ─────────────────────────────────────────────────────────
617
618  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
619    const enabled = await read($, enabledAtom)
620    return enabled ? {} : next(e)
621  })
622
623  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
624    const enabled = await read($, enabledAtom)
625    return enabled ? {} : next(e)
626  })
627
628  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
629    const enabled = await read($, enabledAtom)
630    return enabled ? {} : next(e)
631  })
632
633  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
634    const enabled = await read($, enabledAtom)
635    if (!enabled) return next(e)
636    return next({ ...e, props: { ...e.props, hint: '' } })
637  })
638
639  // ── AbovePrompt band ───────────────────────────────────────────────────────
640
641  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
642    const below = await next(e)
643    if (e.props.hasSurvey) return below
644
645    const enabled = await read($, enabledAtom)
646    const cl = await read($, checklistAtom)
647    const tick = (await read($, tickAtom)) ?? 0
648    const nowMs = await $.clock.now()
649
650    const { Box, Text, Button } = $.ui.resolve(e)
651    const cols = e.props.bodyColumns
652
653    const toggle = async () => {
654      const cur = await read($, enabledAtom)
655      const nxt = !cur
656      await update($, enabledAtom, () => nxt)
657      await $.store.set('cleanViewEnabled', nxt)
658      $.ui.toast(`Clean View ${nxt ? 'on' : 'off'}`)
659    }
660
661    const btnLabel = `● Clean View: ${enabled ? 'ON' : 'OFF'}`
662
663    // When off or no active job: show button only
664    if (!enabled || cl === null) {
665      return (
666        <Box flexDirection="column">
667          {below}
668          <Box key="cv" flexDirection="row" justifyContent="flex-end" width={cols}>
669            <Button key="toggle" label={btnLabel} onPress={toggle} />
670          </Box>
671        </Box>
672      )
673    }
674
675    // Header text per phase
676    const startMs = cl.startedAt ?? nowMs
677    const dur = cl.finishedAt !== null ? elapsed(startMs, cl.finishedAt) : elapsed(startMs, nowMs)
678
679    let headerText: string
680    let headerColor: string | undefined
681    let headerBold = false
682    switch (cl.phase) {
683      case 'working':
684        headerText = `${cl.title} · ${dur}`
685        break
686      case 'needs-you':
687        headerText = `⚑ Needs you · ${cl.needsYouReason ?? 'Claude needs your input'}`
688        headerColor = 'yellow'
689        headerBold = true
690        break
691      case 'stuck':
692        headerText = `⚠ Stuck: ${cl.stuckReason ?? 'something went wrong'}`
693        headerColor = 'red'
694        break
695      case 'stopped':
696        headerText = `■ Stopped · ${cl.title} · you pressed Esc`
697        headerColor = 'gray'
698        break
699      case 'done':
700        headerText = `✓ All done · ${cl.title} · took ${dur}`
701        headerColor = 'green'
702        break
703      default:
704        headerText = cl.title
705    }
706
707    // Collapsed done: one line + button
708    if (cl.isCollapsed && cl.phase === 'done') {
709      return (
710        <Box flexDirection="column">
711          {below}
712          <Box key="cv" flexDirection="row" justifyContent="space-between" width={cols}>
713            <Text color={headerColor} dimColor>{headerText}</Text>
714            <Button key="toggle" label={btnLabel} onPress={toggle} />
715          </Box>
716        </Box>
717      )
718    }
719
720    // Layout math: icon(2) + name + gap + meter(10) + 2 + label(7)
721    // overhead = 21; name column gets the rest, min 10
722    const nameWidth = Math.max(10, cols - 23)
723    const firstUpcomingIdx = cl.tasks.findIndex(t => t.status === 'upcoming')
724
725    const taskRows = cl.tasks.map((task, i) => {
726      const isDone = task.status === 'done'
727      const isActive = task.status === 'active'
728      const isFirst = task.status === 'upcoming' && i === firstUpcomingIdx
729
730      const icon = cl.phase === 'needs-you' && isActive ? '‖' :
731                   isDone ? '✓' : isActive ? '▶' : '○'
732      const meterStr = isDone ? meter(100) :
733                       isActive && task.hasReported ? meter(task.percent) :
734                       isActive ? sweepMeter(tick) :
735                       meter(0)
736      const labelStr = isDone ? 'Done' :
737                       isActive && task.hasReported ? `${task.percent}%` :
738                       isActive ? 'Working' :
739                       isFirst ? 'Next' : 'Up next'
740
741      const displayName = task.name.length > nameWidth
742        ? task.name.slice(0, nameWidth - 1) + '…'
743        : task.name
744
745      return (
746        <Box key={`t-${task.id}`} flexDirection="row">
747          <Text color={isDone ? 'green' : undefined} dimColor={!isActive && !isDone}>
748            {icon}{' '}
749          </Text>
750          <Text dimColor={isDone || !isActive} bold={isActive}>
751            {displayName}
752          </Text>
753          <Box flexGrow={1} />
754          <Text dimColor={isDone || !isActive}>{meterStr}</Text>
755          <Text dimColor>{'  '}{labelStr}</Text>
756        </Box>
757      )
758    })
759
760    return (
761      <Box flexDirection="column">
762        {below}
763        <Box key="cv" flexDirection="column" width={cols}>
764          <Box flexDirection="row" justifyContent="space-between">
765            <Text color={headerColor} bold={headerBold}>{headerText}</Text>
766            <Button key="toggle" label={btnLabel} onPress={toggle} />
767          </Box>
768          {taskRows}
769        </Box>
770      </Box>
771    )
772  })
773}
774
types/index.d.ts 32 lines
1export type TaskStatus = 'done' | 'active' | 'upcoming'
2export type Phase = 'idle' | 'working' | 'needs-you' | 'stuck' | 'stopped' | 'done'
3
4export type Task = {
5  id: string
6  name: string
7  status: TaskStatus
8  percent: number
9  hasReported: boolean
10}
11
12export type Checklist = {
13  title: string
14  phase: Phase
15  tasks: Task[]
16  needsYouReason: string | null
17  stuckReason: string | null
18  startedAt: number | null
19  finishedAt: number | null
20  isCollapsed: boolean
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    'clean-view': {
26      cleanViewEnabled: boolean
27      checklist: Checklist | null
28      tick: number
29    }
30  }
31}
32