Makes Claude Code minimal: progress bars per step, code changes in boxes, short answers and a /o model and effort menu

A Claude Code mod that makes the terminal minimal: less noise, only what matters.
<img width="800" height="450" alt="demo" src="https://github.com/user-attachments/assets/42ecb7ea-f5e1-47e1-a8e6-704b18293d13" />
✓ ok in green when done, ✕ error in red with the reason when it fails./o to pick the model (1–4) and effort (l low, m medium, h high, x max). Esc closes it.auto · /o options, and an empty prompt suggests /o (press Tab).| Command | What it does |
|---|---|
/o | Opens the model and effort menu |
/expand | Shows or hides the full code in every box |
You can also click … N more lines under a box to expand just that one.
In Claude Code, in the terminal:
/plugin install clean-cli --marketplace jpguelerazevedo/claude-clean-cli
Answer y to add the marketplace, then pick a scope.
claude --plugin-dir ./claude-clean-cli
✓ (green) and ✕ (red) itself, so colors work in any language. If it skips a mark, that sentence just stays in the normal color.claude plugin validate .
claude plugin test .hooks/register.tsx 147 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { EffortChoice, ModelChoice } from '../types'
5import { registerAnswer } from './answer'
6import { registerRows } from './rows'
7import { OPTIONS_HINT, registerSteps } from './steps'
8
9const PANE = 'clean-options'
10
11const modelAtom = atom({ plugin: 'clean-cli', key: 'model' } as const, null)
12const effortAtom = atom({ plugin: 'clean-cli', key: 'effort' } as const, null)
13
14export const MODELS: { id: ModelChoice; name: string; apiId: string; note: string; hotkey: string }[] = [
15 { id: 'fable', name: 'fable 5.1', apiId: 'claude-fable-5-1', note: 'most capable', hotkey: '1' },
16 { id: 'opus', name: 'opus 5.5', apiId: 'claude-opus-5-5', note: 'complex tasks', hotkey: '2' },
17 { id: 'sonnet', name: 'sonnet 5.5', apiId: 'claude-sonnet-5-5', note: 'balanced', hotkey: '3' },
18 { id: 'haiku', name: 'haiku 5.5', apiId: 'claude-haiku-5-5', note: 'fast', hotkey: '4' },
19]
20
21export const EFFORTS: { id: EffortChoice; name: string; hotkey: string }[] = [
22 { id: 'low', name: 'low', hotkey: 'l' },
23 { id: 'medium', name: 'medium', hotkey: 'm' },
24 { id: 'high', name: 'high', hotkey: 'h' },
25 { id: 'max', name: 'max', hotkey: 'x' },
26]
27
28/** "auto mode on (shift+tab to cycle) · ← for agents" → "auto · /o options" */
29export function cleanHint(hint: string): string {
30 const mode = /(\S+(?: \S+)?) mode on/i.exec(hint)
31 const parts = mode?.[1] ? [mode[1].toLowerCase()] : []
32 parts.push('/o options')
33 return parts.join(' · ')
34}
35
36function statusText(model: ModelChoice | null, effort: EffortChoice | null): string | undefined {
37 const m = MODELS.find(x => x.id === model)?.name
38 const e = EFFORTS.find(x => x.id === effort)?.name
39 if (!m && !e) return undefined
40 return [m, e].filter(Boolean).join(' · ')
41}
42
43export const register: Register = on => {
44 registerSteps(on)
45 registerRows(on)
46 registerAnswer(on)
47
48 on('session.start', async ($, e, next) => {
49 await $.command.register({
50 name: 'o',
51 description: 'Options: pick the model and effort',
52 })
53 await $.command.register({
54 name: 'expand',
55 description: 'Show or collapse the full code of every change',
56 })
57 $.ui.status(statusText(await read($, modelAtom), await read($, effortAtom)))
58 const started = await next(e)
59 void $.prompt.suggest({ text: OPTIONS_HINT }).catch(() => undefined)
60 return started
61 })
62
63 // An empty prompt shows a dim "/o": Tab completes it, Enter opens the menu.
64 // It takes the place of Claude Code's next-prompt guesses.
65 on('prompt.suggest', { origin: { kind: 'suggestion' } }, ($, e, next) => next({ ...e, text: OPTIONS_HINT }))
66
67 on('command.run', { command: 'o' }, async $ => {
68 await $.ui.open({ id: PANE, title: 'options', focus: true, closeOnEscape: true, rows: 12 })
69 return { text: '' }
70 })
71
72 // The hint under the prompt: just the mode and the menu shortcut.
73 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
74 if (e.props.isWorking || e.props.isDraft) return next(e)
75 return next({ ...e, props: { ...e.props, hint: cleanHint(e.props.hint) } })
76 })
77
78 // Applies the chosen model and effort to every model request.
79 on('turn.step', async function* ($, e, next) {
80 const chosen = await read($, modelAtom)
81 const model = MODELS.find(x => x.id === chosen)
82 const effort = await read($, effortAtom)
83 if (e.agentId || (!model && !effort)) return yield* next(e)
84 return yield* next({
85 ...e,
86 ...(model ? { model: model.apiId } : {}),
87 ...(effort && e.effort !== undefined ? { effort } : {}),
88 })
89 })
90
91 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
92 const { Box, Text, Button } = $.ui.resolve(e)
93 const model = await read($, modelAtom)
94 const effort = await read($, effortAtom)
95
96 const pick = async (m: ModelChoice | null, ef: EffortChoice | null) => {
97 $.ui.status(statusText(m, ef))
98 }
99
100 return (
101 <Box flexDirection="column" paddingX={1} gap={1}>
102 <Box flexDirection="column">
103 <Text dimColor>model</Text>
104 {MODELS.map(m => (
105 <Box key={`row-${m.id}`} gap={2}>
106 <Text color="#D97757">{model === m.id ? '›' : ' '}</Text>
107 <Button
108 key={`model-${m.id}`}
109 plain
110 hotkey={m.hotkey}
111 label={m.name}
112 dimColor={model !== m.id}
113 onPress={async () => {
114 const next = model === m.id ? null : m.id
115 await update($, modelAtom, () => next)
116 await pick(next, effort)
117 }}
118 />
119 <Text dimColor>{m.note}</Text>
120 </Box>
121 ))}
122 </Box>
123 <Box flexDirection="column">
124 <Text dimColor>effort</Text>
125 <Box gap={2}>
126 {EFFORTS.map(ef => (
127 <Button
128 key={`effort-${ef.id}`}
129 plain
130 hotkey={ef.hotkey}
131 label={ef.name}
132 dimColor={effort !== ef.id}
133 onPress={async () => {
134 const next = effort === ef.id ? null : ef.id
135 await update($, effortAtom, () => next)
136 await pick(model, next)
137 }}
138 />
139 ))}
140 </Box>
141 </Box>
142 <Text dimColor>1-4 model · l m h x effort · esc closes</Text>
143 </Box>
144 )
145 })
146}
147hooks/answer.tsx 137 lines1import { atom, read } from 'claude-code'
2import type { On } from 'claude-code'
3
4import type { TurnSummary } from '../types'
5
6import { GREEN, RED } from './steps'
7
8const CODE_COLOR = '#e8e8e8'
9const CODE_BG = '#262626'
10const SPACER = ' '
11
12export type Tone = 'ok' | 'fail' | null
13export type Segment = { kind: 'text' | 'code' | 'bold'; text: string }
14
15/**
16 * Claude marks outcomes itself (see STYLE): a part that starts with ✓ worked, one that starts
17 * with ✕ failed. No word lists, so it works the same in any language.
18 */
19export function tone(part: string): Tone {
20 const t = part.trimStart()
21 if (t.startsWith('✕')) return 'fail'
22 if (t.startsWith('✓')) return 'ok'
23 return null
24}
25
26/** Splits `code` and **bold** from plain text. */
27export function parseInline(text: string): Segment[] {
28 return text
29 .split(/(`[^`]+`|\*\*[^*]+\*\*)/)
30 .filter(Boolean)
31 .map((part): Segment =>
32 part.startsWith('`') && part.endsWith('`') && part.length > 1
33 ? { kind: 'code', text: part.slice(1, -1) }
34 : part.startsWith('**') && part.endsWith('**') && part.length > 3
35 ? { kind: 'bold', text: part.slice(2, -2) }
36 : { kind: 'text', text: part },
37 )
38}
39
40/** Lists, tables, headings and code blocks keep Claude Code's own formatting. */
41export function isComplex(text: string): boolean {
42 return /```|^\s*([-*+]|\d+[.)])\s|^\s*#{1,6}\s|^\s*\|/m.test(text)
43}
44
45/** Splits a paragraph where Claude put a ✓ or ✕ mark; each part keeps its own color. */
46export function parts(paragraph: string): string[] {
47 return paragraph
48 .replace(/\s*\n\s*/g, ' ')
49 .split(/(?=[✓✕])/)
50 .filter(s => s.trim() !== '')
51}
52
53const STYLE = [
54 'This interface is minimal and to the point; keep every reply as short as it can be while still complete.',
55 'After doing work: lead with the result in one sentence, then at most two more short sentences, only for what the person must know (what failed and what to do about it, a decision they need to make).',
56 'Do not restate the request, describe your process, list the steps or files (the interface already shows them), or paste error messages or command output: name the error in a few words.',
57 'For a question: answer it directly first, in as few sentences as the question needs; add detail only when asked or when the answer would be wrong without it.',
58 'No headings, no bullet lists unless the content is genuinely a list, no closing offers or summaries. Reply in the language the person writes in.',
59 'Mark outcomes so the interface can color them: start a sentence about something that worked with "✓ " and a sentence about something that failed or still needs action with "✕ ". Use these marks only for outcomes, in any language, never as decoration.',
60].join(' ')
61
62// eslint-disable-next-line @typescript-eslint/no-explicit-any
63type Elements = { Box: any; Text: any; Markdown: any }
64
65/** Draws an answer in the mod's style; used in place of the answer and below the code boxes. */
66export function drawAnswer({ Box, Text, Markdown }: Elements, raw: string, withGap: boolean) {
67 const text = raw.trim()
68 if (isComplex(text)) {
69 return (
70 <Box flexDirection="column" paddingLeft={2}>
71 {withGap ? <Text key="gap">{SPACER}</Text> : null}
72 <Markdown key="md" text={text} />
73 </Box>
74 )
75 }
76
77 const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim() !== '')
78 return (
79 <Box flexDirection="column" paddingLeft={2}>
80 {withGap ? <Text key="gap">{SPACER}</Text> : null}
81 {paragraphs.map((para, i) => (
82 <Box key={`p${i}`} marginTop={i === 0 ? 0 : 1}>
83 <Text>
84 {parts(para).map((s, j) => {
85 const t = tone(s)
86 const color = t === 'fail' ? RED : t === 'ok' ? GREEN : undefined
87 return (
88 <Text key={`s${i}-${j}`} color={color}>
89 {parseInline(s).map((seg, k) =>
90 seg.kind === 'code' ? (
91 <Text key={`g${k}`} color={CODE_COLOR} backgroundColor={CODE_BG}>
92 {` ${seg.text} `}
93 </Text>
94 ) : seg.kind === 'bold' ? (
95 <Text key={`g${k}`} bold>
96 {seg.text}
97 </Text>
98 ) : (
99 seg.text
100 ),
101 )}
102 </Text>
103 )
104 })}
105 </Text>
106 </Box>
107 ))}
108 </Box>
109 )
110}
111
112const summariesAtom = atom({ plugin: 'clean-cli', key: 'summaries' } as const, {})
113
114/** True when this text is the final answer of a turn that changed code: it is drawn below the boxes instead. */
115export function movedBelowCode(summaries: Record<string, TurnSummary>, text: string): boolean {
116 const t = text.trim()
117 if (t === '') return false
118 return Object.values(summaries).some(s => (s.changes ?? []).length > 0 && (s.answer ?? '').includes(t))
119}
120
121export function registerAnswer(on: On): void {
122 // The AI answer in the mod's style: no ●, indented like the bars,
123 // `code` on a dark background, what worked in green and what failed in red.
124 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
125 if (e.props.isSummary) return next(e)
126 const els = $.ui.resolve(e)
127 if (e.props.text.trim() === '') return next(e)
128
129 // When the turn ends with code changes, the answer moves below the boxes.
130 if (movedBelowCode(await read($, summariesAtom), e.props.text)) return <els.Box />
131
132 return drawAnswer(els, e.props.text, e.props.isFirstOfReply)
133 })
134}
135
136export { STYLE }
137hooks/rows.tsx 92 lines1import { atom, read } from 'claude-code'
2import type { On } from 'claude-code'
3
4import { ACCENT, BAR_WIDTH, GREEN, RED, TRACK, lineDelta, runningPct, short, stepLabel } from './steps'
5
6const stepsAtom = atom({ plugin: 'clean-cli', key: 'steps' } as const, [])
7const tickAtom = atom({ plugin: 'clean-cli', key: 'tick' } as const, 0)
8
9const LABEL_WIDTH = 36
10/** A real blank line: the terminal ignores a top margin on a transcript row. */
11export const SPACER = '\u00a0'
12
13/** The first line of the error, shortened. */
14export function errorLine(output: unknown): string {
15 const text = typeof output === 'string' ? output : output == null ? '' : JSON.stringify(output)
16 const first = text
17 .replace(/<\/?tool_use_error>/g, '')
18 .split('\n')
19 .map(l => l.trim())
20 .find(Boolean)
21 return short(first ?? 'error', 80)
22}
23
24export function registerRows(on: On): void {
25 // Each tool call becomes a step: icon, name, bar and ok/error.
26 on('ui.render', { component: 'ToolUse' }, async ($, e) => {
27 const { Box, Text } = $.ui.resolve(e)
28 const p = e.props
29 const args = (p.input && typeof p.input === 'object' ? p.input : {}) as Record<string, unknown>
30 const failed = p.isErrored || p.isInterrupted
31 const running = p.isRunning && !failed
32
33 let pct = 100
34 if (running) {
35 await read($, tickAtom)
36 const step = (await read($, stepsAtom)).find(s => s.id === p.tool_use_id)
37 pct = step ? runningPct((await $.clock.now()) - step.startedAt) : 0
38 }
39 const filled = Math.round((pct / 100) * BAR_WIDTH)
40 const color = running ? ACCENT : failed ? RED : GREEN
41 const icon = running ? '●' : failed ? '✕' : '✓'
42 const status = running ? `${pct}%` : failed ? 'error' : 'ok'
43
44 const delta = lineDelta(p.tool, args)
45 const hasDelta = !failed && !running && delta.added + delta.removed > 0
46
47 return (
48 <Box flexDirection="column" paddingLeft={2}>
49 <Text key="gap">{SPACER}</Text>
50 <Box gap={1}>
51 <Text color={color}>{icon}</Text>
52 <Box width={LABEL_WIDTH}>
53 <Text dimColor={running} wrap="truncate-end">
54 {stepLabel(p.tool, args)}
55 </Text>
56 </Box>
57 <Text color={color}>{'━'.repeat(filled)}</Text>
58 <Text color={TRACK}>{'━'.repeat(BAR_WIDTH - filled)}</Text>
59 <Text color={running ? undefined : color} dimColor={running}>
60 {status}
61 </Text>
62 </Box>
63 {failed ? (
64 <Box paddingLeft={2}>
65 <Text color={RED} wrap="truncate-end">
66 {p.isInterrupted ? 'interrupted' : `failed: ${errorLine(p.output)}`}
67 </Text>
68 </Box>
69 ) : null}
70 {hasDelta ? (
71 <Box paddingLeft={2} gap={1}>
72 <Text color={GREEN}>+{delta.added}</Text>
73 {delta.removed > 0 ? <Text color={RED}>−{delta.removed}</Text> : null}
74 <Text color={GREEN}>lines</Text>
75 </Box>
76 ) : null}
77 </Box>
78 )
79 })
80
81 // The raw result (file content, command output) is hidden: the step line already says what happened.
82 on('ui.render', { component: 'ToolResult' }, ($, e) => {
83 const { Box } = $.ui.resolve(e)
84 return <Box />
85 })
86
87 // No "Ran 1 shell command": each call shows as its own step.
88 on('ui.render', { component: 'ToolGroup' }, ($, e, next) =>
89 next({ ...e, props: { ...e.props, isExpanded: true } }),
90 )
91}
92hooks/steps.tsx 305 lines1import { atom, read, update } from 'claude-code'
2import type { On, Timer } from 'claude-code'
3
4import { STYLE, drawAnswer } from './answer'
5import type { CodeChange, DiffLine, FileChange, Step, TurnSummary } from '../types'
6
7const stepsAtom = atom({ plugin: 'clean-cli', key: 'steps' } as const, [])
8const tickAtom = atom({ plugin: 'clean-cli', key: 'tick' } as const, 0)
9const summariesAtom = atom({ plugin: 'clean-cli', key: 'summaries' } as const, {})
10const expandedAtom = atom({ plugin: 'clean-cli', key: 'expanded' } as const, {})
11const expandAllAtom = atom({ plugin: 'clean-cli', key: 'expandAll' } as const, false)
12
13export const GREEN = '#5fb37a'
14export const RED = '#e06c6c'
15export const ACCENT = '#D97757'
16/** What an empty prompt shows, dimmed. */
17export const OPTIONS_HINT = '/o'
18export const TRACK = '#3a3a3a'
19export const BAR_WIDTH = 20
20
21/** Tools that are not steps: conversation, not work. */
22const SKIP = new Set(['TodoWrite', 'AskUserQuestion', 'SendUserMessage', 'ToolSearch'])
23
24export function base(path: unknown): string {
25 if (typeof path !== 'string' || !path) return ''
26 const parts = path.split(/[\\/]/).filter(Boolean)
27 return parts.slice(-2).join('/')
28}
29
30export function short(text: unknown, max = 48): string {
31 const s = typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
32 return s.length > max ? `${s.slice(0, max - 1)}…` : s
33}
34
35function lines(text: unknown): number {
36 if (typeof text !== 'string' || text === '') return 0
37 return text.split('\n').length
38}
39
40/** The step name, like "edit auth/login.ts" or "run npm test". */
41export function stepLabel(tool: string, args: Record<string, unknown>): string {
42 switch (tool) {
43 case 'Read':
44 return `read ${base(args.file_path)}`
45 case 'Edit':
46 case 'MultiEdit':
47 return `edit ${base(args.file_path)}`
48 case 'Write':
49 return `create ${base(args.file_path)}`
50 case 'NotebookEdit':
51 return `edit ${base(args.notebook_path)}`
52 case 'Bash':
53 return `run ${short(args.description || args.command, 40)}`
54 case 'Grep':
55 return `search "${short(args.pattern, 30)}"`
56 case 'Glob':
57 return `find ${short(args.pattern, 30)}`
58 case 'WebSearch':
59 return `web search ${short(args.query, 36)}`
60 case 'WebFetch':
61 return `open ${short(args.url, 36)}`
62 case 'Agent':
63 case 'Task':
64 return `subagent: ${short(args.description, 34)}`
65 default:
66 return tool.replace(/^mcp__[^_]+__/, '').replace(/_/g, ' ').toLowerCase()
67 }
68}
69
70/** Lines added and removed by an edit, from its arguments. */
71export function lineDelta(tool: string, args: Record<string, unknown>): { added: number; removed: number } {
72 if (tool === 'Write') return { added: lines(args.content), removed: 0 }
73 if (tool === 'Edit') return { added: lines(args.new_string), removed: lines(args.old_string) }
74 if (tool === 'MultiEdit' && Array.isArray(args.edits)) {
75 return (args.edits as Record<string, unknown>[]).reduce<{ added: number; removed: number }>(
76 (acc, one) => ({ added: acc.added + lines(one.new_string), removed: acc.removed + lines(one.old_string) }),
77 { added: 0, removed: 0 },
78 )
79 }
80 return { added: 0, removed: 0 }
81}
82
83export const MAX_DIFF_LINES = 8
84/** How many lines per side to keep for the expanded view. */
85export const MAX_STORED_LINES = 400
86export const DIFF_BORDER = '#2e2e2e'
87
88function splitLines(text: unknown): string[] {
89 return typeof text === 'string' && text !== '' ? text.split('\n') : []
90}
91
92/** The code of a change: what was removed (−) and added (+), up to MAX_STORED_LINES per side. */
93export function diffLines(tool: string, args: Record<string, unknown>): { lines: DiffLine[]; hidden: number } {
94 const edits: { removed: string[]; added: string[] }[] =
95 tool === 'Write'
96 ? [{ removed: [], added: splitLines(args.content) }]
97 : tool === 'MultiEdit' && Array.isArray(args.edits)
98 ? (args.edits as Record<string, unknown>[]).map(one => ({
99 removed: splitLines(one.old_string),
100 added: splitLines(one.new_string),
101 }))
102 : tool === 'Edit'
103 ? [{ removed: splitLines(args.old_string), added: splitLines(args.new_string) }]
104 : []
105 const lines: DiffLine[] = []
106 let hidden = 0
107 for (const one of edits) {
108 for (const [sign, list] of [['−', one.removed], ['+', one.added]] as const) {
109 list.slice(0, MAX_STORED_LINES).forEach(text => lines.push({ sign, text: text.trimEnd() }))
110 hidden += Math.max(0, list.length - MAX_STORED_LINES)
111 }
112 }
113 return { lines, hidden }
114}
115
116/** The collapsed view: the first MAX_DIFF_LINES lines per side; `more` is what was left out. */
117export function collapse(lines: DiffLine[], hidden: number): { shown: DiffLine[]; more: number } {
118 const count = { '−': 0, '+': 0 }
119 const shown = lines.filter(l => ++count[l.sign] <= MAX_DIFF_LINES)
120 return { shown, more: lines.length - shown.length + hidden }
121}
122
123/** Progress of a running step: fills fast, then slows down, never past 95%. */
124export function runningPct(elapsedMs: number): number {
125 return Math.min(95, Math.round(100 * (1 - Math.exp(-elapsedMs / 6000))))
126}
127
128export function formatDuration(ms: number): string {
129 const s = Math.max(0, Math.round(ms / 1000))
130 const m = Math.floor(s / 60)
131 return m > 0 ? `${m}m ${s % 60}s` : `${s}s`
132}
133
134export function summarize(steps: Step[], durationMs: number, answer = ''): TurnSummary {
135 const byFile = new Map<string, FileChange>()
136 for (const s of steps) {
137 if (s.status !== 'done' || !s.file) continue
138 const cur = byFile.get(s.file) ?? { file: s.file, added: 0, removed: 0 }
139 byFile.set(s.file, { file: s.file, added: cur.added + s.added, removed: cur.removed + s.removed })
140 }
141 return {
142 done: steps.filter(s => s.status === 'done').length,
143 failed: steps.filter(s => s.status === 'failed').length,
144 durationMs,
145 files: [...byFile.values()].filter(f => f.added + f.removed > 0),
146 changes: steps
147 .filter(s => s.status === 'done' && s.file && (s.diff ?? []).length > 0)
148 .map((s): CodeChange => ({ file: s.file as string, lines: s.diff, hidden: s.hidden })),
149 answer,
150 }
151}
152
153
154export function registerSteps(on: On): void {
155 let ticker: Timer | null = null
156
157 on('prompt.submit', async ($, e, next) => {
158 await update($, stepsAtom, () => [])
159 return next(e)
160 }).catch(($, e, next) => next(e))
161
162 on('turn.start', async ($, e, next) => {
163 if (!ticker) {
164 ticker = $.clock.every(200, () => {
165 void (async () => {
166 const steps = await read($, stepsAtom)
167 if (steps.some(s => s.status === 'running')) await update($, tickAtom, n => n + 1)
168 })()
169 })
170 }
171 return next(e)
172 })
173
174 on('tool.call', async ($, e, next) => {
175 if (e.agentId || SKIP.has(e.tool)) return next(e)
176
177 const args = e as unknown as Record<string, unknown>
178 const id = e.tool_use_id ?? `${e.tool}-${await $.clock.now()}`
179 const delta = lineDelta(e.tool, args)
180 const code = diffLines(e.tool, args)
181 const file = ['Edit', 'MultiEdit', 'Write'].includes(e.tool) ? base(args.file_path) : null
182 const step: Step = {
183 id,
184 label: stepLabel(e.tool, args),
185 status: 'running',
186 startedAt: await $.clock.now(),
187 endedAt: null,
188 detail: null,
189 file,
190 added: delta.added,
191 removed: delta.removed,
192 diff: file ? code.lines : [],
193 hidden: file ? code.hidden : 0,
194 }
195 await update($, stepsAtom, list => [...list, step].slice(-40))
196
197 const ran = await next(e)
198
199 const failed = ran.deny !== undefined || ran.isError === true
200 const reason = ran.deny ?? (typeof ran.text === 'string' ? ran.text.split('\n')[0] : '')
201 const detail = failed
202 ? `failed: ${short(reason, 60) || 'error'}`
203 : file
204 ? `+${delta.added} −${delta.removed} lines`
205 : null
206 const endedAt = await $.clock.now()
207 await update($, stepsAtom, list =>
208 list.map(s => (s.id === id ? { ...s, status: failed ? 'failed' : 'done', endedAt, detail } : s)),
209 )
210 return ran
211 }).catch(($, e, next) => next(e))
212
213 on('turn.complete', async ($, e, next) => {
214 if (!e.agentId) {
215 ticker?.cancel()
216 ticker = null
217 const steps = await read($, stepsAtom)
218 if (steps.length > 0) {
219 const summary = summarize(steps, e.durationMs, e.answer ?? '')
220 await update($, summariesAtom, all => {
221 const keys = Object.keys(all).slice(-49)
222 const kept: Record<string, TurnSummary> = {}
223 for (const k of keys) kept[k] = all[k] as TurnSummary
224 kept[String(e.durationMs)] = summary
225 return kept
226 })
227 }
228 }
229 const done = await next(e)
230 if (!e.agentId) void $.prompt.suggest({ text: OPTIONS_HINT }).catch(() => undefined)
231 return done
232 })
233
234 // After the steps: the code of each change and, below it, the AI answer. No duration line or step
235 // count, since the bars already showed that. "… N more lines" expands a box; /expand opens them all.
236 on('ui.render', { component: 'TurnDuration' }, async ($, e) => {
237 const els = $.ui.resolve(e)
238 const { Box, Text, Button } = els
239 const all = await read($, summariesAtom)
240 const summary = all[String(e.props.durationMs)]
241 const changes = summary?.changes ?? []
242 if (changes.length === 0) return <Box />
243
244 const expanded = await read($, expandedAtom)
245 const expandAll = await read($, expandAllAtom)
246
247 return (
248 <Box flexDirection="column">
249 <Text key="gap">{'\u00a0'}</Text>
250 {changes.map((c, i) => {
251 const id = `${e.props.durationMs}:${i}`
252 const isOpen = expandAll || expanded[id] === true
253 const { shown, more } = collapse(c.lines, c.hidden)
254 const lines = isOpen ? c.lines : shown
255 return (
256 <Box key={`change-${i}`} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
257 <Text dimColor>{c.file}</Text>
258 <Box flexDirection="column" borderStyle="round" borderColor={DIFF_BORDER} paddingX={1}>
259 {lines.map((l, j) => (
260 <Text key={`c${i}-${j}`} color={l.sign === '+' ? GREEN : RED} wrap="truncate-end">
261 {l.sign} {l.text}
262 </Text>
263 ))}
264 {more > 0 && !expandAll ? (
265 <Button
266 key={`more-${id}`}
267 plain
268 dimColor
269 label={isOpen ? '▴ collapse' : `… ${more} more lines`}
270 onPress={() =>
271 update($, expandedAtom, map => {
272 const next = { ...map }
273 if (isOpen) delete next[id]
274 else next[id] = true
275 return next
276 })
277 }
278 />
279 ) : null}
280 {isOpen && c.hidden > 0 ? <Text dimColor>… {c.hidden} more lines too long to show</Text> : null}
281 </Box>
282 </Box>
283 )
284 })}
285 {summary?.answer ? drawAnswer(els, summary.answer, true) : null}
286 </Box>
287 )
288 })
289
290 on('command.run', { command: 'expand' }, async $ => {
291 await update($, expandAllAtom, v => !v)
292 const now = await read($, expandAllAtom)
293 return { text: now ? 'Full code: expanded' : 'Full code: collapsed' }
294 })
295
296 // Short, direct answers.
297 on('prompt.compose', async ($, e, next) => {
298 const composed = await next(e)
299 return {
300 ...composed,
301 sections: [...composed.sections, { id: 'clean-cli-style', text: STYLE, scope: 'session' as const }],
302 }
303 })
304}
305types/index.d.ts 48 lines1export type ModelChoice = 'fable' | 'opus' | 'sonnet' | 'haiku'
2export type EffortChoice = 'low' | 'medium' | 'high' | 'max'
3
4export type DiffLine = { sign: '−' | '+'; text: string }
5
6export type StepStatus = 'running' | 'done' | 'failed'
7
8export type Step = {
9 id: string
10 label: string
11 status: StepStatus
12 startedAt: number
13 endedAt: number | null
14 detail: string | null
15 file: string | null
16 added: number
17 removed: number
18 diff: DiffLine[]
19 hidden: number
20}
21
22export type FileChange = { file: string; added: number; removed: number }
23
24export type CodeChange = { file: string; lines: DiffLine[]; hidden: number }
25
26export type TurnSummary = {
27 done: number
28 failed: number
29 durationMs: number
30 files: FileChange[]
31 changes: CodeChange[]
32 answer: string
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 'clean-cli': {
38 model: ModelChoice | null
39 effort: EffortChoice | null
40 steps: Step[]
41 tick: number
42 summaries: Record<string, TurnSummary>
43 expanded: Record<string, boolean>
44 expandAll: boolean
45 }
46 }
47}
48