Calm, non-technical view: hides tool rows and shows one plain-English checklist above the prompt.

Public Claude Code plugins and mods by schmas.
| Plugin | Scope | Contents | ||
|---|---|---|---|---|
clean-view | Calm view for non-technical users (mod) | Hides tool calls, diffs and command output; shows one plain-English checklist with progress meters above the prompt. Three modes: on hides details, both shows details and the checklist, off. Switch with the band button or `/simple on\ | both\ | off`. With Toolbox installed, the switch is a row in the Toolbox panel |
toolbox | Model and effort switcher (mod) | ◆ Toolbox button sits in the prompt footer, next to the mode labels, and opens a panel last above the prompt. A SETTINGS row moves the button above the prompt too. One click sets the session model or effort. Other mods add rows to it through $.toolbox. Toggle with the button or /toolbox |
Add the marketplace once, then install the plugins you want.
/plugin marketplace add schmas/claude-plugins
/plugin install clean-view@schmas
/plugin install toolbox@schmas
/reload-plugins
claude-plugins/
├── .claude-plugin/
│ └── marketplace.json # Lists 2 plugins
├── plugins/
│ ├── clean-view/
│ │ ├── .claude-plugin/plugin.json
│ │ ├── hooks/ # Function-hook mod + tests
│ │ └── types/ # Shared checklist state contract
│ └── toolbox/
│ ├── .claude-plugin/plugin.json
│ ├── hooks/ # Function-hook mod + tests
│ └── types/ # $.toolbox add-on API + state contract
└── README.md
MIT. See LICENSE.
hooks/register.tsx 8 lines1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5export const register: Register = on => {
6 registerCleanView(on)
7}
8hooks/clean-view.tsx 1150 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderElement, RenderInput, Timer } from 'claude-code'
3
4import type { CleanViewChecklist, CleanViewMode, CleanViewPhase, CleanViewTask } from '../types'
5
6type Engine = EngineInterface
7
8const PLAN_TOOL = 'mcp__clean-view__plan_steps'
9const PROGRESS_TOOL = 'mcp__clean-view__report_progress'
10const STORE_KEY = 'cleanViewMode'
11/** The on/off value saved before the both mode existed. */
12const LEGACY_STORE_KEY = 'cleanViewEnabled'
13const SECTION_ID = 'clean-view:checklist'
14
15const MAX_NAME = 40
16const FALLBACK_NAME = 'Working on it'
17const METER_CELLS = 16
18const FRAME_MS = 250
19const FAIL_LIMIT = 3
20const MIN_STEPS = 2
21const MAX_STEPS = 8
22
23const ALWAYS_ALLOWED = new Set([
24 'ToolSearch',
25 'TodoWrite',
26 'TaskCreate',
27 'TaskUpdate',
28 'AskUserQuestion',
29 PLAN_TOOL,
30])
31
32const PLACEHOLDER_STEPS = ['Understand your request', 'Plan the steps']
33
34const OWN_TOOLS = new Set([PLAN_TOOL, PROGRESS_TOOL])
35const OFF_DESCRIPTION = 'Clean View is off. Do not call this tool.'
36const OFF_REPLY = 'Clean View is off. Skip this tool and go on with the work.'
37
38const NEEDS_OK = 'Claude needs your OK to continue'
39const HAS_QUESTION = 'Claude has a question for you'
40const WAITING_REPLY = 'Claude is waiting for your reply'
41const SAID_NO = 'you said no to a step, so Claude paused'
42const KEEPS_FAILING = 'a step keeps failing, Claude is trying another way'
43const REFUSED = "Claude couldn't help with that request"
44const API_USAGE = 'you hit your usage limit, try again a little later'
45const API_BUSY = "Claude's servers are busy, try again in a minute"
46const API_TOO_LONG = 'type /compact and try again'
47const API_NETWORK = 'the internet connection dropped'
48const API_AUTH = 'type /login'
49const API_OTHER = 'something went wrong talking to Claude, try again'
50
51const USER_SAID_NO = /doesn't want to proceed|tool use was rejected/i
52
53const GATE_MESSAGE =
54 `Clean View: call ${PLAN_TOOL} first to lay out the steps of this job in plain English ` +
55 '(load it with ToolSearch if it is deferred). Then try this tool again.'
56
57const CODE_EXTENSIONS =
58 'tsx?|jsx?|mjs|cjs|mts|cts|py|rb|go|rs|java|kt|kts|swift|c|cc|cpp|h|hpp|cs|php|sh|bash|zsh|fish|ps1|' +
59 'json|jsonc|ya?ml|toml|ini|env|lock|md|mdx|html?|css|scss|sass|less|sql|vue|svelte|xml|gradle|tf|dart|lua|r'
60const FILE_NAME = new RegExp(`^[("'\\[]*[\\w.-]+\\.(${CODE_EXTENSIONS})[)"'\\],.:;!?]*$`, 'i')
61
62const EMPTY: CleanViewChecklist = {
63 title: '',
64 phase: 'idle',
65 tasks: [],
66 needsYouReason: null,
67 stuckReason: null,
68 startedAt: null,
69 finishedAt: null,
70 isPlanned: false,
71}
72
73const MODES: readonly CleanViewMode[] = ['on', 'both', 'off']
74
75const MODE_LABEL: Record<CleanViewMode, string> = {
76 on: '● Clean View: ON',
77 both: '◐ Clean View: BOTH',
78 off: '○ Clean View: OFF',
79}
80
81const MODE_TOAST: Record<CleanViewMode, string> = {
82 on: 'Clean View is on. Technical details are hidden.',
83 both: 'Clean View is on. You see the checklist and every detail.',
84 off: 'Clean View is off. You see every detail again.',
85}
86
87/** The Toolbox row's choices, in /simple's order. */
88const MODE_CHOICE: Record<CleanViewMode, string> = {
89 on: 'On',
90 both: 'Both',
91 off: 'Off',
92}
93
94const MODE_REPLY: Record<CleanViewMode, string> = {
95 on: 'Clean View is on.',
96 both: 'Clean View is on, with every detail shown.',
97 off: 'Clean View is off.',
98}
99
100const MODE = { plugin: 'clean-view', key: 'mode' } as const
101const mode = atom(MODE, 'on' as CleanViewMode)
102const checklist = atom({ plugin: 'clean-view', key: 'checklist' } as const, EMPTY)
103const tick = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
104
105/** Turns any step or job name into short plain English with no code in it. */
106export function cleanName(raw: string): string {
107 const words = raw
108 .replace(/`[^`]*`/g, ' ')
109 .replace(/`/g, ' ')
110 .split(/\s+/)
111 .filter(word => word !== '' && !word.includes('/') && !word.includes('\\') && !FILE_NAME.test(word))
112 const text = words.join(' ').replace(/\s+/g, ' ').trim()
113
114 if (text === '') {
115 return FALLBACK_NAME
116 }
117
118 const named = text[0]!.toUpperCase() + text.slice(1)
119
120 if (named.length <= MAX_NAME) {
121 return named
122 }
123
124 const head = named.slice(0, MAX_NAME - 1)
125 const space = head.lastIndexOf(' ')
126 const cut = (space > 0 ? head.slice(0, space) : head).replace(/[\s,.;:!?-]+$/, '')
127
128 return `${cut}…`
129}
130
131function jobTitle(raw: string): string {
132 const firstLine = raw.split('\n').find(line => line.trim() !== '') ?? ''
133 const words = cleanName(firstLine.replace(/["'*_#.]/g, ' ')).replace(/…$/, '').split(' ')
134
135 return cleanName(words.slice(0, 6).join(' '))
136}
137
138function elapsed(ms: number): string {
139 const seconds = Math.max(0, Math.floor(ms / 1000))
140 const hours = Math.floor(seconds / 3600)
141 const minutes = Math.floor((seconds % 3600) / 60)
142
143 if (hours > 0) {
144 return `${hours}h ${minutes}m`
145 }
146
147 return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`
148}
149
150function clampPercent(value: unknown): number {
151 const number = Number(value)
152
153 return Number.isFinite(number) ? Math.min(100, Math.max(0, Math.round(number))) : 0
154}
155
156function isMode(value: unknown): value is CleanViewMode {
157 return MODES.includes(value as CleanViewMode)
158}
159
160function nextMode(current: CleanViewMode): CleanViewMode {
161 return MODES[(MODES.indexOf(current) + 1) % MODES.length]!
162}
163
164/**
165 * The mode now. A /clear starts the session's state over, and no event this
166 * mod can count on fires after it, so a mode not set yet this session comes
167 * from the store.
168 */
169async function currentMode($: Engine): Promise<CleanViewMode> {
170 const { value } = await $.state.get(MODE)
171
172 return isMode(value) ? value : storedMode($)
173}
174
175/** The checklist runs in `on` and `both`. */
176async function isTracking($: Engine): Promise<boolean> {
177 return (await currentMode($)) !== 'off'
178}
179
180/** Tool rows are hidden only in `on`; `both` keeps them next to the checklist. */
181async function isHiding($: Engine): Promise<boolean> {
182 return (await currentMode($)) === 'on'
183}
184
185function isRunning(phase: CleanViewPhase): boolean {
186 return phase === 'working' || phase === 'needsYou' || phase === 'stuck'
187}
188
189function task(id: string, name: string, status: CleanViewTask['status']): CleanViewTask {
190 const isDone = status === 'done'
191
192 return { id, name, status, percent: isDone ? 100 : 0, hasReported: isDone }
193}
194
195function finishAll(tasks: CleanViewTask[]): CleanViewTask[] {
196 return tasks.map(one => ({ ...one, status: 'done', percent: 100, hasReported: true }))
197}
198
199/** Checks off every step before `index`, sets that step's percent and starts the next one at 100. */
200function reportAt(tasks: CleanViewTask[], index: number, percent: number): CleanViewTask[] {
201 const isFinished = percent >= 100
202 const reported = tasks.map((one, at): CleanViewTask => {
203 if (at < index) {
204 return { ...one, status: 'done', percent: 100, hasReported: true }
205 }
206
207 if (at === index) {
208 return { ...one, status: isFinished ? 'done' : 'active', percent, hasReported: true }
209 }
210
211 return one.status === 'active' ? { ...one, status: 'upcoming', percent: 0, hasReported: false } : one
212 })
213
214 if (!isFinished) {
215 return reported
216 }
217
218 const following = reported.findIndex((one, at) => at > index && one.status === 'upcoming')
219
220 return following < 0
221 ? reported
222 : reported.map((one, at) => (at === following ? { ...one, status: 'active', percent: 0, hasReported: false } : one))
223}
224
225function applyProgress(tasks: CleanViewTask[], rawName: string, percent: number): CleanViewTask[] {
226 const name = cleanName(rawName)
227 const found = tasks.findIndex(one => one.name.toLowerCase() === name.toLowerCase())
228
229 if (found >= 0) {
230 return reportAt(tasks, found, percent)
231 }
232
233 // An unplanned step lands where the current step is, so nothing is checked off by mistake.
234 const active = tasks.findIndex(one => one.status === 'active')
235 const at = active >= 0 ? active : tasks.filter(one => one.status === 'done').length
236 const added = [...tasks.slice(0, at), task(`extra-${tasks.length + 1}`, name, 'upcoming'), ...tasks.slice(at)]
237
238 return reportAt(added, at, percent)
239}
240
241function todoStatus(status: unknown): CleanViewTask['status'] {
242 if (status === 'completed') {
243 return 'done'
244 }
245
246 return status === 'in_progress' ? 'active' : 'upcoming'
247}
248
249function apiReason(kind: string, details: string): string {
250 const text = `${kind} ${details}`
251
252 if (/rate_limit|billing|usage limit|429/i.test(text)) {
253 return API_USAGE
254 }
255
256 if (/overloaded|server_error|529|50\d/i.test(text)) {
257 return API_BUSY
258 }
259
260 if (/too long|context|too many tokens|max_output_tokens/i.test(text)) {
261 return API_TOO_LONG
262 }
263
264 if (/authentication|oauth|credential|verification|401|403|\/login|api key/i.test(text)) {
265 return API_AUTH
266 }
267
268 if (/network|connection|connect|econn|enotfound|fetch failed|socket|offline|timed? ?out/i.test(text)) {
269 return API_NETWORK
270 }
271
272 return API_OTHER
273}
274
275function guide(tools: readonly string[]): string {
276 const hasTodos = tools.includes('TodoWrite') || tools.includes('TaskCreate')
277 const lines = [
278 '# Clean View',
279 'The person sees a simple checklist of your work instead of tool calls. Keep it accurate.',
280 '- 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".',
281 '- Never put file paths, file names, commands, code or tool names in a step name.',
282 `- For every request, even a quick question, call ${PLAN_TOOL} first with 2 to 8 short steps in order. Load it with ToolSearch if it is deferred. Other tools are blocked until a plan exists.`,
283 `- Then call ${PROGRESS_TOOL} as real progress happens, and with percent 100 the moment a step finishes.`,
284 ]
285
286 if (hasTodos) {
287 lines.push('- You can use your to-do list (TodoWrite or TaskCreate) as the plan instead; the same naming rules apply.')
288 }
289
290 return lines.join('\n')
291}
292
293function padEnd(text: string, width: number): string {
294 if (width <= 0) {
295 return ''
296 }
297
298 const chars = [...text]
299
300 if (chars.length > width) {
301 return `${chars.slice(0, Math.max(0, width - 1)).join('')}…`
302 }
303
304 return text + ' '.repeat(width - chars.length)
305}
306
307type Stops = readonly string[]
308
309const PINK = '#ff4f8b'
310const TITLE_STOPS: Stops = ['#ff8a3d', '#ff4f8b', '#c86bfa', '#7aa2ff']
311const OVERALL_STOPS: Stops = ['#ff8a3d', '#ff4f8b']
312const DONE_STOPS: Stops = ['#1e8f4e', '#6ee7a0']
313const ACTIVE_STOPS: Stops = ['#8a2f5a', '#ff6fb0']
314const GRADIENT_STEPS = 8
315const SHIMMER_CELLS = 6
316/** The empty part of a bar: a dark gray box, just above the terminal background. */
317const TRACK = '#1f1f25'
318/** One bar cell. The box is narrower than the cell, so each step shows as its own box. */
319const BOX = '■'
320
321const BORDER: Record<CleanViewPhase, string> = {
322 idle: PINK,
323 working: PINK,
324 needsYou: 'yellow',
325 stuck: 'yellow',
326 stopped: 'red',
327 done: 'green',
328}
329
330function hex(color: string): number[] {
331 return [1, 3, 5].map(at => parseInt(color.slice(at, at + 2), 16))
332}
333
334/** The color at `t` (0 to 1) along evenly spaced stops. */
335export function blend(stops: Stops, t: number): string {
336 if (stops.length === 1) {
337 return stops[0]!
338 }
339
340 const at = Math.min(1, Math.max(0, t)) * (stops.length - 1)
341 const low = Math.min(stops.length - 2, Math.floor(at))
342 const from = hex(stops[low]!)
343 const to = hex(stops[low + 1]!)
344 const mix = at - low
345
346 return `#${from.map((one, i) => Math.round(one + (to[i]! - one) * mix).toString(16).padStart(2, '0')).join('')}`
347}
348
349type Cell = { char: string; color: string }
350
351/** Draws cells as Text runs, one run per color, so a long bar stays a few elements. */
352function cells($: Engine, e: RenderInput<'AbovePrompt'>, key: string, list: Cell[]): RenderElement {
353 const { Text } = $.ui.resolve(e)
354 const runs: Cell[] = []
355
356 for (const one of list) {
357 const last = runs[runs.length - 1]
358
359 if (last !== undefined && last.color === one.color) {
360 last.char += one.char
361 } else {
362 runs.push({ ...one })
363 }
364 }
365
366 return (
367 <Text key={key}>
368 {runs.map((run, at) => (
369 <Text key={`${key}-${at}`} color={run.color}>
370 {run.char}
371 </Text>
372 ))}
373 </Text>
374 )
375}
376
377/** `filled` gradient boxes, then dark track boxes, `width` in all. */
378function bar(width: number, filled: number, stops: Stops): Cell[] {
379 return Array.from({ length: width }, (_, at): Cell => {
380 if (at >= filled) {
381 return { char: BOX, color: TRACK }
382 }
383
384 const t = Math.round((filled <= 1 ? 1 : at / (filled - 1)) * (GRADIENT_STEPS - 1)) / (GRADIENT_STEPS - 1)
385
386 return { char: BOX, color: blend(stops, t) }
387 })
388}
389
390/** A short pink streak that runs along the track while a step has no percent yet. */
391function shimmer(width: number, frame: number): Cell[] {
392 const head = frame % (width + SHIMMER_CELLS)
393
394 return Array.from({ length: width }, (_, at): Cell => {
395 const behind = head - at
396
397 if (behind < 0 || behind >= SHIMMER_CELLS) {
398 return { char: BOX, color: TRACK }
399 }
400
401 return { char: BOX, color: blend(ACTIVE_STOPS, 1 - behind / (SHIMMER_CELLS - 1)) }
402 })
403}
404
405function meter(one: CleanViewTask, frame: number, width: number): Cell[] {
406 if (one.status === 'done') {
407 return bar(width, width, DONE_STOPS)
408 }
409
410 if (one.status === 'upcoming') {
411 return bar(width, 0, DONE_STOPS)
412 }
413
414 if (!one.hasReported) {
415 return shimmer(width, frame)
416 }
417
418 return bar(width, Math.round((one.percent / 100) * width), ACTIVE_STOPS)
419}
420
421/** Whole-job progress: done steps plus the active step's reported share. */
422export function overallPercent(tasks: readonly CleanViewTask[]): number {
423 if (tasks.length === 0) {
424 return 0
425 }
426
427 const share = tasks.reduce((sum, one) => {
428 if (one.status === 'done') {
429 return sum + 1
430 }
431
432 return one.status === 'active' && one.hasReported ? sum + one.percent / 100 : sum
433 }, 0)
434
435 return Math.round((share / tasks.length) * 100)
436}
437
438/** The step number the header names: the active step, or the count done when none is active. */
439function stepNumber(tasks: readonly CleanViewTask[]): number {
440 const active = tasks.findIndex(one => one.status === 'active')
441
442 return active >= 0 ? active + 1 : tasks.filter(one => one.status === 'done').length
443}
444
445let frameTimer: Timer | null = null
446let failures = 0
447let lastApiError: { kind: string; details: string } | null = null
448
449function syncClock($: Engine, list: CleanViewChecklist): void {
450 const shouldAnimate = list.phase === 'working' || list.phase === 'needsYou'
451
452 if (shouldAnimate && frameTimer === null) {
453 frameTimer = $.clock.every(FRAME_MS, () => {
454 void update($, tick, frame => frame + 1)
455 })
456 }
457
458 if (!shouldAnimate && frameTimer !== null) {
459 frameTimer.cancel()
460 frameTimer = null
461 }
462}
463
464async function change($: Engine, fn: (list: CleanViewChecklist) => CleanViewChecklist): Promise<CleanViewChecklist> {
465 const list = await update($, checklist, fn)
466 syncClock($, list)
467
468 return list
469}
470
471async function startJob($: Engine, title: string): Promise<CleanViewChecklist> {
472 const now = await $.clock.now()
473 failures = 0
474 lastApiError = null
475
476 return change($, () => ({
477 ...EMPTY,
478 title,
479 phase: 'working',
480 tasks: PLACEHOLDER_STEPS.map((name, at) => task(`placeholder-${at + 1}`, name, at === 0 ? 'active' : 'upcoming')),
481 startedAt: now,
482 }))
483}
484
485async function ensureJob($: Engine): Promise<void> {
486 const list = await read($, checklist)
487
488 if (!isRunning(list.phase)) {
489 await startJob($, FALLBACK_NAME)
490 }
491}
492
493async function nameJob($: Engine, prompt: string, startedAt: number | null): Promise<void> {
494 try {
495 const reply = await $.model.complete({
496 model: 'haiku',
497 effort: 'low',
498 maxTokens: 40,
499 timeoutMs: 15000,
500 system:
501 'Name the request as a job title a non-technical person understands: 2 to 6 plain English words that start with a verb, like "Build my landing page". No file names, paths, code or quotes. Reply with the title only.',
502 prompt: prompt.slice(0, 4000),
503 })
504
505 if (!reply.isAnswered) {
506 return
507 }
508
509 const title = jobTitle(reply.text)
510 await update($, checklist, list => (list.startedAt === startedAt ? { ...list, title } : list))
511 } catch {
512 // The header keeps its fallback name.
513 }
514}
515
516async function setStuck($: Engine, reason: string): Promise<void> {
517 await change($, list => (isRunning(list.phase) ? { ...list, phase: 'stuck', stuckReason: reason, needsYouReason: null } : list))
518}
519
520async function setNeedsYou($: Engine, reason: string): Promise<void> {
521 await ensureJob($)
522 await change($, list => ({ ...list, phase: 'needsYou', needsYouReason: reason }))
523}
524
525async function resume($: Engine): Promise<void> {
526 await change($, list =>
527 list.phase === 'needsYou' || list.phase === 'stuck'
528 ? { ...list, phase: 'working', needsYouReason: null, stuckReason: null }
529 : list,
530 )
531}
532
533async function setMode($: Engine, value: CleanViewMode): Promise<void> {
534 const wasTracking = await isTracking($)
535 await update($, mode, () => value)
536
537 // The tool descriptions change only between off and the other modes; a redescribe spends the prompt cache.
538 if (wasTracking !== (value !== 'off')) {
539 $.ui.invalidate('tool.describe')
540 }
541
542 await $.store.set(STORE_KEY, value)
543 sendToolboxRow($, value)
544 $.ui.toast(MODE_TOAST[value])
545}
546
547/** The mode a Toolbox choice names, or null for an unknown one. */
548export function choiceMode(choice: unknown): CleanViewMode | null {
549 return MODES.find(one => MODE_CHOICE[one] === choice) ?? null
550}
551
552const TOOLBOX_ROW = 'clean-view.mode'
553
554/**
555 * Sends the mode switch to the Toolbox panel when the Toolbox mod is loaded.
556 * Returns false when Toolbox is not loaded, so Clean View keeps its own button.
557 */
558function sendToolboxRow($: Engine, current: CleanViewMode): boolean {
559 try {
560 $.toolbox.addSetting({
561 id: TOOLBOX_ROW,
562 label: 'Clean View',
563 hint: 'simple checklist',
564 kind: 'choice',
565 options: MODES.map(one => MODE_CHOICE[one]),
566 value: MODE_CHOICE[current],
567 })
568
569 return true
570 } catch {
571 // Toolbox is not installed or not enabled: Clean View works as before.
572 return false
573 }
574}
575
576async function storedMode($: Engine): Promise<CleanViewMode> {
577 const stored = await $.store.get(STORE_KEY)
578
579 if (isMode(stored)) {
580 return stored
581 }
582
583 const legacy = await $.store.get(LEGACY_STORE_KEY)
584
585 return legacy === false ? 'off' : 'on'
586}
587
588async function planSteps($: Engine, input: Record<string, unknown>) {
589 const raw = Array.isArray(input.steps) ? input.steps : []
590 const steps = raw.map(step => cleanName(String(step))).slice(0, MAX_STEPS)
591
592 if (steps.length < MIN_STEPS) {
593 return { deny: `Give ${MIN_STEPS} to ${MAX_STEPS} short step names, in order.` }
594 }
595
596 await ensureJob($)
597 await change($, list => ({
598 ...list,
599 phase: list.phase === 'stuck' ? 'working' : list.phase,
600 stuckReason: list.phase === 'stuck' ? null : list.stuckReason,
601 tasks: steps.map((name, at) => task(`step-${at + 1}`, name, at === 0 ? 'active' : 'upcoming')),
602 isPlanned: true,
603 }))
604
605 return { result: `Planned ${steps.length} steps. The first one has started.` }
606}
607
608async function reportProgress($: Engine, input: Record<string, unknown>) {
609 const percent = clampPercent(input.percent)
610 await ensureJob($)
611 await change($, list => ({ ...list, tasks: applyProgress(list.tasks, String(input.task ?? ''), percent) }))
612
613 return { result: `Progress noted: ${percent}%.` }
614}
615
616async function trackTodos($: Engine, input: Record<string, unknown>): Promise<void> {
617 if (!Array.isArray(input.todos)) {
618 return
619 }
620
621 const todos = input.todos as Array<Record<string, unknown>>
622 await ensureJob($)
623 await change($, list => ({
624 ...list,
625 isPlanned: list.isPlanned || todos.length > 0,
626 tasks: todos.map((todo, at) => {
627 const name = cleanName(String(todo.content ?? todo.activeForm ?? ''))
628 const status = todoStatus(todo.status)
629 const before = list.tasks.find(one => one.name === name && one.status === 'active')
630
631 return status === 'active' && before !== undefined
632 ? { ...before, id: `todo-${at + 1}` }
633 : task(`todo-${at + 1}`, name, status)
634 }),
635 }))
636}
637
638async function trackTaskCreate($: Engine, input: Record<string, unknown>, resultText: string): Promise<void> {
639 const name = cleanName(String(input.subject ?? input.description ?? ''))
640 const number = /#?(\d+)/.exec(resultText)?.[1]
641 await ensureJob($)
642 await change($, list => {
643 const kept = list.isPlanned ? list.tasks : []
644 const id = `task-${number ?? kept.length + 1}`
645
646 return { ...list, isPlanned: true, tasks: [...kept.filter(one => one.id !== id), task(id, name, 'upcoming')] }
647 })
648}
649
650async function trackTaskUpdate($: Engine, input: Record<string, unknown>): Promise<void> {
651 const id = `task-${String(input.taskId ?? '')}`
652 const subject = typeof input.subject === 'string' ? cleanName(input.subject) : null
653 await change($, list => {
654 if (input.status === 'deleted') {
655 return { ...list, tasks: list.tasks.filter(one => one.id !== id) }
656 }
657
658 const status = input.status === undefined ? null : todoStatus(input.status)
659
660 return {
661 ...list,
662 tasks: list.tasks.map(one => {
663 if (one.id !== id) {
664 return one
665 }
666
667 const named = subject === null ? one : { ...one, name: subject }
668
669 if (status === null || status === named.status) {
670 return named
671 }
672
673 return status === 'active' ? { ...named, status, percent: 0, hasReported: false } : task(id, named.name, status)
674 }),
675 }
676 })
677}
678
679async function trackOutcome($: Engine, ran: { deny?: string; isError?: true; text?: string; result?: unknown }): Promise<void> {
680 const message = ran.deny ?? ran.text ?? (typeof ran.result === 'string' ? ran.result : '')
681
682 if (USER_SAID_NO.test(message)) {
683 failures = 0
684 await setStuck($, SAID_NO)
685
686 return
687 }
688
689 if (ran.deny !== undefined) {
690 return
691 }
692
693 if (ran.isError === true) {
694 failures += 1
695
696 if (failures >= FAIL_LIMIT) {
697 await setStuck($, KEEPS_FAILING)
698 }
699
700 return
701 }
702
703 failures = 0
704 await change($, list => (list.phase === 'stuck' ? { ...list, phase: 'working', stuckReason: null } : list))
705}
706
707/** Colors each word of the title along the title gradient. */
708function gradientTitle($: Engine, e: RenderInput<'AbovePrompt'>, title: string): RenderElement {
709 const { Text } = $.ui.resolve(e)
710 const words = title.split(' ')
711
712 return (
713 <Text bold>
714 {words.map((word, at) => (
715 <Text key={`word-${at}`} color={blend(TITLE_STOPS, words.length <= 1 ? 0 : at / (words.length - 1))}>
716 {at === 0 ? word : ` ${word}`}
717 </Text>
718 ))}
719 </Text>
720 )
721}
722
723/** Draws the band; null when there is nothing to show. With Toolbox loaded, the mode switch lives in its panel. */
724async function drawBand($: Engine, e: RenderInput<'AbovePrompt'>, hasToolbox: boolean): Promise<RenderElement | null> {
725 const { Box, Text, Button } = $.ui.resolve(e)
726 const current = await currentMode($)
727 const list = await read($, checklist)
728 const frame = await read($, tick)
729 const now = await $.clock.now()
730 const width = Math.max(24, e.props.bodyColumns)
731 // The round border and one cell of padding on each side.
732 const inner = width - 4
733
734 const toggle = hasToolbox ? null : (
735 <Box key="toggle-row" flexDirection="row" justifyContent="flex-end" width={width}>
736 <Button key="toggle" label={MODE_LABEL[current]} onPress={() => setMode($, nextMode(current))} />
737 </Box>
738 )
739
740 if (current === 'off' || list.phase === 'idle') {
741 return toggle === null ? null : <Box flexDirection="column">{toggle}</Box>
742 }
743
744 const title = list.title || FALLBACK_NAME
745 const runFor = elapsed((list.finishedAt ?? now) - (list.startedAt ?? now))
746 let left: RenderElement
747
748 switch (list.phase) {
749 case 'needsYou':
750 left = (
751 <Text wrap="truncate">
752 <Text bold inverse color="yellow"> Needs you </Text> {list.needsYouReason ?? WAITING_REPLY}
753 </Text>
754 )
755 break
756 case 'stuck':
757 left = (
758 <Text wrap="truncate" color="yellow">
759 ⚠ Stuck: {list.stuckReason ?? KEEPS_FAILING}
760 </Text>
761 )
762 break
763 case 'stopped':
764 left = (
765 <Text wrap="truncate">
766 <Text color="red">■ Stopped</Text> · {title} · you pressed Esc
767 </Text>
768 )
769 break
770 case 'done':
771 left = (
772 <Text wrap="truncate">
773 <Text color="green">✓ All done</Text> · {title}
774 </Text>
775 )
776 break
777 default:
778 left = (
779 <Text wrap="truncate">
780 <Text color={PINK}>✶ </Text>
781 {gradientTitle($, e, title)}
782 </Text>
783 )
784 }
785
786 // One button per mode; the current one is bracketed and full strength, the others dim.
787 const switches = MODES.map(one =>
788 one === current ? (
789 <Button key={`mode-${one}`} plain label={`[${MODE_CHOICE[one]}]`} onPress={() => setMode($, one)} />
790 ) : (
791 <Button key={`mode-${one}`} plain dimColor label={` ${MODE_CHOICE[one]} `} onPress={() => setMode($, one)} />
792 ),
793 )
794 // A finished list stays until the next request or until this button clears it.
795 const clear = isRunning(list.phase) ? null : (
796 <Button key="clear" plain dimColor label=" ✕ Clear " onPress={() => change($, () => EMPTY)} />
797 )
798 const header = (
799 <Box key="header" flexDirection="row" justifyContent="space-between" width={inner}>
800 <Box flexShrink={1}>{left}</Box>
801 <Box key="header-right" flexDirection="row" flexShrink={0}>
802 {switches}
803 {clear}
804 <Text dimColor> {runFor}</Text>
805 </Box>
806 </Box>
807 )
808 const frameBox = (children: RenderElement[]) => (
809 <Box key="frame" flexDirection="column" borderStyle="round" borderColor={BORDER[list.phase]} paddingX={1} width={width}>
810 {children}
811 </Box>
812 )
813
814 const total = list.tasks.length
815 const percent = overallPercent(list.tasks)
816 const stepLabel = `Step ${stepNumber(list.tasks)} of ${total} `
817 // label + bar + gap (1) + percent (4)
818 const overallWidth = Math.max(4, inner - stepLabel.length - 1 - 4)
819 const overall = (
820 <Box key="overall" flexDirection="row">
821 <Text>{stepLabel}</Text>
822 {cells($, e, 'overall-bar', bar(overallWidth, Math.round((percent / 100) * overallWidth), OVERALL_STOPS))}
823 <Text bold color={PINK}> {`${percent}%`.padStart(4)}</Text>
824 </Box>
825 )
826
827 // icon (2) + name + meter + gap (2) + label (7)
828 const meterWidth = Math.max(4, Math.min(METER_CELLS, inner - 2 - 12 - 2 - 7))
829 const nameWidth = Math.max(4, Math.min(MAX_NAME + 2, inner - 2 - meterWidth - 2 - 7))
830 const firstUpcoming = list.tasks.findIndex(one => one.status === 'upcoming')
831 const rows = list.tasks.map((one, at) => {
832 const name = `${padEnd(one.name, nameWidth - 1)} `
833 const track = cells($, e, `meter-${one.id}`, meter(one, frame, meterWidth))
834
835 if (one.status === 'done') {
836 return (
837 <Box key={`row-${one.id}`} flexDirection="row">
838 <Text color="green">✓ </Text>
839 <Text>{name}</Text>
840 {track}
841 <Text> Done</Text>
842 </Box>
843 )
844 }
845
846 if (one.status === 'active') {
847 const isWaiting = list.phase === 'needsYou'
848
849 return (
850 <Box key={`row-${one.id}`} flexDirection="row">
851 <Text color={isWaiting ? 'yellow' : PINK}>{isWaiting ? '‖ ' : '● '}</Text>
852 <Text bold>{name}</Text>
853 {track}
854 <Text bold color={PINK}>
855 {' '}
856 {one.hasReported ? `${one.percent}%` : 'Working'}
857 </Text>
858 </Box>
859 )
860 }
861
862 return (
863 <Box key={`row-${one.id}`} flexDirection="row">
864 <Text dimColor>○ </Text>
865 <Text dimColor>{name}</Text>
866 {track}
867 <Text dimColor> {at === firstUpcoming ? 'Next' : 'Up next'}</Text>
868 </Box>
869 )
870 })
871
872 return (
873 <Box flexDirection="column" marginTop={1}>
874 {toggle}
875 {frameBox([header, overall, ...rows])}
876 </Box>
877 )
878}
879
880export function registerCleanView(on: On): void {
881 on('session.start', async ($, e, next) => {
882 await $.tool.register({
883 name: 'plan_steps',
884 description:
885 'Lay out every step of the current job up front, in order, so the person sees a simple checklist. Call it first for every request. Each step is plain English, under 40 characters, starts with a verb, and never holds file paths, file names, commands, code or tool names.',
886 inputSchema: {
887 type: 'object',
888 properties: {
889 steps: {
890 type: 'array',
891 items: { type: 'string' },
892 minItems: MIN_STEPS,
893 maxItems: MAX_STEPS,
894 description: '2 to 8 short step names, in order, like "Build the pricing section".',
895 },
896 },
897 required: ['steps'],
898 },
899 })
900 await $.tool.register({
901 name: 'report_progress',
902 description:
903 'Report progress on the current step of the plan. Call it as real progress happens, and with percent 100 the moment a step finishes; the next step then starts by itself. Use the step name exactly as planned.',
904 inputSchema: {
905 type: 'object',
906 properties: {
907 task: { type: 'string', description: 'The step name, as given to plan_steps.' },
908 percent: { type: 'number', minimum: 0, maximum: 100, description: 'How far along the step is, 0 to 100.' },
909 },
910 required: ['task', 'percent'],
911 },
912 })
913 await $.command.register({
914 name: 'simple',
915 description: 'Set Clean View: on hides details, both shows details and the checklist, off (no argument cycles)',
916 argumentHint: 'on|both|off',
917 })
918
919 const stored = await storedMode($)
920 await update($, mode, () => stored)
921 syncClock($, await read($, checklist))
922 sendToolboxRow($, stored)
923
924 return next(e)
925 })
926
927 // A click on Clean View's row in the Toolbox panel.
928 on('state.set', { plugin: 'toolbox', key: 'press' }, async ($, e, next) => {
929 const result = await next(e)
930 const pressed = e.value
931 const value = pressed !== null && pressed.id === TOOLBOX_ROW ? choiceMode(pressed.value) : null
932
933 if (value !== null) {
934 await setMode($, value)
935 }
936
937 return result
938 })
939
940 on('command.run', { command: 'simple' }, async ($, e) => {
941 const arg = e.args.trim().toLowerCase()
942
943 if (arg !== '' && !isMode(arg)) {
944 return { text: 'Use /simple on, /simple both, /simple off, or /simple to go to the next mode.' }
945 }
946
947 const value = arg === '' ? nextMode(await currentMode($)) : arg
948 await setMode($, value)
949
950 return { text: MODE_REPLY[value] }
951 })
952
953 on('prompt.compose', async ($, e, next) => {
954 const composed = await next(e)
955
956 if (!(await isTracking($))) {
957 return composed
958 }
959
960 const sections = composed.sections.filter(section => section.id !== SECTION_ID)
961
962 return { ...composed, sections: [...sections, { id: SECTION_ID, text: guide(e.tools), scope: 'session' as const }] }
963 })
964
965 // Off: the model is told to leave the Clean View tools alone, not only the checklist section.
966 on('tool.describe', async ($, e, next) => {
967 const described = await next(e)
968
969 if (!OWN_TOOLS.has(e.tool) || (await isTracking($))) {
970 return described
971 }
972
973 return { description: OFF_DESCRIPTION, isDeferred: true }
974 })
975
976 on('turn.start', async ($, e, next) => {
977 const text = e.text.trim()
978
979 if (text === '' || text.startsWith('/')) {
980 return next(e)
981 }
982
983 const list = await read($, checklist)
984
985 if (isRunning(list.phase)) {
986 await resume($)
987
988 return next(e)
989 }
990
991 const started = await startJob($, FALLBACK_NAME)
992
993 if (await isTracking($)) {
994 void nameJob($, text, started.startedAt)
995 }
996
997 return next(e)
998 })
999
1000 on('tool.call', async ($, e, next) => {
1001 const tool = String(e.tool)
1002 const input = e as unknown as Record<string, unknown>
1003 const isMain = e.agentId === undefined
1004
1005 if (isMain && !ALWAYS_ALLOWED.has(tool) && (await isTracking($)) && !(await read($, checklist)).isPlanned) {
1006 return { deny: GATE_MESSAGE }
1007 }
1008
1009 if (OWN_TOOLS.has(tool) && !(await isTracking($))) {
1010 return { result: OFF_REPLY }
1011 }
1012
1013 if (tool === PLAN_TOOL) {
1014 return planSteps($, input)
1015 }
1016
1017 if (tool === PROGRESS_TOOL) {
1018 return reportProgress($, input)
1019 }
1020
1021 if (!isMain) {
1022 return next(e)
1023 }
1024
1025 if (tool === 'AskUserQuestion') {
1026 await setNeedsYou($, HAS_QUESTION)
1027 } else {
1028 await resume($)
1029 }
1030
1031 const ran = await next(e)
1032
1033 await change($, list => (list.phase === 'needsYou' ? { ...list, phase: 'working', needsYouReason: null } : list))
1034
1035 if (ran.deny === undefined && ran.isError !== true) {
1036 if (tool === 'TodoWrite') {
1037 await trackTodos($, input)
1038 } else if (tool === 'TaskCreate') {
1039 await trackTaskCreate($, input, ran.text ?? JSON.stringify(ran.result ?? ''))
1040 } else if (tool === 'TaskUpdate') {
1041 await trackTaskUpdate($, input)
1042 }
1043 }
1044
1045 await trackOutcome($, ran)
1046
1047 return ran
1048 })
1049
1050 on('classic.Notification', async ($, e, next) => {
1051 if (e.notification_type === 'permission_prompt') {
1052 await setNeedsYou($, NEEDS_OK)
1053 } else if (e.notification_type === 'elicitation_dialog') {
1054 await setNeedsYou($, HAS_QUESTION)
1055 }
1056
1057 return next(e)
1058 })
1059
1060 on('classic.StopFailure', async ($, e, next) => {
1061 lastApiError = { kind: e.error, details: e.error_details ?? e.last_assistant_message ?? '' }
1062 await setStuck($, apiReason(lastApiError.kind, lastApiError.details))
1063
1064 return next(e)
1065 })
1066
1067 on('turn.complete', async ($, e, next) => {
1068 if (e.agentId !== undefined) {
1069 return next(e)
1070 }
1071
1072 const list = await read($, checklist)
1073
1074 if (!isRunning(list.phase)) {
1075 return next(e)
1076 }
1077
1078 const now = await $.clock.now()
1079 const apiError = lastApiError
1080 failures = 0
1081 lastApiError = null
1082
1083 if (e.reason === 'error') {
1084 await setStuck($, apiReason(apiError?.kind ?? '', apiError?.details ?? e.answer))
1085 } else if (e.reason === 'refusal') {
1086 await setStuck($, REFUSED)
1087 } else if (e.reason === 'aborted') {
1088 await change($, current => ({ ...current, phase: 'stopped', needsYouReason: null, stuckReason: null, finishedAt: now }))
1089 } else if (list.isPlanned && list.tasks.some(one => one.status !== 'done')) {
1090 await change($, current => ({ ...current, phase: 'needsYou', needsYouReason: WAITING_REPLY, stuckReason: null }))
1091 } else {
1092 await change($, current => ({
1093 ...current,
1094 phase: 'done',
1095 tasks: finishAll(current.tasks),
1096 needsYouReason: null,
1097 stuckReason: null,
1098 finishedAt: now,
1099 }))
1100 }
1101
1102 return next(e)
1103 })
1104
1105 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
1106 const { Box } = $.ui.resolve(e)
1107
1108 return (await isHiding($)) ? <Box display="none" /> : next(e)
1109 })
1110
1111 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
1112 const { Box } = $.ui.resolve(e)
1113
1114 return (await isHiding($)) ? <Box display="none" /> : next(e)
1115 })
1116
1117 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
1118 const { Box } = $.ui.resolve(e)
1119
1120 return (await isHiding($)) ? <Box display="none" /> : next(e)
1121 })
1122
1123 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) =>
1124 (await isHiding($)) ? next({ ...e, props: { ...e.props, hint: '' } }) : next(e),
1125 )
1126
1127 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1128 if (e.props.hasSurvey) {
1129 return next(e)
1130 }
1131
1132 // Sent on each draw, so the Toolbox panel has the row again after Toolbox reloads.
1133 const hasToolbox = sendToolboxRow($, await currentMode($))
1134 const { Box } = $.ui.resolve(e)
1135 const band = await drawBand($, e, hasToolbox)
1136 const beneath = await next(e)
1137
1138 if (band === null) {
1139 return beneath
1140 }
1141
1142 return (
1143 <Box flexDirection="column">
1144 {band}
1145 {beneath}
1146 </Box>
1147 )
1148 })
1149}
1150types/index.d.ts 37 lines1/** `on` hides details, `both` shows details and the checklist, `off` shows details only. */
2export type CleanViewMode = 'on' | 'both' | 'off'
3
4export type CleanViewTaskStatus = 'done' | 'active' | 'upcoming'
5
6export type CleanViewTask = {
7 id: string
8 name: string
9 status: CleanViewTaskStatus
10 percent: number
11 hasReported: boolean
12}
13
14export type CleanViewPhase = 'idle' | 'working' | 'needsYou' | 'stuck' | 'stopped' | 'done'
15
16export type CleanViewChecklist = {
17 title: string
18 phase: CleanViewPhase
19 tasks: CleanViewTask[]
20 needsYouReason: string | null
21 stuckReason: string | null
22 startedAt: number | null
23 finishedAt: number | null
24 /** True once Claude laid out a real plan (plan_steps or a to-do list). */
25 isPlanned: boolean
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'clean-view': {
31 mode: CleanViewMode
32 checklist: CleanViewChecklist
33 tick: number
34 }
35 }
36}
37