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

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