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

hooks/register.tsx 7 lines1import type { Register } from 'claude-code'
2import { registerCleanView } from './clean-view'
3
4export const register: Register = on => {
5 registerCleanView(on)
6}
7hooks/clean-view.tsx 774 lines1// 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}
774types/index.d.ts 32 lines1export 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