A calm view of Claude Code for non-technical users — tool calls, diffs and command output are hidden while one checklist above the prompt shows the plan, the…

A mod (a hooks plugin, not a skill) that makes Claude Code feel calm for people who aren't technical. While Claude works, tool calls, file diffs and command output are hidden, and one checklist above the prompt shows the plan, what is happening now and how far along it is.
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
plan_steps lays out the steps up front, report_progress fills the meter; TodoWrite and TaskCreate lists also feed the checklist/simple on|off; remembered across restarts; starts on/plugin install clean-view@ai-marketplace
Choose the user scope to have it in every session. It is a terminal (/plugin) install.
/simple off. Every hidden row comes back and only the button stays./simple on. /simple alone flips it.plan_steps (or TodoWrite / TaskCreate) every other tool call is denied, so each request costs one extra tool call. After 3 denials in one job the gate lets calls through, so it cannot deadlock. Subagents are never gated./simple off removes the gate, the prompt section and the hiding.claude plugin validate plugins/clean-view
claude plugin test plugins/clean-viewhooks/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 466 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Checklist, CleanPhase, CleanTask } from '../types'
5
6type On = Parameters<Register>[0]
7
8const enabled = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
9const tick = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
10
11export const IDLE: Checklist = {
12 title: '',
13 phase: 'idle',
14 tasks: [],
15 needsYouReason: null,
16 stuckReason: null,
17 startedAt: 0,
18 finishedAt: null,
19 isCollapsed: false,
20}
21const checklist = atom({ plugin: 'clean-view', key: 'checklist' } as const, IDLE)
22
23const PLAN = 'mcp__clean-view__plan_steps'
24const REPORT = 'mcp__clean-view__report_progress'
25const ALWAYS_ALLOWED = new Set([
26 'ToolSearch', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'AskUserQuestion', PLAN, REPORT,
27])
28const MAX_NAME = 40
29const METER = 10
30
31// ---------- pure helpers (exported for tests) ----------
32
33const CODE_EXT = /\.(tsx?|jsx?|mjs|cjs|json|ya?ml|toml|md|mdx|py|rb|go|rs|java|kt|cs|php|css|scss|html?|sh|sql|lock|env)\b/i
34
35export const cleanName = (raw: string): string => {
36 const words = raw
37 .replace(/`[^`]*`/g, ' ')
38 .split(/\s+/)
39 .filter(w => w !== '' && !w.includes('/') && !w.includes('\\') && !CODE_EXT.test(w))
40 let s = words.join(' ').trim()
41 if (s === '') return 'Working on it'
42 s = s.charAt(0).toUpperCase() + s.slice(1)
43 if (s.length > MAX_NAME) {
44 const cut = s.slice(0, MAX_NAME - 1)
45 const at = cut.lastIndexOf(' ')
46 s = `${(at > 8 ? cut.slice(0, at) : cut).trimEnd()}…`
47 }
48 return s
49}
50
51const task = (id: string, name: string): CleanTask => ({
52 id, name, status: 'upcoming', percent: 0, hasReported: false,
53})
54
55export const normalize = (tasks: CleanTask[]): CleanTask[] => {
56 let seen = false
57 return tasks.map(t => {
58 if (t.status === 'done') return { ...t, percent: 100, hasReported: true }
59 if (!seen) {
60 seen = true
61 return { ...t, status: 'active' as const }
62 }
63 return { ...t, status: 'upcoming' as const, percent: 0, hasReported: false }
64 })
65}
66
67export const placeholders = (): CleanTask[] =>
68 normalize([task('ph-1', 'Understand your request'), task('ph-2', 'Plan the steps')])
69
70export const hasRealPlan = (tasks: readonly CleanTask[]): boolean =>
71 tasks.some(t => !t.id.startsWith('ph-'))
72
73export const planSteps = (names: readonly string[]): CleanTask[] =>
74 normalize(names.slice(0, 8).map((n, i) => task(`s-${i + 1}`, cleanName(n))))
75
76export const reportProgress = (
77 tasks: readonly CleanTask[],
78 name: string,
79 rawPercent: number,
80): CleanTask[] => {
81 const percent = Math.min(100, Math.max(0, Math.round(Number.isFinite(rawPercent) ? rawPercent : 0)))
82 const clean = cleanName(name)
83 const key = clean.toLowerCase()
84 let list = tasks.filter(t => !t.id.startsWith('ph-')).map(t => ({ ...t }))
85 let i = list.findIndex(t => t.name.toLowerCase() === key)
86 if (i < 0) i = list.findIndex(t => t.name.toLowerCase().includes(key) || key.includes(t.name.toLowerCase()))
87 if (i < 0) {
88 list = [...list, task(`s-${list.length + 1}`, clean)]
89 i = list.length - 1
90 }
91 list = list.map((t, j) => (j < i ? { ...t, status: 'done' as const } : t))
92 list[i] =
93 percent >= 100
94 ? { ...list[i], status: 'done', percent: 100, hasReported: true }
95 : { ...list[i], status: 'active', percent, hasReported: true }
96 return normalize(list)
97}
98
99export const fromTodos = (
100 todos: ReadonlyArray<{ content: string; status: string }>,
101): CleanTask[] =>
102 normalize(
103 todos.map((t, i) => ({
104 ...task(`t-${i + 1}`, cleanName(t.content)),
105 status: t.status === 'completed' ? ('done' as const) : ('upcoming' as const),
106 })),
107 )
108
109export const errorSentence = (text: string): string => {
110 const s = text.toLowerCase()
111 if (/rate.?limit|usage limit|429|quota/.test(s)) return 'you hit your usage limit, try again a little later'
112 if (/overload|529|busy|unavailable|503/.test(s)) return "Claude's servers are busy, try again in a minute"
113 if (/context|too long|too many tokens|prompt is too long/.test(s)) return 'type /compact and try again'
114 if (/network|econn|enotfound|fetch failed|connection|timed? ?out|offline/.test(s)) return 'the internet connection dropped'
115 if (/auth|login|api key|401|credential|token expired/.test(s)) return 'type /login'
116 return 'something went wrong, try again'
117}
118
119export const formatElapsed = (ms: number): string => {
120 const s = Math.max(0, Math.floor(ms / 1000))
121 const m = Math.floor(s / 60)
122 return m > 0 ? `${m}m ${s % 60}s` : `${s}s`
123}
124
125export const meter = (t: CleanTask, frame: number, isWaiting: boolean): string => {
126 if (t.status === 'done') return '█'.repeat(METER)
127 if (t.status === 'upcoming') return '░'.repeat(METER)
128 if (!t.hasReported && !isWaiting) {
129 const pos = (frame % (METER + 3)) - 3
130 return Array.from({ length: METER }, (_, i) => (i >= pos && i < pos + 3 ? '█' : '░')).join('')
131 }
132 const filled = Math.round((t.percent / 100) * METER)
133 return '█'.repeat(filled) + '░'.repeat(METER - filled)
134}
135
136const pad = (s: string, width: number): string => {
137 const cut = s.length > width ? `${s.slice(0, Math.max(1, width - 1))}…` : s
138 return cut.padEnd(width)
139}
140
141// ---------- the mod ----------
142
143let timer: { cancel: () => void } | undefined
144let failures = 0
145let denials = 0
146
147
148function syncTimer($: any, phase: CleanPhase): void {
149 const isLive = phase === 'working' || phase === 'needs-you'
150 if (isLive && timer === undefined) {
151 timer = $.clock.every(250, () => {
152 void update($, tick, n => n + 1)
153 })
154 } else if (!isLive && timer !== undefined) {
155 timer.cancel()
156 timer = undefined
157 }
158}
159
160async function patch($: any, fn: (c: Checklist) => Checklist): Promise<Checklist> {
161 const next = await update($, checklist, fn)
162 const c = (next ?? (await read($, checklist))) as Checklist
163 syncTimer($, c.phase)
164 return c
165}
166
167async function setEnabled($: any, value: boolean): Promise<void> {
168 await update($, enabled, () => value)
169 await $.store.set('cleanViewEnabled', value)
170 $.ui.toast(`Clean View is ${value ? 'on' : 'off'}`)
171}
172
173async function finish($: any, phase: CleanPhase, extra: Partial<Checklist>): Promise<void> {
174 const now = await $.clock.now()
175 await patch($, c => ({ ...c, phase, finishedAt: now, ...extra }))
176 if (phase === 'done') {
177 $.clock.after(5000, () => {
178 void update($, checklist, c => (c.phase === 'done' ? { ...c, isCollapsed: true } : c))
179 })
180 }
181}
182
183
184export const registerCleanView = (on: On): void => {
185 on('session.start', async ($, e, next) => {
186 const saved = await $.store.get('cleanViewEnabled')
187 if (typeof saved === 'boolean') await update($, enabled, () => saved)
188 await $.command.register({
189 name: 'simple',
190 description: 'Turn Clean View on or off',
191 argumentHint: 'on|off',
192 })
193 await $.tool.register({
194 name: 'plan_steps',
195 description:
196 'Lay out every step of the job up front, 2 to 8 short plain-English names in order. The first step starts right away.',
197 inputSchema: {
198 type: 'object',
199 properties: { steps: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8 } },
200 required: ['steps'],
201 },
202 isDeferred: false,
203 })
204 await $.tool.register({
205 name: 'report_progress',
206 description:
207 'Report progress on a step of the plan. percent is 0 to 100; send 100 the moment the step finishes.',
208 inputSchema: {
209 type: 'object',
210 properties: { task: { type: 'string' }, percent: { type: 'number' } },
211 required: ['task', 'percent'],
212 },
213 isDeferred: false,
214 })
215 return next(e)
216 })
217
218 on('command.run', { command: 'simple' }, async ($, e) => {
219 const arg = String((e as { args?: string }).args ?? '').trim().toLowerCase()
220 const value = arg === 'on' ? true : arg === 'off' ? false : !(await read($, enabled))
221 await setEnabled($, value)
222 return { text: `Clean View is ${value ? 'on' : 'off'}.` }
223 })
224
225 on('prompt.compose', async ($, e, next) => {
226 const out = await next(e)
227 if (!(await read($, enabled))) return out
228 const text = [
229 'Clean View is on: the person sees only a checklist of your plan, not your tool calls.',
230 '- Write every step name in plain English a non-technical person understands, under 40 characters, starting with a verb, like "Build the pricing section".',
231 '- Never put file paths, file names, commands, code or tool names in a step name.',
232 `- For every request, even a quick question, call ${PLAN} first (load it with ToolSearch if it is deferred). Then call ${REPORT} as real progress happens, and with percent 100 the moment a step finishes.`,
233 '- If TodoWrite or TaskCreate is available, its to-do list can serve as the plan instead.',
234 ].join('\n')
235 return { ...out, sections: [...out.sections, { id: 'clean-view:rules', text, scope: 'session' as const }] }
236 })
237
238 on('turn.start', async ($, e, next) => {
239 if (e.text.trim().startsWith('/') || !(await read($, enabled))) return next(e)
240 const c = await read($, checklist)
241 if (c.phase === 'working' || c.phase === 'needs-you') {
242 await patch($, cur => ({ ...cur, phase: 'working', needsYouReason: null }))
243 return next(e)
244 }
245 const now = await $.clock.now()
246 failures = 0
247 denials = 0
248 await patch($, () => ({
249 ...IDLE,
250 title: 'Working on it',
251 phase: 'working',
252 tasks: placeholders(),
253 startedAt: now,
254 }))
255 void $.model
256 .complete({
257 model: 'haiku',
258 effort: 'low',
259 maxTokens: 30,
260 prompt: `Name this job in 2 to 6 plain words, starting with a verb. Reply with the name only.\n\nRequest: ${e.text.slice(0, 500)}`,
261 })
262 .then(async (r: any) => {
263 if (!r.isAnswered) return
264 const name = cleanName(String(r.text).split('\n')[0])
265 await update($, checklist, cur => (cur.startedAt === now ? { ...cur, title: name } : cur))
266 })
267 .catch(() => undefined)
268 return next(e)
269 })
270
271 on('tool.call', async ($, e, next) => {
272 if (e.agentId !== undefined || !(await read($, enabled))) return next(e)
273 const c = await read($, checklist)
274 const isJob = c.phase === 'working' || c.phase === 'needs-you' || c.phase === 'stuck'
275 if (isJob && !hasRealPlan(c.tasks) && !ALWAYS_ALLOWED.has(e.tool) && denials < 3) {
276 denials += 1
277 return {
278 deny: `Clean View needs a plan first. Call ${PLAN} with 2 to 8 short plain-English steps, then continue.`,
279 }
280 }
281 if (c.phase === 'needs-you' || (c.phase === 'working' && c.needsYouReason !== null)) {
282 await patch($, cur => ({ ...cur, phase: 'working', needsYouReason: null }))
283 }
284 if (e.tool === 'AskUserQuestion') {
285 await patch($, cur => ({ ...cur, phase: 'needs-you', needsYouReason: 'Claude has a question for you' }))
286 } else if (e.tool === 'TodoWrite') {
287 const todos = (e as { todos?: Array<{ content: string; status: string }> }).todos ?? []
288 await patch($, cur => ({ ...cur, tasks: fromTodos(todos) }))
289 } else if (e.tool === 'TaskCreate') {
290 const subject = String((e as { subject?: string }).subject ?? '')
291 await patch($, cur => {
292 const real = cur.tasks.filter(t => !t.id.startsWith('ph-'))
293 return { ...cur, tasks: normalize([...real, task(`n-${real.length + 1}`, cleanName(subject))]) }
294 })
295 } else if (e.tool === 'TaskUpdate') {
296 const u = e as { taskId?: string; status?: string; subject?: string }
297 await patch($, cur => {
298 const list = cur.tasks
299 .filter(t => !(u.status === 'deleted' && t.id === `n-${u.taskId}`))
300 .map(t => {
301 if (t.id !== `n-${u.taskId}`) return t
302 const name = u.subject !== undefined ? cleanName(u.subject) : t.name
303 if (u.status === 'completed') return { ...t, name, status: 'done' as const }
304 return { ...t, name }
305 })
306 return { ...cur, tasks: normalize(list) }
307 })
308 }
309 return next(e)
310 }).catch(($, e, next) => next(e))
311
312 on('tool.call', { tool: PLAN }, async ($, e) => {
313 const steps = ((e as { steps?: unknown }).steps ?? []) as unknown
314 const names = Array.isArray(steps) ? steps.map(String) : []
315 if (names.length === 0) return { deny: 'Give at least one step.' }
316 const tasks = planSteps(names)
317 await patch($, c => ({ ...c, tasks }))
318 return { result: `Planned ${tasks.length} steps. The first one has started.` }
319 })
320
321 on('tool.call', { tool: REPORT }, async ($, e) => {
322 const r = e as { task?: string; percent?: number }
323 const percent = Math.min(100, Math.max(0, Number(r.percent ?? 0)))
324 await patch($, c => ({ ...c, tasks: reportProgress(c.tasks, String(r.task ?? ''), percent) }))
325 return { result: `Progress noted: ${Math.round(percent)}%.` }
326 })
327
328 on('classic.Notification', async ($, e, next) => {
329 const c = await read($, checklist)
330 if ((c.phase === 'working' || c.phase === 'needs-you') && (await read($, enabled))) {
331 await patch($, cur => ({ ...cur, phase: 'needs-you', needsYouReason: 'Claude needs your OK to continue' }))
332 }
333 return next(e)
334 })
335
336 on('classic.PermissionDenied', async ($, e, next) => {
337 await patch($, c => ({
338 ...c, phase: 'stuck', stuckReason: 'you said no to a step, so Claude paused',
339 }))
340 return next(e)
341 })
342
343 on('classic.PostToolUseFailure', async ($, e, next) => {
344 if (e.is_interrupt === true) return next(e)
345 failures += 1
346 if (failures >= 3) {
347 await patch($, c => ({
348 ...c, phase: 'stuck', stuckReason: 'a step keeps failing, Claude is trying another way',
349 }))
350 }
351 return next(e)
352 })
353
354 on('classic.PostToolUse', async ($, e, next) => {
355 failures = 0
356 const c = await read($, checklist)
357 if (c.phase === 'stuck') await patch($, cur => ({ ...cur, phase: 'working', stuckReason: null }))
358 return next(e)
359 })
360
361 on('turn.complete', async ($, e, next) => {
362 if (e.agentId !== undefined || !(await read($, enabled))) return next(e)
363 const c = await read($, checklist)
364 if (c.phase === 'idle') return next(e)
365 if (e.reason === 'error') {
366 await finish($, 'stuck', { stuckReason: errorSentence(e.answer) })
367 } else if (e.reason === 'refusal') {
368 await finish($, 'stuck', { stuckReason: "Claude couldn't help with that request" })
369 } else if (e.reason === 'aborted') {
370 await finish($, 'stopped', {})
371 } else if (c.tasks.some(t => t.status !== 'done')) {
372 await patch($, cur => ({
373 ...cur, phase: 'needs-you', needsYouReason: 'Claude is waiting for your reply',
374 }))
375 syncTimer($, 'idle')
376 } else {
377 await finish($, 'done', {})
378 }
379 return next(e)
380 })
381
382 // Hide the technical rows.
383 for (const component of ['ToolUse', 'ToolResult', 'ToolGroup'] as const) {
384 on('ui.render', { component }, async ($, e, next) => {
385 if (!(await read($, enabled))) return next(e)
386 const { Box } = $.ui.resolve(e)
387 return <Box />
388 })
389 }
390
391 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
392 if (!(await read($, enabled))) return next(e)
393 return next({ ...e, props: { ...e.props, hint: '' } })
394 })
395
396 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
397 if (e.props.hasSurvey) return next(e)
398 const { Box, Text, Button } = $.ui.resolve(e)
399 const isOn = await read($, enabled)
400 const c = await read($, checklist)
401 const frame = await read($, tick)
402 const now = await $.clock.now()
403
404 const button = (
405 <Button
406 key="toggle"
407 label={isOn ? '● Clean View: ON' : '○ Clean View: OFF'}
408 onPress={() => setEnabled($, !isOn)}
409 />
410 )
411
412 let left: any = null
413 if (isOn && c.phase !== 'idle') {
414 const took = formatElapsed((c.finishedAt ?? now) - c.startedAt)
415 if (c.phase === 'working') {
416 left = <Text bold>{c.title} · {took}</Text>
417 } else if (c.phase === 'needs-you') {
418 left = (
419 <Text>
420 <Text inverse bold> Needs you </Text> {c.needsYouReason}
421 </Text>
422 )
423 } else if (c.phase === 'stuck') {
424 left = <Text color="red">⚠ Stuck: {c.stuckReason}</Text>
425 } else if (c.phase === 'stopped') {
426 left = <Text dimColor>■ Stopped · {c.title} · you pressed Esc</Text>
427 } else {
428 left = <Text color="green">✓ All done · {c.title} · took {took}</Text>
429 }
430 }
431
432 const isShort = c.phase === 'done' && c.isCollapsed
433 const showRows = isOn && c.phase !== 'idle' && !isShort && c.tasks.length > 0
434 const isWaiting = c.phase === 'needs-you'
435 const nameWidth = Math.max(8, e.props.bodyColumns - 2 - METER - 2 - 9)
436
437 return (
438 <Box flexDirection="column">
439 <Box justifyContent="space-between">
440 <Box flexGrow={1}>{left}</Box>
441 {button}
442 </Box>
443 {showRows &&
444 c.tasks.map(t => {
445 const icon = t.status === 'done' ? '✓' : t.status === 'active' ? (isWaiting ? '‖' : '▶') : '○'
446 const first = c.tasks.find(x => x.status === 'upcoming')
447 const label =
448 t.status === 'done'
449 ? 'Done'
450 : t.status === 'active'
451 ? t.hasReported ? `${t.percent}%` : 'Working'
452 : t === first ? 'Next' : 'Up next'
453 const dim = t.status !== 'active'
454 return (
455 <Text key={t.id} dimColor={t.status === 'upcoming' || t.status === 'done'}>
456 <Text color={t.status === 'done' ? 'green' : undefined}>{icon} </Text>
457 <Text bold={t.status === 'active'} dimColor={dim}>{pad(t.name, nameWidth)}</Text>
458 {' '}{meter(t, frame, isWaiting)} {label}
459 </Text>
460 )
461 })}
462 </Box>
463 )
464 })
465}
466types/index.d.ts 27 lines1export type CleanTask = {
2 id: string
3 name: string
4 status: 'done' | 'active' | 'upcoming'
5 percent: number
6 hasReported: boolean
7}
8
9export type CleanPhase = 'idle' | 'working' | 'needs-you' | 'stuck' | 'stopped' | 'done'
10
11export type Checklist = {
12 title: string
13 phase: CleanPhase
14 tasks: CleanTask[]
15 needsYouReason: string | null
16 stuckReason: string | null
17 startedAt: number
18 finishedAt: number | null
19 isCollapsed: boolean
20}
21
22declare module 'claude-code' {
23 interface PluginState {
24 'clean-view': { cleanViewEnabled: boolean; checklist: Checklist; tick: number }
25 }
26}
27