Hides tool rows and shows a plain-English checklist of Claude's plan above the prompt

A Claude Code mod that makes Claude calm and friendly to watch. While Claude works, it hides tool calls, file diffs and command output. In their place, one plain checklist sits above the prompt:
Build my landing page · 1m 12s [ ● Clean View: ON ]
✓ Read your brand notes ██████████ Done
▶ Build the pricing section ██████░░░░ 60%
○ Add the contact form ░░░░░░░░░░ Next
○ Polish the footer ░░░░░░░░░░ Up next
The header says Needs you when Claude waits for your OK or your reply, Stuck when something goes wrong, Stopped after Esc, and All done when the job finishes.
Turn it on or off with the button at the right of the band, or type /simple on, /simple off or /simple. Your choice is remembered.
Type at the prompt of a Claude Code terminal session:
/plugin install clean-view --marketplace hassanhmd23/claude-plugins
Answer y to add the marketplace, then pick a scope.
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 507 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, Timer } from 'claude-code'
3
4import type { Checklist, Task, TaskStatus } from '../types'
5
6type $ = EngineInterface
7
8const PLUGIN = 'clean-view'
9const PLAN_TOOL = `mcp__${PLUGIN}__plan_steps`
10const REPORT_TOOL = `mcp__${PLUGIN}__report_progress`
11const ALWAYS_ALLOWED = new Set(['ToolSearch', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'AskUserQuestion'])
12const METER = 10
13const MAX_NAME = 40
14const FALLBACK_NAME = 'Working on it'
15
16const enabled = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
17const checklist = atom({ plugin: 'clean-view', key: 'checklist' } as const, null)
18const tick = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
19
20const SAID_NO = 'you said no to a step, so Claude paused'
21const KEEPS_FAILING = 'a step keeps failing, Claude is trying another way'
22const WAITING_FOR_REPLY = 'Claude is waiting for your reply'
23const NEEDS_OK = 'Claude needs your OK to continue'
24const HAS_QUESTION = 'Claude has a question for you'
25
26const GUIDE = `## Clean View is on
27
28The person sees a plain checklist of your plan instead of your tool calls. Keep it accurate:
29- For every request, even a quick question, call \`${PLAN_TOOL}\` first with every step of the job in order (2 to 8 steps). If it is deferred, load it with ToolSearch first. If this session has TodoWrite or TaskCreate you may use your to-do list as the plan instead.
30- Then call \`${REPORT_TOOL}\` as real progress happens, and with percent 100 the moment a step finishes.
31- Write every step name in plain English a non-technical person understands, under 40 characters, starting with a verb, like "Build the pricing section".
32- Never put file paths, file names, commands, code or tool names in a step name.`
33
34// ---------- plain names ----------
35
36const CODE_FILE = /\b[\w.-]+\.(?:[cm]?[jt]sx?|py|rb|go|rs|java|kt|swift|c|h|cc|cpp|hpp|cs|php|json|ya?ml|toml|ini|md|mdx|css|scss|less|html?|vue|svelte|sh|bash|zsh|sql|lock|env|xml|gradle|txt)\b/gi
37
38export function cleanName(raw: string): string {
39 const text = raw
40 .replace(/`[^`]*`?/g, ' ')
41 .replace(/\S*\/\S*/g, ' ')
42 .replace(CODE_FILE, ' ')
43 .replace(/\s+/g, ' ')
44 .trim()
45 if (text === '') return FALLBACK_NAME
46 const name = text[0].toUpperCase() + text.slice(1)
47 if (name.length <= MAX_NAME) return name
48 const cut = name.slice(0, MAX_NAME - 1)
49 const space = cut.lastIndexOf(' ')
50
51 return `${(space > 0 ? cut.slice(0, space) : cut).trimEnd()}…`
52}
53
54// ---------- the checklist, as pure steps ----------
55
56const task = (name: string, status: TaskStatus, id = name): Task => ({
57 id,
58 name: cleanName(name),
59 status,
60 percent: status === 'done' ? 100 : 0,
61 hasReported: status === 'done',
62})
63
64/** One task active: the first not done, when none is. */
65function settle(tasks: Task[]): Task[] {
66 if (tasks.some(t => t.status === 'active')) return tasks
67 const next = tasks.findIndex(t => t.status === 'upcoming')
68
69 return tasks.map((t, i) => (i === next ? { ...t, status: 'active' } : t))
70}
71
72export function startJob(prompt: string, now: number): Checklist {
73 return {
74 title: cleanName(prompt.split(/\s+/).slice(0, 6).join(' ')),
75 phase: 'working',
76 tasks: [task('Understand your request', 'active'), task('Plan the steps', 'upcoming')],
77 hasPlan: false,
78 needsYouReason: null,
79 stuckReason: null,
80 startedAt: now,
81 finishedAt: null,
82 isCollapsed: false,
83 }
84}
85
86export function planSteps(list: Checklist, steps: readonly string[]): Checklist {
87 const tasks = steps.map((s, i) => task(s, i === 0 ? 'active' : 'upcoming', `step-${i}`))
88
89 return { ...list, tasks, hasPlan: true }
90}
91
92export function reportProgress(list: Checklist, name: string, percent: number): Checklist {
93 const clean = cleanName(name)
94 const pct = Math.max(0, Math.min(100, Math.round(Number.isFinite(percent) ? percent : 0)))
95 let tasks = [...list.tasks]
96 let at = tasks.findIndex(t => t.name.toLowerCase() === clean.toLowerCase())
97 if (at === -1) {
98 // A new step goes after the last one already started.
99 const lastStarted = tasks.map(t => t.status !== 'upcoming').lastIndexOf(true)
100 at = lastStarted + 1
101 tasks.splice(at, 0, task(clean, 'upcoming', `step-${tasks.length}-${clean}`))
102 }
103 tasks = tasks.map((t, i): Task => {
104 if (i < at) return t.status === 'done' ? t : { ...t, status: 'done', percent: 100, hasReported: true }
105 if (i === at) return { ...t, status: pct >= 100 ? 'done' : 'active', percent: pct, hasReported: true }
106
107 return t.status === 'active' ? { ...t, status: 'upcoming' } : t
108 })
109
110 return { ...list, tasks: settle(tasks), hasPlan: true }
111}
112
113type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed' }
114const FROM_TODO = { pending: 'upcoming', in_progress: 'active', completed: 'done' } as const
115
116export function fromTodos(list: Checklist, todos: readonly Todo[]): Checklist {
117 const tasks = todos.map((todo, i) => {
118 const fresh = task(todo.content, FROM_TODO[todo.status], `todo-${i}`)
119 const old = list.tasks.find(t => t.name === fresh.name)
120 return fresh.status === 'active' && old?.status === 'active' ? { ...fresh, percent: old.percent, hasReported: old.hasReported } : fresh
121 })
122
123 return { ...list, tasks: settle(tasks), hasPlan: true }
124}
125
126export function addTask(list: Checklist, id: string, subject: string): Checklist {
127 const tasks = list.hasPlan ? list.tasks : []
128
129 return { ...list, tasks: settle([...tasks, task(subject, 'upcoming', id)]), hasPlan: true }
130}
131
132export function updateTask(list: Checklist, id: string, status?: string, subject?: string): Checklist {
133 if (status === 'deleted') return { ...list, tasks: settle(list.tasks.filter(t => t.id !== id)) }
134 const tasks = list.tasks.map((t): Task => {
135 if (t.id !== id) return t
136 const named = subject ? { ...t, name: cleanName(subject) } : t
137 if (status === 'completed') return { ...named, status: 'done', percent: 100, hasReported: true }
138 if (status === 'in_progress') return { ...named, status: 'active' }
139 if (status === 'pending') return { ...named, status: 'upcoming' }
140 return named
141 })
142 const active = tasks.find(t => t.id === id && t.status === 'active')
143
144 return {
145 ...list,
146 tasks: settle(active ? tasks.map(t => (t.status === 'active' && t !== active ? { ...t, status: 'upcoming' } : t)) : tasks),
147 }
148}
149
150// ---------- calm words for what went wrong ----------
151
152export function apiErrorReason(kind: string, details = ''): string {
153 if (/too long|context|max_output/i.test(details) || kind === 'max_output_tokens') return 'type /compact and try again'
154 if (kind === 'rate_limit' || kind === 'billing_error') return 'you hit your usage limit, try again a little later'
155 if (kind === 'overloaded' || kind === 'server_error') return "Claude's servers are busy, try again in a minute"
156 if (/auth|oauth|credential|verification|account/.test(kind)) return 'type /login'
157 if (/network|connect|ECONN|ENOTFOUND|fetch failed|socket|timed? ?out/i.test(details) || kind === 'unknown') {
158 return 'the internet connection dropped'
159 }
160
161 return 'something went wrong, try again'
162}
163
164// ---------- drawing ----------
165
166const duration = (ms: number) => {
167 const s = Math.max(0, Math.round(ms / 1000))
168 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
169}
170
171function meter(t: Task, frame: number): string {
172 if (t.status === 'done') return '█'.repeat(METER)
173 if (t.status === 'upcoming') return '░'.repeat(METER)
174 if (t.hasReported) {
175 const filled = Math.round(t.percent / 10)
176 return '█'.repeat(filled) + '░'.repeat(METER - filled)
177 }
178 const at = frame % (METER + 2)
179 return Array.from({ length: METER }, (_, i) => (i >= at - 2 && i <= at ? '█' : '░')).join('')
180}
181
182const fit = (text: string, width: number) =>
183 text.length <= width ? text.padEnd(width) : `${text.slice(0, Math.max(0, width - 1))}…`
184
185// ---------- the mod ----------
186
187let clock: Timer | undefined
188let failures = 0
189let lastApiError: { kind: string; details: string } | undefined
190
191const isOn = ($: $) => read($, enabled)
192const setList = ($: $, fn: (list: Checklist) => Checklist) =>
193 update($, checklist, list => (list === null ? list : fn(list)))
194
195/** The 250ms clock runs only while a job is working or waiting on the person. */
196async function syncClock($: $) {
197 const list = await read($, checklist)
198 const isLive = list !== null && (list.phase === 'working' || list.phase === 'needs-you')
199 if (isLive && clock === undefined) clock = $.clock.every(250, () => void update($, tick, n => n + 1))
200 if (!isLive && clock !== undefined) {
201 clock.cancel()
202 clock = undefined
203 }
204}
205
206async function change($: $, fn: (list: Checklist) => Checklist) {
207 await setList($, fn)
208 await syncClock($)
209}
210
211const working = (list: Checklist): Checklist => ({ ...list, phase: 'working', needsYouReason: null, stuckReason: null })
212const needsYou = (reason: string) => (list: Checklist): Checklist => ({ ...list, phase: 'needs-you', needsYouReason: reason })
213const stuck = (reason: string) => (list: Checklist): Checklist => ({ ...list, phase: 'stuck', stuckReason: reason })
214
215async function finish($: $, fn: (list: Checklist) => Checklist) {
216 const now = await $.clock.now()
217 await change($, list => ({ ...fn(list), finishedAt: now }))
218}
219
220async function setEnabled($: $, value: boolean) {
221 await update($, enabled, () => value)
222 await $.store.set('cleanViewEnabled', value)
223 $.ui.toast(value ? 'Clean View is on: tool details are hidden' : 'Clean View is off: showing every detail')
224}
225
226async function nameJob($: $, prompt: string, startedAt: number) {
227 const reply = await $.model.complete({
228 model: 'haiku',
229 effort: 'low',
230 maxTokens: 30,
231 system: 'You name tasks for people who are not technical. Answer with the name alone.',
232 prompt: `Name this request in 2 to 6 plain words, starting with a verb, no punctuation:\n\n${prompt.slice(0, 2000)}`,
233 })
234 if (!reply.isAnswered) return
235 const title = cleanName(reply.text.replace(/["'.]/g, ''))
236 await setList($, list => (list.startedAt === startedAt ? { ...list, title } : list))
237}
238
239export function registerCleanView(on: On) {
240 on('session.start', async ($, e, next) => {
241 const saved = await $.store.get('cleanViewEnabled')
242 await update($, enabled, () => saved !== false)
243 await $.tool.register({
244 name: 'plan_steps',
245 isDeferred: false,
246 description:
247 'Lay out every step of the job up front, 2 to 8 short plain-English names in order, before any other tool. The first step starts right away.',
248 inputSchema: {
249 type: 'object',
250 properties: { steps: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8 } },
251 required: ['steps'],
252 },
253 })
254 await $.tool.register({
255 name: 'report_progress',
256 isDeferred: false,
257 description:
258 'Report progress on the current step by its plan name, 0 to 100. Send 100 the moment a step finishes; the next one starts on its own.',
259 inputSchema: {
260 type: 'object',
261 properties: { task: { type: 'string' }, percent: { type: 'number', minimum: 0, maximum: 100 } },
262 required: ['task', 'percent'],
263 },
264 })
265 await $.command.register({ name: 'simple', description: 'Turn Clean View on or off', argumentHint: 'on|off' })
266 await syncClock($)
267
268 return next(e)
269 })
270
271 on('command.run', { command: 'simple' }, async ($, e) => {
272 const arg = e.args.trim().toLowerCase()
273 const value = arg === 'on' ? true : arg === 'off' ? false : !(await isOn($))
274 await setEnabled($, value)
275
276 return { text: `Clean View is ${value ? 'on' : 'off'}.` }
277 })
278
279 on('prompt.compose', async ($, e, next) => {
280 const composed = await next(e)
281 if (!(await isOn($))) return composed
282
283 return { sections: [...composed.sections, { id: `${PLUGIN}:guide`, text: GUIDE, scope: 'session' }] }
284 })
285
286 on('turn.start', async ($, e, next) => {
287 const text = e.text.trim()
288 if (text === '' || text.startsWith('/')) return next(e)
289 const list = await read($, checklist)
290 if (list?.phase === 'working') return next(e)
291 if (list?.phase === 'needs-you') {
292 await change($, working)
293 return next(e)
294 }
295 const now = await $.clock.now()
296 failures = 0
297 await update($, checklist, () => startJob(text, now))
298 await syncClock($)
299 $.clock.after(0, () => void nameJob($, text, now))
300
301 return next(e)
302 })
303
304 on('tool.call', { tool: PLAN_TOOL }, async ($, e) => {
305 const steps = (Array.isArray(e.steps) ? e.steps : []).map(String).slice(0, 8)
306 if (steps.length === 0) return { deny: 'plan_steps needs at least one step name.' }
307 const now = await $.clock.now()
308 await update($, checklist, list => planSteps(working(list ?? startJob(steps[0], now)), steps))
309 await syncClock($)
310 const text = `Planned ${steps.length} steps. The first one has started.`
311
312 return { result: text, text }
313 })
314
315 on('tool.call', { tool: REPORT_TOOL }, async ($, e) => {
316 const list = await read($, checklist)
317 if (list === null || !list.hasPlan) return { deny: `Call ${PLAN_TOOL} first.` }
318 const percent = Math.max(0, Math.min(100, Math.round(Number(e.percent) || 0)))
319 await change($, l => reportProgress(working(l), String(e.task ?? ''), percent))
320 const text = `Progress noted: ${percent}%.`
321
322 return { result: text, text }
323 })
324
325 on('tool.call', async ($, e, next) => {
326 if (e.agentId !== undefined || e.tool === PLAN_TOOL || e.tool === REPORT_TOOL) return next(e)
327 const isEnabled = await isOn($)
328 const list = await read($, checklist)
329 if (isEnabled && !ALWAYS_ALLOWED.has(e.tool) && !list?.hasPlan) {
330 return { deny: `Clean View: call ${PLAN_TOOL} with the steps of this job before any other tool.` }
331 }
332 if (e.tool === 'AskUserQuestion') await change($, needsYou(HAS_QUESTION))
333 else if (list?.phase === 'needs-you') await change($, working)
334
335 const ran = await next(e)
336
337 if (ran.isError) {
338 if (/doesn't want to proceed|user (denied|rejected|declined)|permission .*denied/i.test(ran.text ?? '')) {
339 await change($, stuck(SAID_NO))
340 } else if (++failures >= 3) {
341 await change($, stuck(KEEPS_FAILING))
342 }
343 } else if (ran.deny === undefined) {
344 failures = 0
345 const now = await read($, checklist)
346 if (now?.phase === 'stuck' || now?.phase === 'needs-you') await change($, working)
347 if (e.tool === 'TodoWrite') await change($, l => fromTodos(l, e.todos))
348 if (e.tool === 'TaskCreate') {
349 const id = (ran.result as { task?: { id: string } } | undefined)?.task?.id
350 if (id !== undefined) await change($, l => addTask(l, id, e.subject))
351 }
352 if (e.tool === 'TaskUpdate') await change($, l => updateTask(l, e.taskId, e.status, e.subject))
353 }
354
355 return ran
356 }).catch(($, e, next) => next(e))
357
358 on('classic.Notification', async ($, e, next) => {
359 if (/permission/i.test(e.notification_type)) await change($, needsYou(NEEDS_OK))
360
361 return next(e)
362 })
363
364 on('classic.PermissionDenied', async ($, e, next) => {
365 await change($, stuck(SAID_NO))
366
367 return next(e)
368 })
369
370 on('classic.StopFailure', async ($, e, next) => {
371 lastApiError = { kind: e.error, details: e.error_details ?? '' }
372 await finish($, stuck(apiErrorReason(e.error, e.error_details)))
373
374 return next(e)
375 })
376
377 on('turn.complete', async ($, e, next) => {
378 if (e.agentId !== undefined) return next(e)
379 const list = await read($, checklist)
380 if (list === null || list.phase === 'done' || list.phase === 'stopped') return next(e)
381
382 if (e.reason === 'error') {
383 const err = lastApiError ?? { kind: 'unknown', details: '' }
384 await finish($, stuck(apiErrorReason(err.kind, err.details)))
385 } else if (e.reason === 'refusal') {
386 await finish($, stuck("Claude couldn't help with that request"))
387 } else if (e.reason === 'aborted') {
388 await finish($, l => (l.stuckReason === SAID_NO ? l : { ...l, phase: 'stopped' }))
389 } else if (list.hasPlan && list.tasks.some(t => t.status !== 'done')) {
390 await finish($, needsYou(WAITING_FOR_REPLY))
391 } else {
392 await finish($, l => ({
393 ...l,
394 phase: 'done',
395 needsYouReason: null,
396 stuckReason: null,
397 tasks: l.tasks.map(t => ({ ...t, status: 'done', percent: 100, hasReported: true })),
398 }))
399 const startedAt = list.startedAt
400 $.clock.after(5000, () =>
401 void setList($, l => (l.startedAt === startedAt && l.phase === 'done' ? { ...l, isCollapsed: true } : l)),
402 )
403 }
404 lastApiError = undefined
405
406 return next(e)
407 })
408
409 // ---------- hide the technical rows ----------
410
411 on('ui.render', { component: ['ToolUse', 'ToolResult', 'ToolGroup'] }, async ($, e, next) => {
412 if (!(await isOn($))) return next(e)
413 const { Box } = $.ui.resolve(e)
414
415 return <Box display="none" />
416 })
417
418 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) =>
419 (await isOn($)) ? next({ ...e, props: { ...e.props, hint: '' } }) : next(e),
420 )
421
422 // ---------- the band ----------
423
424 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
425 if (e.props.hasSurvey) return next(e)
426 const { Box, Text, Button } = $.ui.resolve(e)
427 const isEnabled = await isOn($)
428 const list = isEnabled ? await read($, checklist) : null
429 const frame = await read($, tick)
430 const now = await $.clock.now()
431 const width = e.props.bodyColumns
432
433 const toggle = (
434 <Button
435 key="clean-view-toggle"
436 label={isEnabled ? '● Clean View: ON' : '○ Clean View: OFF'}
437 variant={isEnabled ? 'primary' : 'secondary'}
438 onPress={() => setEnabled($, !isEnabled)}
439 />
440 )
441
442 let header = <Text> </Text>
443 if (list !== null) {
444 const elapsed = duration((list.finishedAt ?? now) - list.startedAt)
445 if (list.phase === 'working') header = <Text bold wrap="truncate-end">{list.title} <Text dimColor>· {elapsed}</Text></Text>
446 if (list.phase === 'needs-you') {
447 header = (
448 <Text wrap="truncate-end">
449 <Text bold inverse color="warning"> Needs you </Text> {list.needsYouReason}
450 </Text>
451 )
452 }
453 if (list.phase === 'stuck') header = <Text color="warning" wrap="truncate-end">⚠ Stuck: {list.stuckReason}</Text>
454 if (list.phase === 'stopped') header = <Text wrap="truncate-end">■ Stopped · {list.title} · <Text dimColor>you pressed Esc</Text></Text>
455 if (list.phase === 'done') {
456 header = (
457 <Text wrap="truncate-end">
458 <Text color="success">✓ All done</Text> · {list.title} · <Text dimColor>took {elapsed}</Text>
459 </Text>
460 )
461 }
462 }
463
464 const headerRow = (
465 <Box flexDirection="row" justifyContent="space-between">
466 <Box flexShrink={1}>{header}</Box>
467 {toggle}
468 </Box>
469 )
470 if (list === null || (list.phase === 'done' && list.isCollapsed)) return headerRow
471
472 const nameWidth = Math.max(8, width - 2 - 1 - METER - 2 - 7)
473 const firstUpcoming = list.tasks.findIndex(t => t.status === 'upcoming')
474 const rows = list.tasks.map((t, i) => {
475 const name = fit(t.name, nameWidth)
476 const bar = meter(t, frame)
477 if (t.status === 'done') {
478 return (
479 <Text key={t.id}>
480 <Text color="success">✓ </Text><Text dimColor>{name} </Text><Text color="success">{bar}</Text> Done
481 </Text>
482 )
483 }
484 if (t.status === 'active') {
485 const icon = list.phase === 'needs-you' ? '‖ ' : '▶ '
486 return (
487 <Text key={t.id}>
488 <Text color="claude">{icon}</Text><Text bold>{name} </Text><Text color="claude">{bar}</Text> {t.hasReported ? `${t.percent}%` : 'Working'}
489 </Text>
490 )
491 }
492 return (
493 <Text key={t.id} dimColor>
494 ○ {name} {bar} {i === firstUpcoming ? 'Next' : 'Up next'}
495 </Text>
496 )
497 })
498
499 return (
500 <Box flexDirection="column">
501 {headerRow}
502 {rows}
503 </Box>
504 )
505 })
506}
507types/index.d.ts 35 lines1export type TaskStatus = 'done' | 'active' | 'upcoming'
2
3export type Task = {
4 id: string
5 name: string
6 status: TaskStatus
7 percent: number
8 hasReported: boolean
9}
10
11export type Phase = 'working' | 'needs-you' | 'stuck' | 'stopped' | 'done'
12
13export type Checklist = {
14 title: string
15 phase: Phase
16 tasks: Task[]
17 /** False while only the placeholder steps show. */
18 hasPlan: boolean
19 needsYouReason: string | null
20 stuckReason: string | null
21 startedAt: number
22 finishedAt: number | null
23 isCollapsed: boolean
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'clean-view': {
29 cleanViewEnabled: boolean
30 checklist: Checklist | null
31 tick: number
32 }
33 }
34}
35