SLOPSHOPPER

clean-cli

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

newpanespinnerrowsguardcommand
★ 1v0.14.0no licenseupdated 2026-10-08jpguelerazevedo/claude-clean-cli
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clean-cli
│ ┃ options ✕ › fix the failing auth test and add an audit log call │ ┃ model │ ┃ 1: fable 5.1 most capable ⏺ Read(src/auth.ts) │ ┃ 2: opus 5.5 complex tasks ⎿ Read 6 lines │ ┃ 3: sonnet 5.5 balanced   │ ┃ 4: haiku 5.5 fast ✓ edit src/auth.ts ━━━━━━━━━━━━━━━━━━━━ ok │ ┃ ⏺ Bash(bun test) │ ┃ effort ⎿ 3 pass, 1 fail │ ┃ l: low m: medium h: high x: max │ ┃ Done. I made refresh reject expired claims and added an audit call. │ ┃ 1-4 model · l m h x effort · esc closes │   │ src/auth.ts │ ╭───────────────────────────────────────────────────────────────────╮ │ │ − if (!claims) throw new Error('invalid token') │ │ │ + if (!claims || claims.exp < Date.now() / 1000) throw new Error… │ │ │ + await audit('refresh', claims.sub) │ │ ╰───────────────────────────────────────────────────────────────────╯ │ │ src/audit.ts │ ╭───────────────────────────────────────────────────────────────────╮ │ │ + // TODO: implement persistence │ │ │ + export async function audit(event: string, subject: string) { │ │ │ + console.log('audit', event, subject) │ │ │ + // ... rest of the implementation │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · options
model 1: fable 5.1 most capable 2: opus 5.5 complex tasks 3: sonnet 5.5 balanced 4: haiku 5.5 fast effort l: low m: medium h: high x: max 1-4 model · l m h x effort · esc closes
Turn line
  src/auth.ts ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ − if (!claims) throw new Error('invalid token') │ │ + if (!claims || claims.exp < Date.now() / 1000) throw new Error('invalid token') │ │ + await audit('refresh', claims.sub) │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ src/audit.ts ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ + // TODO: implement persistence │ │ + export async function audit(event: string, subject: string) { │ │ + console.log('audit', event, subject) │ │ + // ... rest of the implementation │ │ + } │ │ + │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ src/cache.ts ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ + // TODO: implement eviction later │ │ + const store = new Map<string, unknown>() │ │ + │ │ + export function remember(key: string, value: unknown) { │ │ + console.log('here') │ │ + store.set(key, value) │ │ + } │ │ + │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯   Done. I made refresh reject expired claims, added an audit call, and created src/audit.ts . One test still fails ( revokes on logout ), which looks unrelated to this change.
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
  ✓ edit src/auth.ts ━━━━━━━━━━━━━━━━━━━━ ok
README

clean-cli

A Claude Code mod that makes the terminal minimal: less noise, only what matters.

Demo

<img width="800" height="450" alt="demo" src="https://github.com/user-attachments/assets/42ecb7ea-f5e1-47e1-a8e6-704b18293d13" />

Features

  • Progress bars: every action Claude takes is one line with a bar. ✓ ok in green when done, ✕ error in red with the reason when it fails.
  • Code at the end: after the steps, each change is shown in a small box (removed lines in red, added in green), followed by Claude's answer.
  • Shorter answers: Claude is told to lead with the result and keep it to a few sentences.
  • Model and effort menu: type /o to pick the model (1–4) and effort (l low, m medium, h high, x max). Esc closes it.
  • Cleaner hints: the line under the prompt shows just auto · /o options, and an empty prompt suggests /o (press Tab).

Commands

CommandWhat it does
/oOpens the model and effort menu
/expandShows or hides the full code in every box

You can also click … N more lines under a box to expand just that one.

Install

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.

Try it from the folder

claude --plugin-dir ./claude-clean-cli

Good to know

  • Claude still answers in the language you write in.
  • The progress percentage is an estimate based on time; tools don't report real progress.
  • Claude marks outcomes with ✓ (green) and ✕ (red) itself, so colors work in any language. If it skips a mark, that sentence just stays in the normal color.
  • While Claude is writing, the answer shows in its usual place; it moves below the code boxes when the turn ends.

Development

claude plugin validate .
claude plugin test .
Source 5 files
hooks/register.tsx 147 lines
1import { 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}
147
hooks/answer.tsx 137 lines
1import { 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 }
137
hooks/rows.tsx 92 lines
1import { 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}
92
hooks/steps.tsx 305 lines
1import { 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}
305
types/index.d.ts 48 lines
1export 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