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.

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.
/plugin marketplace add danielmalka/malka-claude-mods
/plugin install cleanscreen@malka-claude-mods
/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.
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.
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.
/cleanscreen off
/plugin uninstall cleanscreen@malka-claude-mods
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.
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.
hooks/register.tsx 267 lines1import { 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}
267hooks/band.tsx 73 lines1import 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}
73hooks/clean.ts 142 lines1// 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}
142hooks/page.tsx 63 lines1import 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}
63types/index.d.ts 23 lines1export 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