SLOPSHOPPER

clean-view

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…

newbandrowsguardcommandtoast
★ 2v0.1.0no licenseupdated 2026-10-12tt8747430-png/piano-trainer/.claude/mods/clean-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clean-view
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /lean ⎿ clean-view: Lean output is on. ⎿ clean-view: This session it trimmed 0 results, keeping about 0 tokens out of the context. ⎿ clean-view: Keep list: empty. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Piano Trainer

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.

Run it

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

Work on it

  • How code is organised and written: CLAUDE.md, docs/CODE_STYLE.md.
  • The words we use: docs/UBIQUITOUS_LANGUAGE.md.
  • Decisions and why: docs/adr; the visual world in ADR 0007.
  • Every push and pull request runs typecheck, lint, tests with coverage and the build (.github/workflows/ci.yml). Vercel builds a preview of each pull request and deploys main to production (vercel.ts).
  • How to add a song, a progression, a pattern or a path step: docs/CONTENT.md.
Source 7 files
hooks/register.tsx 10 lines
1import 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}
10
hooks/clean-view.tsx 429 lines
1import { 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}
429
hooks/lean-output.ts 157 lines
1import { 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}
157
hooks/checklist.ts 184 lines
1// 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}
184
hooks/names.ts 23 lines
1export 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}
23
hooks/trim.ts 102 lines
1// 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}
102
types/index.d.ts 41 lines
1export 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