A calm screen while Claude works: routine tool rows hidden, one live checklist above the prompt, and long command output trimmed before it enters the context…

Learn to play songs at the piano: a path from easy to hard, the chords and scales each song needs, and a practice player that plays along, steps through, or waits for you. Installable, works offline, in English and Russian.
Needs Node 24 (nvm use).
npm install
npm run dev # http://localhost:5173
npm run test # all tests
npm run build && npm run preview # the production build, with the service worker
.github/workflows/ci.yml). Vercel builds a preview of each pull request and deploys main to production (vercel.ts).hooks/register.tsx 10 lines1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4import { registerLeanOutput } from './lean-output'
5
6export const register: Register = on => {
7 registerCleanView(on)
8 registerLeanOutput(on)
9}
10hooks/clean-view.tsx 429 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, Timer } from 'claude-code'
3
4import type { CleanViewChecklist, CleanViewPhase } from '../types'
5import {
6 IDLE,
7 addTask,
8 errorSentence,
9 fmtElapsed,
10 fromTodos,
11 meterCells,
12 plan,
13 report,
14 startJob,
15 updateTask,
16 visibleRows,
17} from './checklist'
18import { firstWords } from './names'
19
20type $ = EngineInterface
21
22const enabledAtom = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
23const checklistAtom = atom({ plugin: 'clean-view', key: 'checklist' } as const, IDLE)
24const tickAtom = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
25const STORE_ENABLED = 'cleanViewEnabled'
26
27export const PLAN_TOOL = 'mcp__clean-view__plan_steps'
28export const REPORT_TOOL = 'mcp__clean-view__report_progress'
29
30const ROUTINE = new Set([
31 'Read', 'Grep', 'Glob', 'LS', 'ToolSearch', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet',
32 'WebSearch', 'WebFetch', 'NotebookRead', PLAN_TOOL, REPORT_TOOL,
33])
34const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
35
36/** Whether a tool row is routine work the view hides: reads, searches, and shell commands that succeeded. */
37export function isRoutine(tool: string, isErrored: boolean, isRunning: boolean): boolean {
38 if (isErrored || EDITS.has(tool)) return false
39 if (tool === 'Bash') return !isRunning
40 return ROUTINE.has(tool)
41}
42
43const REJECTED = /user (doesn't|does not) want|was rejected|user rejected|denied by the user|permission (was )?denied/i
44
45export const COMPOSE_TEXT = `# Progress checklist (Clean View)
46When the work has three or more steps, call plan_steps first (load it with ToolSearch if it is deferred), then report_progress as real progress happens, and with percent 100 the moment a step finishes. For a quick question or a one-step job, do not plan.
47A step name is under 48 characters, starts with a verb and says what the step achieves. A file or function name is fine; a full path or a command is not.
48If the work is already tracked, by a to-do list (TodoWrite, TaskCreate) or by a savvy-flow run reporting to savvy-progress, do not call plan_steps as well.`
49
50/** Module state: lost on reload, rebuilt from $.state where it matters. */
51let anim: Timer | null = null
52let collapseTimer: Timer | null = null
53let failures = 0
54let lastError: { kind?: string; details?: string } | null = null
55const toolOf = new Map<string, string>()
56
57const ANIMATED: CleanViewPhase[] = ['working', 'needs-you']
58
59async function setChecklist($: $, change: (c: CleanViewChecklist) => CleanViewChecklist) {
60 await update($, checklistAtom, change)
61 await syncTimers($)
62}
63
64/** Runs the 250 ms clock only while a job is working or waiting; schedules the 5 s collapse after Done. */
65async function syncTimers($: $) {
66 const cl = await read($, checklistAtom)
67 const isAnimated = ANIMATED.includes(cl.phase)
68 if (isAnimated && !anim) {
69 anim = $.clock.every(250, () => {
70 void update($, tickAtom, n => n + 1)
71 })
72 }
73 if (!isAnimated && anim) {
74 anim.cancel()
75 anim = null
76 }
77 if (cl.phase === 'done' && !cl.isCollapsed && !collapseTimer) {
78 collapseTimer = $.clock.after(5000, () => {
79 collapseTimer = null
80 void update($, checklistAtom, c => (c.phase === 'done' ? { ...c, isCollapsed: true } : c))
81 })
82 }
83 if (cl.phase !== 'done' && collapseTimer) {
84 collapseTimer.cancel()
85 collapseTimer = null
86 }
87}
88
89async function needsYou($: $, reason: string) {
90 await setChecklist($, c => (c.phase === 'idle' ? c : { ...c, phase: 'needs-you', needsYouReason: reason }))
91}
92
93async function clearNeedsYou($: $) {
94 const cl = await read($, checklistAtom)
95 if (cl.phase === 'needs-you') await setChecklist($, c => ({ ...c, phase: 'working', needsYouReason: '' }))
96}
97
98async function stuck($: $, reason: string) {
99 await setChecklist($, c => (c.phase === 'idle' ? c : { ...c, phase: 'stuck', stuckReason: reason }))
100}
101
102export async function setCleanView($: $, value: boolean) {
103 await update($, enabledAtom, () => value)
104 await $.store.set(STORE_ENABLED, value)
105}
106
107async function simple($: $, args: string): Promise<{ text: string }> {
108 const word = args.trim().toLowerCase()
109 const now = await read($, enabledAtom)
110 const value = word === 'on' ? true : word === 'off' ? false : !now
111 await setCleanView($, value)
112 return {
113 text: value
114 ? 'Clean View is on: routine tool rows are hidden and the checklist shows above the prompt.'
115 : 'Clean View is off: every tool row is back and the checklist is hidden.',
116 }
117}
118
119const PLAN_SPEC = {
120 name: 'plan_steps',
121 description:
122 'Lay out a job of three or more steps up front so the person sees a checklist. title: the job in 2 to 6 words, starting with a verb. steps: 2 to 8 short step names in order, each under 48 characters, starting with a verb. The first step starts right away.',
123 inputSchema: {
124 type: 'object',
125 properties: {
126 title: { type: 'string', description: 'The job in 2 to 6 words, starting with a verb.' },
127 steps: { type: 'array', items: { type: 'string' }, minItems: 2, maxItems: 8 },
128 },
129 required: ['title', 'steps'],
130 },
131 isDeferred: false,
132}
133
134const REPORT_SPEC = {
135 name: 'report_progress',
136 description:
137 'Report progress on the current step of the plan: task is the step name, percent 0 to 100. Report 100 the moment a step finishes; the next step starts by itself.',
138 inputSchema: {
139 type: 'object',
140 properties: {
141 task: { type: 'string' },
142 percent: { type: 'number', minimum: 0, maximum: 100 },
143 },
144 required: ['task', 'percent'],
145 },
146 isDeferred: false,
147}
148
149/** Session start for the view: its switch, its two tools and /simple. */
150async function startCleanView($: $) {
151 await update($, enabledAtom, () => true)
152 if ((await $.store.get(STORE_ENABLED)) === false) await update($, enabledAtom, () => false)
153 await $.tool.register(PLAN_SPEC)
154 await $.tool.register(REPORT_SPEC)
155 await $.command.register({ name: 'simple', description: 'Clean View: hide routine tool rows and show one checklist', argumentHint: 'on | off' })
156 await syncTimers($)
157}
158
159async function cleanViewSection($: $) {
160 return (await read($, enabledAtom)) ? [{ id: 'clean-view:checklist', text: COMPOSE_TEXT, scope: 'session' as const }] : []
161}
162
163export function registerCleanView(on: On) {
164 on('session.start', async ($, e, next) => {
165 const r = await next(e)
166 try {
167 await startCleanView($)
168 } catch {
169 // the view draws from its defaults
170 }
171 return r
172 })
173
174 on('prompt.compose', async ($, e, next) => {
175 const r = await next(e)
176 return { sections: [...r.sections, ...(await cleanViewSection($))] }
177 }).catch(($, e, next) => next(e))
178
179 on('command.run', { command: 'simple' }, async ($, e) => {
180 try {
181 return await simple($, e.args)
182 } catch {
183 return { text: 'Clean View could not change the setting. Try /simple again.' }
184 }
185 })
186
187 on('tool.call', { tool: PLAN_TOOL }, async ($, e) => {
188 const input = (e as { input?: { title?: unknown; steps?: unknown } }).input ?? (e as { title?: unknown; steps?: unknown })
189 const steps = Array.isArray(input.steps) ? input.steps.filter((s): s is string => typeof s === 'string') : []
190 const title = typeof input.title === 'string' ? input.title : 'Work through the plan'
191 const now = await $.clock.now()
192 await setChecklist($, c => plan(c, title, steps, now))
193 const n = (await read($, checklistAtom)).tasks.length
194 return { result: `Planned ${n} steps. The first one has started.` }
195 }).catch(() => ({ result: 'Planned. The first step has started.' }))
196
197 on('tool.call', { tool: REPORT_TOOL }, async ($, e) => {
198 const input = (e as { input?: { task?: unknown; percent?: unknown } }).input ?? (e as { task?: unknown; percent?: unknown })
199 const pct = Math.max(0, Math.min(100, Math.round(Number(input.percent) || 0)))
200 const now = await $.clock.now()
201 await setChecklist($, c => report(c, typeof input.task === 'string' ? input.task : 'Working on it', pct, now))
202 return { result: `Progress noted: ${pct}%.` }
203 }).catch(() => ({ result: 'Progress noted.' }))
204
205 on('tool.call', async ($, e, next) => {
206 const tool = String(e.tool)
207 toolOf.set(e.tool_use_id, tool)
208 if (toolOf.size > 2000) toolOf.delete(toolOf.keys().next().value as string)
209 if (tool === PLAN_TOOL || tool === REPORT_TOOL) return next(e)
210 const isMain = e.agentId === undefined
211 if (isMain && tool !== 'AskUserQuestion') await clearNeedsYou($)
212 if (isMain && tool === 'AskUserQuestion') await needsYou($, 'Claude has a question for you')
213
214 const ran = await next(e)
215 if (!isMain) return ran
216 const now = await $.clock.now()
217 await clearNeedsYou($)
218
219 if (ran.deny === undefined && ran.isError !== true) {
220 if (e.tool === 'TodoWrite') await setChecklist($, c => fromTodos(c, e.todos, now))
221 if (e.tool === 'TaskCreate') {
222 const id = (ran.result as { task?: { id?: string } } | undefined)?.task?.id ?? `tc${now}`
223 await setChecklist($, c => addTask(c, id, e.subject, now))
224 }
225 if (e.tool === 'TaskUpdate') await setChecklist($, c => updateTask(c, e.taskId, { status: e.status, subject: e.subject }, now))
226 failures = 0
227 const cl = await read($, checklistAtom)
228 if (cl.phase === 'stuck') await setChecklist($, c => ({ ...c, phase: 'working', stuckReason: '' }))
229 return ran
230 }
231 const text = ran.deny ?? ran.text ?? (typeof ran.result === 'string' ? ran.result : '')
232 if (REJECTED.test(text)) {
233 failures = 0
234 await stuck($, 'you said no to a step, so Claude paused')
235 } else {
236 failures += 1
237 if (failures >= 3) await stuck($, 'a step keeps failing, Claude is trying another way')
238 }
239 return ran
240 }).catch(($, e, next) => next(e))
241
242 on('classic.Notification', async ($, e, next) => {
243 const r = await next(e)
244 if (e.notification_type === 'permission_prompt') await needsYou($, 'Claude needs your OK to continue')
245 else if (e.notification_type === 'elicitation_dialog') await needsYou($, 'Claude has a question for you')
246 else if (e.notification_type !== 'idle_prompt' && /permission|approve|needs your/i.test(e.message)) await needsYou($, 'Claude needs your OK to continue')
247 return r
248 }).catch(($, e, next) => next(e))
249
250 on('classic.StopFailure', async ($, e, next) => {
251 lastError = { kind: e.error, details: e.error_details }
252 return next(e)
253 }).catch(($, e, next) => next(e))
254
255 on('turn.start', async ($, e, next) => {
256 const r = await next(e)
257 try {
258 const text = e.text.trim()
259 if (text === '' || text.startsWith('/')) return r
260 lastError = null
261 failures = 0
262 const cl = await read($, checklistAtom)
263 const now = await $.clock.now()
264 const isContinuing = cl.phase === 'needs-you' && cl.tasks.some(t => t.status !== 'done')
265 if (isContinuing) await setChecklist($, c => ({ ...c, phase: 'working', needsYouReason: '' }))
266 else if (cl.phase !== 'working') await setChecklist($, () => startJob(firstWords(text), now))
267 } catch {
268 // the band keeps what it had
269 }
270 return r
271 })
272
273 on('turn.complete', async ($, e, next) => {
274 const r = await next(e)
275 if (e.agentId !== undefined) return r
276 try {
277 const now = await $.clock.now()
278 const cl = await read($, checklistAtom)
279 if (cl.phase === 'idle') return r
280 if (e.reason === 'error') {
281 const why = errorSentence(lastError?.kind, lastError?.details ?? '')
282 await setChecklist($, c => ({ ...c, phase: 'stuck', stuckReason: why, finishedAt: now }))
283 } else if (e.reason === 'refusal') {
284 await setChecklist($, c => ({ ...c, phase: 'stuck', stuckReason: "Claude couldn't help with that request", finishedAt: now }))
285 } else if (e.reason === 'aborted' || e.isAborted) {
286 await setChecklist($, c => ({ ...c, phase: 'stopped', finishedAt: now }))
287 } else if (cl.tasks.length > 0 && cl.tasks.some(t => t.status !== 'done')) {
288 await setChecklist($, c => ({ ...c, phase: 'needs-you', needsYouReason: 'Claude is waiting for your reply' }))
289 } else if (cl.tasks.length > 0) {
290 await setChecklist($, c => ({ ...c, phase: 'done', finishedAt: c.finishedAt || now, isCollapsed: false }))
291 } else {
292 await setChecklist($, () => IDLE)
293 }
294 } catch {
295 // keep the band as it was
296 }
297 return r
298 })
299
300 // ── hidden rows ────────────────────────────────────────────────────────────
301 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
302 if (!(await read($, enabledAtom))) return next(e)
303 if (!isRoutine(e.props.tool, e.props.isErrored, e.props.isRunning)) return next(e)
304 const { Box } = $.ui.resolve(e)
305 return <Box display="none" />
306 })
307
308 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
309 if (!(await read($, enabledAtom))) return next(e)
310 if (!isRoutine(e.props.tool, e.props.isErrored, false)) return next(e)
311 const { Box } = $.ui.resolve(e)
312 return <Box display="none" />
313 })
314
315 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
316 if (!(await read($, enabledAtom))) return next(e)
317 const isQuiet = e.props.calls.every(c => isRoutine(c.tool, c.isErrored, c.tool === 'Bash' ? false : c.isRunning))
318 if (!isQuiet) return next(e)
319 const { Box } = $.ui.resolve(e)
320 return <Box display="none" />
321 })
322
323 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
324 if (!(await read($, enabledAtom))) return next(e)
325 const tool = toolOf.get(e.props.tool_use_id) ?? ''
326 if (tool !== 'Bash' && !ROUTINE.has(tool)) return next(e)
327 return next({ ...e, props: { ...e.props, hint: '' } })
328 })
329
330 // ── the band ───────────────────────────────────────────────────────────────
331 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
332 const rest = await next(e)
333 if (e.props.hasSurvey || !(await read($, enabledAtom))) return rest
334 const cl = await read($, checklistAtom)
335 if (cl.phase === 'idle') return rest
336 const frame = await read($, tickAtom)
337 const now = await $.clock.now()
338 const { Box, Text, Button } = $.ui.resolve(e)
339 const cols = Math.max(30, e.props.bodyColumns)
340 const button = (
341 <Button
342 key="clean-view-toggle"
343 label="● Clean View: ON"
344 onPress={async () => {
345 await setCleanView($, false)
346 $.ui.toast('Clean View is off. Type /simple on to bring it back.')
347 }}
348 />
349 )
350 const elapsed = fmtElapsed((cl.finishedAt || now) - cl.startedAt)
351 const room = cols - 22
352 const cut = (s: string, w: number) => (s.length <= w ? s : `${s.slice(0, Math.max(1, w - 1))}…`)
353
354 let header
355 if (cl.phase === 'needs-you') {
356 header = (
357 <Text>
358 <Text backgroundColor="warning" color="inverseText" bold>{' Needs you '}</Text>
359 <Text>{` ${cut(cl.needsYouReason, room - 11)}`}</Text>
360 </Text>
361 )
362 } else if (cl.phase === 'stuck') {
363 header = <Text color="warning">{cut(`⚠ Stuck: ${cl.stuckReason}`, room)}</Text>
364 } else if (cl.phase === 'stopped') {
365 header = <Text>{cut(`■ Stopped · ${cl.title} · you pressed Esc`, room)}</Text>
366 } else if (cl.phase === 'done') {
367 header = <Text color="success">{cut(`✓ All done · ${cl.title} · took ${elapsed}`, room)}</Text>
368 } else {
369 header = (
370 <Text>
371 <Text bold>{cut(cl.title, room - elapsed.length - 3)}</Text>
372 <Text dimColor>{` · ${elapsed}`}</Text>
373 </Text>
374 )
375 }
376 const headerRow = (
377 <Box key="clean-view-header" flexDirection="row" justifyContent="space-between" width={cols}>
378 {header}
379 {button}
380 </Box>
381 )
382 if ((cl.phase === 'done' && cl.isCollapsed) || cl.tasks.length === 0) {
383 return (
384 <Box flexDirection="column">
385 {rest}
386 {headerRow}
387 </Box>
388 )
389 }
390
391 const nameWidth = Math.max(8, cols - 2 - 1 - 10 - 2 - 7)
392 const rows = visibleRows(cl.tasks).map((row, i) => {
393 if (row.kind === 'done-fold') return <Text key={`f${i}`} color="success">{`✓ ${row.count} done`}</Text>
394 if (row.kind === 'more-fold') return <Text key={`m${i}`} dimColor>{`+${row.count} more`}</Text>
395 const t = row.task
396 const m = meterCells(t, frame)
397 const icon = t.status === 'done' ? '✓' : t.status === 'active' ? (cl.phase === 'needs-you' ? '‖' : '▶') : '○'
398 const name = cut(t.name, nameWidth).padEnd(nameWidth)
399 const meter = m.sweep ? (
400 <Text>
401 <Text dimColor>{m.empty.slice(0, m.sweep[0])}</Text>
402 <Text color="claude">{'█'.repeat(m.sweep[1] - m.sweep[0])}</Text>
403 <Text dimColor>{m.empty.slice(m.sweep[1])}</Text>
404 </Text>
405 ) : (
406 <Text>
407 <Text color={t.status === 'done' ? 'success' : 'claude'}>{m.full}</Text>
408 <Text dimColor>{m.empty}</Text>
409 </Text>
410 )
411 return (
412 <Text key={t.id}>
413 <Text color={t.status === 'done' ? 'success' : undefined} dimColor={t.status === 'upcoming'}>{`${icon} `}</Text>
414 <Text bold={t.status === 'active'} dimColor={t.status !== 'active'}>{`${name} `}</Text>
415 {meter}
416 <Text dimColor={t.status !== 'active'}>{` ${row.label}`}</Text>
417 </Text>
418 )
419 })
420 return (
421 <Box flexDirection="column">
422 {rest}
423 {headerRow}
424 {rows}
425 </Box>
426 )
427 })
428}
429hooks/lean-output.ts 157 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { MARKER_TAG, isExempt, trimOutput } from './trim'
5
6type $ = EngineInterface
7
8const leanEnabledAtom = atom({ plugin: 'clean-view', key: 'leanEnabled' } as const, true)
9const leanAtom = atom({ plugin: 'clean-view', key: 'lean' } as const, { trimmedCount: 0, charsSaved: 0 })
10const STORE_LEAN = 'leanEnabled'
11const STORE_KEEP = 'keepList'
12
13export const LEAN_TEXT = `# Lean output (Clean View)
14Long command output arrives trimmed, with a marker line that names the file holding the full text. Read the part you need from that file instead of running the command again. When the person asks for a command's full output, read the file and show it to them.`
15
16let keepList: string[] = []
17let baseDir = ''
18let sessionDir = ''
19
20async function folderFor($: $): Promise<string> {
21 if (sessionDir) return sessionDir
22 const tmp = (await $.env.get('TMPDIR')) ?? (await $.env.get('TEMP')) ?? '/tmp'
23 baseDir = `${tmp.replace(/\/+$/, '')}/claude-clean-view`
24 sessionDir = `${baseDir}/${await $.session.id()}`
25 return sessionDir
26}
27
28async function saveKeep($: $, list: string[]) {
29 keepList = list
30 await $.store.set(STORE_KEEP, list)
31}
32
33async function lean($: $, raw: string): Promise<{ text: string }> {
34 const args = raw.trim()
35 const [word = '', ...rest] = args.split(/\s+/)
36 const target = rest.join(' ').trim()
37 const cmd = word.toLowerCase()
38 if (cmd === 'on' || cmd === 'off') {
39 await update($, leanEnabledAtom, () => cmd === 'on')
40 await $.store.set(STORE_LEAN, cmd === 'on')
41 return { text: cmd === 'on' ? 'Lean output is on: long command output is trimmed before it reaches Claude.' : 'Lean output is off: command output reaches Claude whole.' }
42 }
43 if (cmd === 'keep' || cmd === 'drop') {
44 if (!target) return { text: `Name the command: /lean ${cmd} <command>.` }
45 const list = cmd === 'keep' ? [...new Set([...keepList, target])] : keepList.filter(k => k !== target)
46 await saveKeep($, list)
47 return { text: cmd === 'keep' ? `${target} is on the keep list: its output is never trimmed.` : `${target} is off the keep list.` }
48 }
49 const isOn = await read($, leanEnabledAtom)
50 const stats = await read($, leanAtom)
51 const tokens = Math.round(stats.charsSaved / 4)
52 return {
53 text: [
54 `Lean output is ${isOn ? 'on' : 'off'}.`,
55 `This session it trimmed ${stats.trimmedCount} result${stats.trimmedCount === 1 ? '' : 's'}, keeping about ${tokens.toLocaleString('en-US')} tokens out of the context.`,
56 `Keep list: ${keepList.length === 0 ? 'empty' : keepList.join(', ')}.`,
57 ].join('\n'),
58 }
59}
60
61type BashRecord = {
62 stdout: string
63 stderr: string
64 isImage?: boolean
65 backgroundTaskId?: string
66 persistedOutputPath?: string
67}
68
69/** Session start for lean output: its switch, its keep list, /lean, and the week-old sweep. */
70async function startLeanOutput($: $) {
71 await update($, leanEnabledAtom, () => true)
72 if ((await $.store.get(STORE_LEAN)) === false) await update($, leanEnabledAtom, () => false)
73 const saved = await $.store.get(STORE_KEEP)
74 keepList = Array.isArray(saved) ? saved.filter((k): k is string => typeof k === 'string') : []
75 await $.command.register({ name: 'lean', description: 'Clean View: trim long command output before it reaches Claude', argumentHint: 'on | off | keep <command> | drop <command>' })
76 await folderFor($)
77 void $.process
78 .run(['find', baseDir, '-mindepth', '1', '-maxdepth', '1', '-mtime', '+7', '-exec', 'rm', '-rf', '{}', '+'])
79 .catch(() => undefined)
80}
81
82async function leanSection($: $) {
83 return (await read($, leanEnabledAtom)) ? [{ id: 'clean-view:lean', text: LEAN_TEXT, scope: 'session' as const }] : []
84}
85
86export function registerLeanOutput(on: On) {
87 // Matched on every session and every model: the view registers its own unmatched hooks for these two events.
88 on('session.start', { cwd: /^/ }, async ($, e, next) => {
89 const r = await next(e)
90 try {
91 await startLeanOutput($)
92 } catch {
93 // trimming works with its defaults
94 }
95 return r
96 })
97
98 on('prompt.compose', { model: /^/ }, async ($, e, next) => {
99 const r = await next(e)
100 return { sections: [...r.sections, ...(await leanSection($))] }
101 }).catch(($, e, next) => next(e))
102
103 on('session.end', async ($, e, next) => {
104 try {
105 if (sessionDir) await $.process.run(['rm', '-rf', sessionDir])
106 } catch {
107 // the seven-day sweep catches it next time
108 }
109 return next(e)
110 })
111
112 on('command.run', { command: 'lean' }, async ($, e) => {
113 try {
114 return await lean($, e.args)
115 } catch {
116 return { text: 'Lean output could not do that. Try /lean again.' }
117 }
118 })
119
120 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
121 const ran = await next(e)
122 if (ran.deny !== undefined || e.run_in_background === true) return ran
123 if (!(await read($, leanEnabledAtom)) || isExempt(e.command, keepList)) return ran
124
125 const folder = await folderFor($)
126 if (ran.isError === true) {
127 const text = ran.text ?? (typeof ran.result === 'string' ? ran.result : '')
128 if (text.includes(MARKER_TAG)) return ran
129 const path = `${folder}/${e.tool_use_id}.txt`
130 const t = trimOutput(text, { isFailed: true, path })
131 if (!t) return ran
132 await $.fs.write(path, text)
133 await count($, text.length - t.text.length)
134 return { isError: true, result: t.text, text: t.text, context: ran.context }
135 }
136
137 const rec = ran.result as BashRecord | undefined
138 if (!rec || rec.isImage || rec.backgroundTaskId || typeof rec.stdout !== 'string') return ran
139 const path = rec.persistedOutputPath ?? `${folder}/${e.tool_use_id}.txt`
140 const out = trimOutput(rec.stdout, { isFailed: false, path })
141 const err = trimOutput(rec.stderr ?? '', { isFailed: true, path })
142 if (!out && !err) return ran
143 if (!rec.persistedOutputPath) {
144 const full = rec.stderr ? `${rec.stdout}\n--- stderr ---\n${rec.stderr}` : rec.stdout
145 await $.fs.write(path, full)
146 }
147 const stdout = out?.text ?? rec.stdout
148 const stderr = err?.text ?? rec.stderr
149 await count($, rec.stdout.length + (rec.stderr ?? '').length - stdout.length - (stderr ?? '').length)
150 return { result: { ...(ran.result as object), stdout, stderr }, context: ran.context }
151 }).catch(($, e, next) => next(e))
152}
153
154async function count($: $, saved: number) {
155 await update($, leanAtom, s => ({ trimmedCount: s.trimmedCount + 1, charsSaved: s.charsSaved + Math.max(0, saved) }))
156}
157hooks/checklist.ts 184 lines1// The checklist as pure functions: every change is old state in, new state out.
2
3import type { CleanViewChecklist, CleanViewTask } from '../types'
4import { cleanName } from './names'
5
6export const IDLE: CleanViewChecklist = {
7 title: '',
8 phase: 'idle',
9 tasks: [],
10 needsYouReason: '',
11 stuckReason: '',
12 startedAt: 0,
13 finishedAt: 0,
14 isCollapsed: false,
15}
16
17const clamp = (n: number) => Math.max(0, Math.min(100, Math.round(Number.isFinite(n) ? n : 0)))
18const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
19
20export function startJob(title: string, now: number): CleanViewChecklist {
21 return { ...IDLE, title, phase: 'working', startedAt: now }
22}
23
24export function plan(cl: CleanViewChecklist, title: string, steps: readonly string[], now: number): CleanViewChecklist {
25 const names = steps.map(s => cleanName(s)).filter(Boolean).slice(0, 20)
26 const tasks: CleanViewTask[] = names.map((name, i) => ({
27 id: `p${i}`,
28 name,
29 status: i === 0 ? 'active' : 'upcoming',
30 percent: 0,
31 hasReported: false,
32 }))
33 return {
34 ...cl,
35 title: cleanName(title),
36 phase: 'working',
37 tasks,
38 stuckReason: '',
39 needsYouReason: '',
40 startedAt: cl.startedAt || now,
41 finishedAt: 0,
42 isCollapsed: false,
43 }
44}
45
46/** Starts the first upcoming step when none is active. */
47function advance(tasks: CleanViewTask[]): CleanViewTask[] {
48 if (tasks.some(t => t.status === 'active')) return tasks
49 const i = tasks.findIndex(t => t.status === 'upcoming')
50 if (i < 0) return tasks
51 return tasks.map((t, j) => (j === i ? { ...t, status: 'active', percent: 0, hasReported: false } : t))
52}
53
54export function report(cl: CleanViewChecklist, task: string, percent: number, now: number): CleanViewChecklist {
55 const name = cleanName(task)
56 const pct = clamp(percent)
57 let tasks = cl.tasks.slice()
58 let at = tasks.findIndex(t => same(t.name, name))
59 if (at < 0) {
60 // A step that is not in the plan: it becomes the current one, where the active step stood.
61 const active = tasks.findIndex(t => t.status === 'active')
62 at = active < 0 ? tasks.length : active
63 tasks = tasks.map(t => (t.status === 'active' ? { ...t, status: 'upcoming' } : t))
64 tasks.splice(at, 0, { id: `r${now}-${tasks.length}`, name, status: 'active', percent: 0, hasReported: false })
65 }
66 tasks = tasks.map((t, j) => {
67 if (j < at) return t.status === 'done' ? t : { ...t, status: 'done', percent: 100 }
68 if (j === at) return { ...t, status: pct >= 100 ? 'done' : 'active', percent: pct, hasReported: true }
69 return t.status === 'active' ? { ...t, status: 'upcoming' } : t
70 })
71 tasks = advance(tasks)
72 const allDone = tasks.length > 0 && tasks.every(t => t.status === 'done')
73 return {
74 ...cl,
75 tasks,
76 phase: allDone ? 'done' : cl.phase === 'stuck' || cl.phase === 'idle' ? 'working' : cl.phase,
77 finishedAt: allDone ? now : 0,
78 startedAt: cl.startedAt || now,
79 }
80}
81
82export type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed'; activeForm?: string }
83
84export function fromTodos(cl: CleanViewChecklist, todos: readonly Todo[], now: number): CleanViewChecklist {
85 const tasks = advance(
86 todos.map((t, i): CleanViewTask => ({
87 id: `t${i}`,
88 name: cleanName(t.content),
89 status: t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'active' : 'upcoming',
90 percent: t.status === 'completed' ? 100 : 0,
91 hasReported: false,
92 })),
93 )
94 const allDone = tasks.length > 0 && tasks.every(t => t.status === 'done')
95 return {
96 ...cl,
97 title: cl.title || 'Work through the to-dos',
98 tasks,
99 phase: allDone ? 'done' : cl.phase === 'idle' || cl.phase === 'done' ? 'working' : cl.phase,
100 startedAt: cl.startedAt || now,
101 finishedAt: allDone ? now : 0,
102 isCollapsed: false,
103 }
104}
105
106export function addTask(cl: CleanViewChecklist, id: string, name: string, now: number): CleanViewChecklist {
107 const tasks = advance([...cl.tasks, { id, name: cleanName(name), status: 'upcoming', percent: 0, hasReported: false }])
108 return { ...cl, title: cl.title || 'Work through the tasks', tasks, phase: cl.phase === 'idle' || cl.phase === 'done' ? 'working' : cl.phase, startedAt: cl.startedAt || now }
109}
110
111export function updateTask(
112 cl: CleanViewChecklist,
113 id: string,
114 change: { status?: 'pending' | 'in_progress' | 'completed' | 'deleted'; subject?: string },
115 now: number,
116): CleanViewChecklist {
117 let tasks = cl.tasks.filter(t => !(t.id === id && change.status === 'deleted'))
118 tasks = tasks.map(t => {
119 if (t.id !== id) return change.status === 'in_progress' && t.status === 'active' ? { ...t, status: 'upcoming' } : t
120 const status = change.status === 'completed' ? 'done' : change.status === 'in_progress' ? 'active' : change.status === 'pending' ? 'upcoming' : t.status
121 return { ...t, name: change.subject ? cleanName(change.subject) : t.name, status, percent: status === 'done' ? 100 : t.percent }
122 })
123 tasks = advance(tasks)
124 const allDone = tasks.length > 0 && tasks.every(t => t.status === 'done')
125 return { ...cl, tasks, phase: allDone ? 'done' : cl.phase === 'done' ? 'working' : cl.phase, finishedAt: allDone ? now : 0 }
126}
127
128export type Row =
129 | { kind: 'task'; task: CleanViewTask; label: string }
130 | { kind: 'done-fold'; count: number }
131 | { kind: 'more-fold'; count: number }
132
133/** At most `max` step rows: earlier done steps fold into one row, later ones into "+N more". */
134export function visibleRows(tasks: readonly CleanViewTask[], max = 5): Row[] {
135 let firstUpcoming = true
136 const labelled = tasks.map(task => {
137 let label = 'Done'
138 if (task.status === 'active') label = task.hasReported ? `${task.percent}%` : 'Working'
139 if (task.status === 'upcoming') {
140 label = firstUpcoming ? 'Next' : 'Up next'
141 firstUpcoming = false
142 }
143 return { kind: 'task' as const, task, label }
144 })
145 if (labelled.length <= max) return labelled
146 const focus = Math.max(0, tasks.findIndex(t => t.status !== 'done'))
147 let start = Math.min(focus, labelled.length - max)
148 start = Math.max(0, start)
149 const rows: Row[] = []
150 if (start > 0) rows.push({ kind: 'done-fold', count: start })
151 rows.push(...labelled.slice(start, start + max))
152 const after = labelled.length - (start + max)
153 if (after > 0) rows.push({ kind: 'more-fold', count: after })
154 return rows
155}
156
157export function meterCells(task: CleanViewTask, frame: number, width = 10): { full: string; empty: string; sweep?: [number, number] } {
158 if (task.status === 'done') return { full: '█'.repeat(width), empty: '' }
159 if (task.status === 'upcoming') return { full: '', empty: '░'.repeat(width) }
160 if (!task.hasReported) {
161 const at = frame % width
162 return { full: '', empty: '░'.repeat(width), sweep: [at, Math.min(width, at + 3)] }
163 }
164 const n = Math.round((task.percent / 100) * width)
165 return { full: '█'.repeat(n), empty: '░'.repeat(width - n) }
166}
167
168export function fmtElapsed(ms: number): string {
169 const s = Math.max(0, Math.floor(ms / 1000))
170 const m = Math.floor(s / 60)
171 return m > 0 ? `${m}m ${s % 60}s` : `${s}s`
172}
173
174/** One calm sentence for an API error. */
175export function errorSentence(kind: string | undefined, details = ''): string {
176 const d = details.toLowerCase()
177 if (kind === 'rate_limit') return 'you hit your usage limit, try again a little later'
178 if (kind === 'overloaded' || kind === 'server_error') return "Claude's servers are busy, try again in a minute"
179 if (/prompt is too long|context (window|length)|too many tokens/.test(d)) return 'type /compact and try again'
180 if (kind === 'authentication_failed' || kind === 'oauth_org_not_allowed' || /auth|login|401/.test(d)) return 'type /login'
181 if (/econn|enotfound|network|fetch failed|socket|timed out|offline/.test(d)) return 'the internet connection dropped'
182 return 'Claude hit an error and stopped'
183}
184hooks/names.ts 23 lines1export const NAME_MAX = 48
2
3/** One cleaner for every step and job name. */
4export function cleanName(raw: string, max = NAME_MAX): string {
5 let s = raw.replace(/`([^`]*)`/g, '$1').replace(/`/g, '')
6 // A path keeps its last part.
7 s = s.replace(/(?:[\w@.+~-]*\/)+([\w@.+-]+)/g, '$1')
8 s = s.replace(/\s+/g, ' ').trim()
9 if (s === '') return 'Working on it'
10 s = s.charAt(0).toUpperCase() + s.slice(1)
11 if (s.length <= max) return s
12 const room = s.slice(0, max - 1)
13 const cut = room.lastIndexOf(' ')
14 return `${(cut > max / 2 ? room.slice(0, cut) : room).replace(/[\s,.;:-]+$/, '')}…`
15}
16
17/** The first few words of a prompt, for the line shown before a plan arrives. */
18export function firstWords(text: string, words = 6): string {
19 const list = text.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean)
20 const head = list.slice(0, words).join(' ')
21 return cleanName(list.length > words ? `${head}…` : head)
22}
23hooks/trim.ts 102 lines1// Lean output's trimming as pure functions: a command and its output in, the text Claude reads out.
2
3export const LINE_LIMIT = 80
4export const CHAR_LIMIT = 8000
5export const HEAD = 20
6export const TAIL = 40
7export const TAIL_FAILED = 120
8export const MIDDLE_MAX = 40
9export const LINE_MAX = 2000
10export const MARKER_TAG = 'trimmed by Clean View'
11
12/** Commands whose output is the content asked for: never trimmed. */
13const READERS = /(^|[\s;&|()`$])(curl|wget|cat|head|tail|sed|awk|jq|grep|rg|ls|find|tree|wc|gh|terminal-browser|git\s+(?:-\S+\s+)*(?:diff|show|log|status|blame))(?=$|[\s;&|()`])/
14
15const ERRORISH = /error|fail|warn|exception|traceback|✗|×/i
16
17const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
18
19export function isExempt(command: string, keep: readonly string[] = []): boolean {
20 if (READERS.test(command)) return true
21 return keep.some(k => k.trim() !== '' && new RegExp(`(^|[\\s;&|()\`$/])${escape(k.trim())}(?=$|[\\s;&|()\`])`).test(command))
22}
23
24export function isOverLimit(text: string): boolean {
25 return text.length > CHAR_LIMIT || countLines(text) > LINE_LIMIT
26}
27
28function countLines(text: string): number {
29 let n = 1
30 for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) n++
31 return n
32}
33
34export function stripAnsi(text: string): string {
35 return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g, '')
36}
37
38/** A line a progress bar or spinner overwrote keeps only what was drawn last. */
39function lastDrawn(line: string): string {
40 if (!line.includes('\r')) return line
41 const parts = line.split('\r').filter(p => p !== '')
42 return parts[parts.length - 1] ?? ''
43}
44
45export function fold(lines: readonly string[]): string[] {
46 const out: string[] = []
47 let i = 0
48 while (i < lines.length) {
49 const line = lines[i]!
50 let j = i + 1
51 while (j < lines.length && lines[j] === line) j++
52 const n = j - i
53 if (line.trim() === '') out.push('')
54 else out.push(n > 1 ? `${line} (×${n})` : line)
55 i = j
56 }
57 return out
58}
59
60export function commas(n: number): string {
61 return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
62}
63
64export type Trimmed = { text: string; dropped: number }
65
66/**
67 * Shortens one stream of a command's output, or answers undefined when it is
68 * under the limit or already trimmed. `path` is where the full text is kept.
69 */
70export function trimOutput(text: string, opts: { isFailed: boolean; path: string }): Trimmed | undefined {
71 if (!isOverLimit(text) || text.includes(MARKER_TAG)) return undefined
72 const lines = fold(stripAnsi(text).replace(/\r\n/g, '\n').split('\n').map(lastDrawn)).map(l =>
73 l.length > LINE_MAX ? `${l.slice(0, LINE_MAX)}…` : l,
74 )
75 const cleaned = lines.join('\n')
76 if (!isOverLimit(cleaned)) return { text: cleaned, dropped: 0 }
77
78 const tail = opts.isFailed ? TAIL_FAILED : TAIL
79 const keep = new Set<number>()
80 for (let i = 0; i < Math.min(HEAD, lines.length); i++) keep.add(i)
81 for (let i = Math.max(0, lines.length - tail); i < lines.length; i++) keep.add(i)
82 let middle = 0
83 for (let i = HEAD; i < lines.length - tail && middle < MIDDLE_MAX; i++) {
84 if (!ERRORISH.test(lines[i]!)) continue
85 middle++
86 for (const j of [i - 1, i, i + 1]) if (j >= 0 && j < lines.length) keep.add(j)
87 }
88 const dropped = lines.length - keep.size
89 const out: string[] = []
90 let isMarked = false
91 for (let i = 0; i < lines.length; i++) {
92 if (keep.has(i)) {
93 out.push(lines[i]!)
94 continue
95 }
96 if (i > 0 && !keep.has(i - 1)) continue
97 out.push(isMarked ? '…' : `… ${commas(dropped)} lines ${MARKER_TAG}. Full output: ${opts.path}`)
98 isMarked = true
99 }
100 return { text: out.join('\n'), dropped }
101}
102types/index.d.ts 41 lines1export type CleanViewTaskStatus = 'done' | 'active' | 'upcoming'
2
3export type CleanViewTask = {
4 id: string
5 name: string
6 status: CleanViewTaskStatus
7 /** 0..100 */
8 percent: number
9 /** Whether Claude reported a percent for this step yet. */
10 hasReported: boolean
11}
12
13export type CleanViewPhase = 'idle' | 'working' | 'needs-you' | 'stuck' | 'stopped' | 'done'
14
15export type CleanViewChecklist = {
16 title: string
17 phase: CleanViewPhase
18 tasks: CleanViewTask[]
19 needsYouReason: string
20 stuckReason: string
21 /** $.clock.now() ms; 0 while idle. */
22 startedAt: number
23 finishedAt: number
24 isCollapsed: boolean
25}
26
27export type CleanViewLean = { trimmedCount: number; charsSaved: number }
28
29declare module 'claude-code' {
30 interface PluginState {
31 'clean-view': {
32 cleanViewEnabled: boolean
33 leanEnabled: boolean
34 checklist: CleanViewChecklist
35 lean: CleanViewLean
36 /** Animation frame, advanced every 250 ms while a job is working or waiting. */
37 tick: number
38 }
39 }
40}
41