SLOPSHOPPER

cleanscreen

Hides tool calls and their output, shows Claude's text and a progress band above the prompt. Off by default; /cleanscreen on turns it on.

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cleanscreen
│ ┃ Clean screen ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Clean screen │ ┃ Hides tool calls and their output. Shows ⏺ Read(src/auth.ts) │ ┃ Claude's text and a progress band above ⎿ Read 6 lines │ ┃ the prompt. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Clean screen ⏺ Bash(bun test) │ ┃ ○ On ⎿ 3 pass, 1 fail │ ┃ ● Off │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Band color │ ┃ ■ ● blue ✻ Worked for 42s · done 4:20 PM │ ┃ ■ ○ dark │ ┃ ■ ○ light › /cleanscreen │ ┃ ■ ○ green ⎿ cleanscreen: Clean screen settings are open. │ ┃ ■ ○ rainbow │ ┃ ■ ○ custom │ ┃ │ ┃ ↑↓ move · enter select · esc close │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Clean screen
◆ Clean screen Hides tool calls and their output. Shows Claude's text and a progress band above the prompt. Clean screen ○ On ● Off Band color ■ ● blue ■ ○ dark ■ ○ light ■ ○ green ■ ○ rainbow ■ ○ custom ↑↓ move · enter select · esc close
README

cleanscreen

A "Clean screen" mode for Claude Code. While it is on, tool calls and their output are hidden: you see Claude's text, plus a progress band above the prompt (task title, step list, percent bar, elapsed time). Claude feeds the band through an MCP tool, cleanscreen_progress (mcp__cleanscreen__cleanscreen_progress); TodoWrite and the Task tools update it too.

It is off by default.

Install

/plugin marketplace add danielmalka/malka-claude-mods
/plugin install cleanscreen@malka-claude-mods

Commands

  • /cleanscreen on / /cleanscreen off
  • /cleanscreen theme <blue|dark|light|green|rainbow|custom>
  • /cleanscreen color <bar|text|border> <#rrggbb> sets the custom theme's colors (use /cleanscreen theme custom to show them)
  • /cleanscreen with no argument opens the settings pane (On / Off, theme, custom colors)

Your choices are remembered across sessions.

With or without the menu

Works on its own. With the optional menu mod installed, it lists "Clean screen" (off, or on · <theme> band) and shows the same settings page inside the menu's pane, with a back button.

Privacy

It only changes what is displayed. Nothing is sent anywhere, no files are read or written, and the conversation Claude sees is unchanged, except that while it is on, each typed prompt carries a short instruction telling Claude to report progress through the tool. While off, no instruction is added and the tool is not offered to the model. (A session that turned it on and then off keeps the tool registered until restart; it then only answers that the mode is off.)

The band and the instruction start for prompts you type, in the terminal or through Remote Control. SDK prompts get hidden tool rows but no band. Tool calls that errored, were refused or were interrupted are never hidden.

Uninstall

/cleanscreen off
/plugin uninstall cleanscreen@malka-claude-mods

Development

Type declarations in .claude-plugin/types/ are generated by Claude Code when the mod loads (not committed); the mod's state contract is types/index.d.ts, and types/menu.d.ts vendors the menu mod's contract (apiVersion 1). Then claude plugin test plugins/cleanscreen.


Português

Um modo "Clean screen" para o Claude Code. Ligado, as chamadas de ferramenta e suas saídas ficam ocultas: você vê só o texto do Claude e uma faixa de progresso acima do prompt (título, lista de passos, barra de porcentagem, tempo decorrido). O Claude a alimenta pela ferramenta MCP cleanscreen_progress (mcp__cleanscreen__cleanscreen_progress); TodoWrite e as ferramentas de Task também a atualizam.

Vem desligado.

Instalar: /plugin marketplace add danielmalka/malka-claude-mods e /plugin install cleanscreen@malka-claude-mods.

Comandos: /cleanscreen on|off; /cleanscreen theme <blue|dark|light|green|rainbow|custom>; /cleanscreen color <bar|text|border> <#rrggbb> (cores do tema custom); /cleanscreen sem argumento abre o painel de configuração. As escolhas ficam salvas entre sessões.

Com ou sem menu: funciona sozinho. Com o mod opcional menu, aparece na lista ("Clean screen", off ou on · <tema> band) e mostra a mesma página dentro do painel do menu.

Privacidade: só altera o que é exibido. Nada é enviado para fora nem gravado em arquivos. Ligado, cada prompt digitado leva uma instrução curta pedindo ao Claude que reporte o progresso pela ferramenta; desligado, nenhuma instrução é adicionada e a ferramenta não é oferecida ao modelo (numa sessão em que foi ligado e depois desligado, a ferramenta segue registrada até reiniciar e apenas responde que o modo está desligado).

A faixa e a instrução começam para prompts que você digita, no terminal ou pelo Remote Control. Prompts do SDK têm as linhas ocultas, mas sem faixa. Chamadas com erro, recusadas ou interrompidas nunca são ocultadas.

Desinstalar: /cleanscreen off e depois /plugin uninstall cleanscreen@malka-claude-mods.

Source 5 files
hooks/register.tsx 267 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type {} from '../types/menu'
5import type { CleanColors, CleanTheme, MenuEntry } from '../types'
6import { cleanBand } from './band'
7import {
8  addStep, colorsFor, contextFor, DEFAULT_CUSTOM, HEX, parseInput, percentOf, PROMPT, THEMES, todoSteps, TOOL, TOOL_DESCRIPTION,
9  TOOL_NAME, TOOL_SCHEMA, updateSteps,
10} from './clean'
11import { cleanPage } from './page'
12
13const isOnAtom = atom({ plugin: 'cleanscreen', key: 'isOn' } as const, false)
14const themeAtom = atom({ plugin: 'cleanscreen', key: 'theme' } as const, 'blue')
15const customAtom = atom({ plugin: 'cleanscreen', key: 'custom' } as const, DEFAULT_CUSTOM)
16const collapsedAtom = atom({ plugin: 'cleanscreen', key: 'isCollapsed' } as const, false)
17const taskAtom = atom({ plugin: 'cleanscreen', key: 'task' } as const, null)
18const clockAtom = atom({ plugin: 'cleanscreen', key: 'clock' } as const, 0)
19// Foreign ref: the optional `menu` mod owns it (apiVersion 1); undefined = no menu.
20const menuPanel = { plugin: 'menu', key: 'panel' } as const
21const menuEntryRef = { plugin: 'cleanscreen', key: 'menuEntry' } as const
22
23const PANE = 'cleanscreen-settings'
24const MENU_PANE = 'mods-menu'
25
26let ticker: Timer | undefined
27const stopTicker = () => {
28  ticker?.cancel()
29  ticker = undefined
30}
31
32const writeEntry = async ($: EngineInterface) => {
33  const isOn = await read($, isOnAtom)
34  const entry: MenuEntry = { apiVersion: 1, title: 'Clean screen', summary: isOn ? `on · ${await read($, themeAtom)} band` : 'off', order: 2 }
35  await $.state.set(menuEntryRef, entry)
36}
37
38// The model can only see the tool once registered, and the API has no way to take it back: it is registered
39// only while on (at session start or when turned on), never while off.
40const exposeTool = ($: EngineInterface) =>
41  $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: TOOL_SCHEMA }).then(() => undefined, () => undefined)
42
43async function finishTask($: EngineInterface) {
44  try {
45    stopTicker()
46    const t = await read($, taskAtom)
47    if (t && t.doneAt === null) {
48      const doneAt = await $.clock.now()
49      await update($, taskAtom, c => (c ? { ...c, doneAt } : c))
50      await update($, clockAtom, () => doneAt)
51      $.clock.after(8000, () => {
52        void update($, taskAtom, c => (c && c.doneAt === doneAt ? null : c))
53      })
54    }
55  } catch {}
56}
57
58async function setOn($: EngineInterface, isOn: boolean) {
59  await update($, isOnAtom, () => isOn)
60  await $.store.set('isOn', isOn)
61  if (isOn) await exposeTool($)
62  else {
63    stopTicker()
64    await update($, taskAtom, () => null)
65  }
66  await writeEntry($)
67}
68
69async function setTheme($: EngineInterface, theme: CleanTheme) {
70  await update($, themeAtom, () => theme)
71  await $.store.set('theme', theme)
72  await writeEntry($)
73}
74
75// Returns an error text, or undefined when the color was set.
76async function setColor($: EngineInterface, part: keyof CleanColors, value: string) {
77  if (!HEX.test(value.trim())) {
78    const text = `${value} is not a hex color like #3b82f6`
79    $.ui.toast(text)
80    return text
81  }
82  const colors = await update($, customAtom, c => ({ ...c, [part]: value.trim() }))
83  await $.store.set('custom', colors)
84  return undefined
85}
86
87const noop = () => undefined
88
89async function page($: EngineInterface, e: any, hasBack: boolean) {
90  return cleanPage($.ui.resolve(e), {
91    isOn: await read($, isOnAtom),
92    theme: await read($, themeAtom),
93    custom: await read($, customAtom),
94    hasBack,
95    onOn: v => void setOn($, v).catch(() => undefined),
96    onTheme: t => void setTheme($, t).catch(() => undefined),
97    onColor: (part, value) => void setColor($, part, value).catch(() => undefined),
98    onBack: noop, // the menu mod handles this press itself (ui.press on mods-back)
99  })
100}
101
102export const register: Register = on => {
103  on('session.start', async ($, e, next) => {
104    try {
105      // Default off: only a stored "on" turns it on.
106      const isOn = (await $.store.get('isOn')) === true
107      const theme = (await $.store.get('theme')) as CleanTheme | undefined
108      const custom = (await $.store.get('custom')) as CleanColors | undefined
109      stopTicker()
110      await update($, taskAtom, () => null)
111      await update($, collapsedAtom, () => false)
112      await update($, isOnAtom, () => isOn)
113      if (theme !== undefined && THEMES.includes(theme)) await update($, themeAtom, () => theme)
114      if (custom !== undefined && HEX.test(custom.bar) && HEX.test(custom.text) && HEX.test(custom.border)) await update($, customAtom, () => custom)
115      if (isOn) await exposeTool($)
116      await writeEntry($)
117      await $.command
118        .register({ name: 'cleanscreen', description: 'Clean screen: hide tool calls, show a progress band', argumentHint: '[on|off|theme <name>|color <bar|text|border> <#hex>]' })
119        .catch(() => undefined)
120    } catch {}
121    return next(e)
122  })
123
124  on('command.run', { command: 'cleanscreen' }, async ($, e) => {
125    const [cmd = '', a1 = '', a2 = ''] = e.args.trim().toLowerCase().split(/\s+/)
126    if (cmd === 'on' || cmd === 'off') {
127      await setOn($, cmd === 'on')
128      return { text: `Clean screen is ${cmd}.` }
129    }
130    if (cmd === 'theme') {
131      if (!THEMES.includes(a1 as CleanTheme)) return { text: `Themes: ${THEMES.join(', ')}.` }
132      await setTheme($, a1 as CleanTheme)
133      return { text: `Clean screen theme is ${a1}.` }
134    }
135    if (cmd === 'color') {
136      if (a1 !== 'bar' && a1 !== 'text' && a1 !== 'border') return { text: 'Usage: /cleanscreen color <bar|text|border> <#rrggbb>' }
137      const bad = await setColor($, a1, a2)
138      return { text: bad ?? `Custom ${a1} color is ${a2}. Use /cleanscreen theme custom to show it.` }
139    }
140    await $.ui.open({ id: PANE, title: 'Clean screen', focus: true, closeOnEscape: true, rows: 22, columns: 56 })
141    return { text: 'Clean screen settings are open.' }
142  })
143
144  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => page($, e, false))
145
146  // Inside the menu's pane (apiVersion 1): drawn only when the menu is on this panel.
147  on('ui.render', { component: 'Pane', requestId: MENU_PANE }, async ($, e, next) => {
148    const { value } = await $.state.get(menuPanel)
149    if (value !== 'cleanscreen') return next(e)
150    return page($, e, true)
151  })
152
153  // The band above the prompt. A failure draws nothing of ours.
154  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
155    try {
156      if (e.props.hasSurvey) return next(e)
157      const t = await read($, taskAtom)
158      if (!(await read($, isOnAtom)) || t === null) return next(e)
159      const theme = await read($, themeAtom)
160      return cleanBand($.ui.resolve(e), {
161        t,
162        theme,
163        colors: colorsFor(theme, await read($, customAtom)),
164        isCollapsed: await read($, collapsedAtom),
165        clock: await read($, clockAtom),
166        columns: Math.max(30, e.props.bodyColumns ?? 80),
167        onToggle: () => update($, collapsedAtom, c => !c),
168      })
169    } catch {
170      return next(e)
171    }
172  })
173
174  // Tool rows draw nothing while it is on, except a call that errored, was refused or was interrupted.
175  for (const component of ['ToolUse', 'ToolResult', 'ToolGroup', 'ToolProgress'] as const) {
176    on('ui.render', { component }, async ($, e, next) => {
177      let hide = false
178      try {
179        const p = e.props as any
180        const problem =
181          p.isErrored === true || p.isInterrupted === true || (Array.isArray(p.calls) && p.calls.some((c: any) => c.isErrored === true || c.isInterrupted === true))
182        hide = !problem && (await read($, isOnAtom))
183      } catch {}
184      return hide ? $.ui.resolve(e).Box({ display: 'none' }) : next(e)
185    })
186  }
187
188  // A typed prompt starts the band's task and carries the instructions; nothing is added while off. Any error: the prompt goes on unchanged.
189  on('prompt.submit', async ($, e, next) => {
190    try {
191      const context = contextFor(e.origin?.kind, e.text, await read($, isOnAtom))
192      if (context.length === 0) return next(e)
193      const startedAt = await $.clock.now()
194      const title = (String(e.text).trim().split('\n')[0] ?? '').slice(0, 200)
195      await update($, taskAtom, () => ({ title, startedAt, doneAt: null, steps: [] }))
196      await update($, clockAtom, () => startedAt)
197      stopTicker()
198      ticker = $.clock.every(1000, () => {
199        void $.clock.now().then(t => update($, clockAtom, () => t))
200      })
201      return next({ ...e, context: [...(e.context ?? []), ...context] })
202    } catch {
203      return next(e)
204    }
205  })
206
207  // The turn ending, or being interrupted, marks the task done; the band clears a moment later.
208  on('turn.complete', async ($, e, next) => {
209    try {
210      return await next(e)
211    } finally {
212      if (!e.agentId) await finishTask($)
213    }
214  })
215  on('turn.abort', async ($, e, next) => {
216    await finishTask($)
217    return next(e)
218  })
219
220  // The model's progress tool. Each call sends the whole step list. Fail-open: a state error answers with a plain result.
221  on('tool.call', { tool: TOOL }, async ($, e) => {
222    try {
223      if (!(await read($, isOnAtom))) return { result: 'Clean screen is off; the progress band is not shown.' }
224      const { title, steps } = parseInput(e)
225      // ponytail: a task begun by this call has no elapsed-time ticker (a typed prompt starts that one).
226      const current = await read($, taskAtom)
227      if (current === null || current.doneAt !== null) {
228        const startedAt = await $.clock.now()
229        await update($, taskAtom, () => ({ title: 'Working', startedAt, doneAt: null, steps: [] }))
230        await update($, clockAtom, () => startedAt)
231      }
232      await update($, taskAtom, t => (t === null ? t : { ...t, title: title || t.title, steps }))
233      const t = await read($, taskAtom)
234      const done = steps.filter(s => s.status === 'completed').length
235      return { result: `Progress band updated: ${done} of ${steps.length} steps done (${t === null ? 0 : percentOf(t)}%).` }
236    } catch {
237      return { result: 'Progress band unavailable.' }
238    }
239  })
240
241  // The three below only follow the tool's input or result: a state error is dropped, the call itself goes on once.
242  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
243    try {
244      await update($, taskAtom, t => (t === null ? t : { ...t, steps: todoSteps(e.todos) }))
245    } catch {}
246    return next(e)
247  })
248
249  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
250    const ran = await next(e)
251    try {
252      const id = (ran.result as { task?: { id?: string } } | undefined)?.task?.id
253      if (id !== undefined) await update($, taskAtom, t => (t === null ? t : { ...t, steps: addStep(t.steps, id, e.subject) }))
254    } catch {}
255    return ran
256  })
257
258  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
259    const ran = await next(e)
260    if (ran.deny !== undefined || ran.isError === true) return ran
261    try {
262      await update($, taskAtom, t => (t === null ? t : { ...t, steps: updateSteps(t.steps, e) }))
263    } catch {}
264    return ran
265  })
266}
267
hooks/band.tsx 73 lines
1import type { CleanColors, CleanTask, CleanTheme } from '../types'
2import { barCells, elapsed, percentOf, stepLabel, visibleSteps } from './clean'
3
4// Clean screen's progress band.
5export function cleanBand(
6  { Box, Text, Button }: { Box: any; Text: any; Button: any },
7  p: { t: CleanTask; theme: CleanTheme; colors: CleanColors; isCollapsed: boolean; clock: number; columns: number; onToggle: () => unknown },
8) {
9  const { t, theme, colors, isCollapsed } = p
10  const percent = percentOf(t)
11  const mark = t.doneAt !== null ? '✓' : '✶'
12  const width = Math.max(8, p.columns - 4 - 16 - 6 - (isCollapsed ? Math.min(30, t.title.length + 2) : 0))
13  const { filled, runs } = barCells(theme, colors.bar, width, percent)
14  const bar = (
15    <Box>
16      {runs.map(r => (
17        <Text color={r.color}>{'█'.repeat(r.cells)}</Text>
18      ))}
19      <Text dimColor>{'░'.repeat(width - filled)}</Text>
20    </Box>
21  )
22  const toggle = <Button key="cs-collapse" plain dimColor label={isCollapsed ? '[+]' : '[–]'} onPress={p.onToggle} />
23
24  if (isCollapsed) {
25    return (
26      <Box borderStyle="round" borderColor={colors.border} paddingX={1} gap={1}>
27        <Box width={Math.min(30, t.title.length + 2)}>
28          <Text color={colors.text} bold wrap="truncate">
29            {mark} {t.title}
30          </Text>
31        </Box>
32        {bar}
33        <Text color={colors.text} bold>{`${percent}%`.padStart(4)}</Text>
34        {toggle}
35      </Box>
36    )
37  }
38
39  const shown = visibleSteps(t)
40  return (
41    <Box flexDirection="column" borderStyle="round" borderColor={colors.border} paddingX={1}>
42      <Box justifyContent="space-between" gap={1}>
43        <Text color={colors.text} bold wrap="truncate">
44          {mark} {t.title}
45        </Text>
46        <Box gap={1} flexShrink={0}>
47          <Text dimColor>{elapsed((t.doneAt ?? p.clock) - t.startedAt)}</Text>
48          {toggle}
49        </Box>
50      </Box>
51      <Box gap={1}>
52        <Text dimColor>{stepLabel(t).padEnd(14)}</Text>
53        {bar}
54        <Text color={colors.text} bold>{`${percent}%`.padStart(4)}</Text>
55      </Box>
56      {shown.map(step => {
57        const isNow = step.status === 'in_progress'
58        return (
59          <Box key={`step-${step.id}`} justifyContent="space-between" gap={1}>
60            <Text color={isNow ? colors.text : undefined} bold={isNow} dimColor={step.status === 'pending'} wrap="truncate">
61              {step.status === 'completed' ? '●' : isNow ? '◉' : '○'} {step.text}
62            </Text>
63            <Text color={isNow ? colors.text : undefined} dimColor={!isNow}>
64              {step.status === 'completed' ? 'Done' : isNow ? 'Working' : 'Next'}
65            </Text>
66          </Box>
67        )
68      })}
69      {t.steps.length > shown.length && <Text dimColor>{t.steps.length - shown.length} more</Text>}
70    </Box>
71  )
72}
73
hooks/clean.ts 142 lines
1// Clean screen's look and arithmetic. Pure; the hooks are in register.tsx.
2import type { CleanColors, CleanStep, CleanTask, CleanTheme, StepStatus } from '../types'
3
4export const THEMES: readonly CleanTheme[] = ['blue', 'dark', 'light', 'green', 'rainbow', 'custom']
5
6export const RAINBOW = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#a855f7', '#ec4899']
7
8const PRESETS: Record<Exclude<CleanTheme, 'custom'>, CleanColors> = {
9  blue: { bar: '#3b82f6', text: '#bfdbfe', border: '#3b82f6' },
10  dark: { bar: '#e5e7eb', text: '#d1d5db', border: '#6b7280' },
11  light: { bar: '#2563eb', text: '#1f2937', border: '#9ca3af' },
12  green: { bar: '#22c55e', text: '#bbf7d0', border: '#16a34a' },
13  rainbow: { bar: '#ef4444', text: '#f9a8d4', border: '#ec4899' },
14}
15
16export const DEFAULT_CUSTOM: CleanColors = { bar: '#3b82f6', text: '#e2e8f0', border: '#3b82f6' }
17
18export const TOOL_NAME = 'cleanscreen_progress'
19// The name the model calls it by: mcp__<plugin>__<name>.
20export const TOOL = `mcp__cleanscreen__${TOOL_NAME}`
21
22export const PROMPT = [
23  'Clean screen is on: the user sees only your text messages, not your tool calls or their output.',
24  `They follow your work on a progress band fed by the ${TOOL_NAME} tool (${TOOL}).`,
25  'Before you start, call it with your plan as a list of short steps. Call it again with the whole list each time a step starts or finishes,',
26  'so exactly one step is in_progress while you work and every step is completed when you are done.',
27  'Before acting, say in a sentence what you are about to do and why.',
28].join(' ')
29
30export const TOOL_DESCRIPTION =
31  'Updates the Clean screen progress band the user watches. Send your whole plan each call: every step with its status ' +
32  '(pending, in_progress or completed). Call it when you start a task and whenever a step starts or finishes.'
33
34export const TOOL_SCHEMA = {
35  type: 'object',
36  properties: {
37    title: { type: 'string', description: 'Optional short name for the whole task.' },
38    steps: {
39      type: 'array',
40      description: 'The full plan, in order.',
41      items: {
42        type: 'object',
43        properties: {
44          text: { type: 'string', description: 'The step, in a few words.' },
45          status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
46        },
47        required: ['text', 'status'],
48      },
49    },
50  },
51  required: ['steps'],
52}
53
54export const colorsFor = (theme: CleanTheme, custom: CleanColors): CleanColors => (theme === 'custom' ? custom : PRESETS[theme])
55
56export const percentOf = (t: CleanTask): number => {
57  if (t.doneAt !== null) return 100
58  if (t.steps.length === 0) return 0
59  return Math.round((t.steps.filter(s => s.status === 'completed').length / t.steps.length) * 100)
60}
61
62const STATUSES: readonly StepStatus[] = ['pending', 'in_progress', 'completed']
63export const asStatus = (s: unknown): StepStatus => (STATUSES.includes(s as StepStatus) ? (s as StepStatus) : 'pending')
64
65export const elapsed = (ms: number): string => {
66  const s = Math.max(0, Math.floor(ms / 1000))
67  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
68}
69
70export const stepLabel = (t: CleanTask): string => {
71  if (t.doneAt !== null) return 'Done'
72  if (t.steps.length === 0) return 'Working…'
73  const current = t.steps.findIndex(s => s.status === 'in_progress')
74  const done = t.steps.filter(s => s.status === 'completed').length
75  return `Step ${Math.min(t.steps.length, (current >= 0 ? current : done) + 1)} of ${t.steps.length}`
76}
77
78// How many cells of each color a bar `width` wide fills at `percent`.
79export const barCells = (theme: CleanTheme, bar: string, width: number, percent: number) => {
80  const filled = Math.round((width * Math.max(0, Math.min(100, percent))) / 100)
81  if (theme !== 'rainbow') return { filled, runs: filled > 0 ? [{ color: bar, cells: filled }] : [] }
82  const runs = RAINBOW.map((color, i) => {
83    const from = Math.floor((width * i) / RAINBOW.length)
84    const to = Math.floor((width * (i + 1)) / RAINBOW.length)
85    return { color, cells: Math.max(0, Math.min(to, filled) - from) }
86  }).filter(r => r.cells > 0)
87  return { filled, runs }
88}
89
90// At most `room` steps, keeping the current one in view.
91export const visibleSteps = (t: CleanTask, room = 6) => {
92  const current = t.steps.findIndex(s => s.status === 'in_progress')
93  const first = Math.max(0, Math.min(current - 2, t.steps.length - room))
94  return t.steps.slice(first, first + room)
95}
96
97// What the model sends is untrusted and drawn on the terminal: control characters go, text is trimmed, sizes capped.
98const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g
99export const TEXT_MAX = 200
100export const STEPS_MAX = 50
101export const cleanText = (v: unknown, max = TEXT_MAX): string =>
102  typeof v === 'string' ? v.replace(CONTROL, '').trim().slice(0, max) : ''
103
104export const parseInput = (input: unknown): { title: string; steps: CleanStep[] } => {
105  const o = input && typeof input === 'object' ? (input as { title?: unknown; steps?: unknown }) : {}
106  const steps: CleanStep[] = []
107  for (const [i, raw] of (Array.isArray(o.steps) ? o.steps.slice(0, STEPS_MAX) : []).entries()) {
108    if (raw === null || typeof raw !== 'object') continue
109    const text = cleanText((raw as { text?: unknown }).text)
110    if (text !== '') steps.push({ id: `cs-${i}`, text, status: asStatus((raw as { status?: unknown }).status) })
111  }
112  return { title: cleanText(o.title), steps }
113}
114
115// TodoWrite / TaskCreate / TaskUpdate as band steps.
116export const todoSteps = (todos: { content: string; activeForm?: string; status: string }[]): CleanStep[] =>
117  todos.slice(0, STEPS_MAX).map((todo, i) => ({
118    id: `todo-${i}`,
119    text: cleanText(todo.status === 'in_progress' ? todo.activeForm || todo.content : todo.content),
120    status: asStatus(todo.status),
121  })).filter(s => s.text !== '')
122
123export const addStep = (steps: CleanStep[], id: string, subject: string): CleanStep[] =>
124  cleanText(subject) === ''
125    ? steps
126    : [...steps.filter(s => s.id !== id), { id, text: cleanText(subject), status: 'pending' as const }].slice(0, STEPS_MAX)
127
128export const updateSteps = (steps: CleanStep[], e: { taskId: string; subject?: string; status?: string }): CleanStep[] =>
129  e.status === 'deleted'
130    ? steps.filter(s => s.id !== e.taskId)
131    : steps.map(s =>
132        s.id === e.taskId ? { ...s, text: e.subject ? cleanText(e.subject) : s.text, status: e.status ? asStatus(e.status) : s.status } : s,
133      ).filter(s => s.text !== '')
134
135export const HEX = /^#[0-9a-f]{6}$/i
136
137// The instruction a prompt carries: a prompt the user typed (terminal or Remote Control), not a slash command, while on.
138export const contextFor = (originKind: string | undefined, text: unknown, isOn: boolean): readonly string[] => {
139  const t = String(text ?? '').trim()
140  return isOn && (originKind === 'composer' || originKind === 'bridge') && t !== '' && !t.startsWith('/') ? [PROMPT] : []
141}
142
hooks/page.tsx 63 lines
1import type { CleanColors, CleanTheme } from '../types'
2import { colorsFor, THEMES } from './clean'
3
4// The settings page, shared by the own pane and the menu's pane.
5export function cleanPage(
6  { Box, Text, Button, Input }: { Box: any; Text: any; Button: any; Input: any },
7  p: {
8    isOn: boolean
9    theme: CleanTheme
10    custom: CleanColors
11    hasBack: boolean
12    onOn: (v: boolean) => unknown
13    onTheme: (t: CleanTheme) => unknown
14    onColor: (part: keyof CleanColors, value: string) => unknown
15    onBack: () => unknown
16  },
17) {
18  const option = (key: string, label: string, isOn: boolean, onPress: () => unknown, swatch?: string) => (
19    <Box key={`row-${key}`} gap={1}>
20      {swatch !== undefined && <Text color={swatch}>■</Text>}
21      <Button key={key} plain label={`${isOn ? '●' : '○'} ${label}`} dimColor={!isOn} onPress={onPress} />
22    </Box>
23  )
24  const heading = (text: string) => (
25    <Box key={`h-${text}`} marginTop={1}>
26      <Text bold>{text}</Text>
27    </Box>
28  )
29  return (
30    <Box flexDirection="column" paddingX={1}>
31      <Box justifyContent="space-between">
32        <Text bold color="claude">
33          ◆ Clean screen
34        </Text>
35        {p.hasBack && <Button key="mods-back" plain dimColor hotkey="b" label="← back" onPress={p.onBack} />}
36      </Box>
37      <Text dimColor>Hides tool calls and their output. Shows Claude's text and a progress band above the prompt.</Text>
38      {heading('Clean screen')}
39      {option('cs-on', 'On', p.isOn, () => p.onOn(true))}
40      {option('cs-off', 'Off', !p.isOn, () => p.onOn(false))}
41      {heading('Band color')}
42      {THEMES.map(t => option(`cs-theme-${t}`, t, t === p.theme, () => p.onTheme(t), colorsFor(t, p.custom).bar))}
43      {p.theme === 'custom' && (
44        <Box flexDirection="column">
45          {(['bar', 'text', 'border'] as const).map(part => (
46            <Input
47              key={`cs-color-${part}`}
48              label={`${part} color`}
49              value={p.custom[part]}
50              placeholder="#3b82f6"
51              submitLabel="set"
52              onSubmit={(value: string) => p.onColor(part, value)}
53            />
54          ))}
55        </Box>
56      )}
57      <Box marginTop={1}>
58        <Text dimColor>↑↓ move · enter select · esc close</Text>
59      </Box>
60    </Box>
61  )
62}
63
types/index.d.ts 23 lines
1export type StepStatus = 'pending' | 'in_progress' | 'completed'
2export type CleanStep = { id: string; text: string; status: StepStatus }
3export type CleanTask = { title: string; startedAt: number; doneAt: number | null; steps: CleanStep[] }
4export type CleanTheme = 'blue' | 'dark' | 'light' | 'green' | 'rainbow' | 'custom'
5export type CleanColors = { bar: string; text: string; border: string }
6
7// Menu contract, apiVersion 1: an optional `menu` mod lists this entry.
8export type MenuEntry = { apiVersion: 1; title: string; summary: string; order: number }
9
10declare module 'claude-code' {
11  interface PluginState {
12    cleanscreen: {
13      menuEntry: MenuEntry
14      isOn: boolean
15      theme: CleanTheme
16      custom: CleanColors
17      isCollapsed: boolean
18      task: CleanTask | null
19      clock: number
20    }
21  }
22}
23