Restyle Claude Code live, including colourblind-safe and high-contrast themes.

<img src="../../brand/pixel-icons/svg/wardrobe.svg" width="64" height="64" alt="">
Restyle Claude Code live, including colourblind-safe and high-contrast themes.
/plugin install wardrobe --marketplace modmyclaude/claude-code-mods
| Command | What it does |
|---|---|
/wear | Opens the gallery: a card per theme with a sample tool row drawn in that theme, its diff chips, Bolt in the theme's costume and a wear button; the one you wear is marked. Close it with its close mark or ctrl+x x. |
/wear <theme> | Switches now. Rows still live on screen redraw in the new look, and the switch band shows the theme above the prompt for six seconds. |
/wear auto | A day theme by day and a night theme by night (see Settings). |
/wear season | The seasonal pack the calendar calls for, re-checked daily (see Seasonal packs). |
/wear pin | This project keeps what it wears now; other projects keep the global choice. While pinned, /wear <theme> changes this project's pin. |
/wear unpin | This project follows the global choice again. |
/wear <theme> --match | Wears the theme and sets Claude Code's own theme setting (the /theme menu) to the built-in nearest it: a light look on a dark theme turns the theme light, keeping a daltonized or ansi variant. Your own theme is remembered. From the desktop app this changes the terminal's theme; the app keeps its own look. |
/wear match | The same for the theme worn now. The gallery's match theme button does both at once. |
/wear default | Back to Claude Code's own drawing. After a match, Claude Code's theme goes back to the one you had, unless you changed it yourself since the match: then your choice stands. |
Each command answers in a toast (an unknown theme's message stays longer). Nothing /wear says is added to the conversation, so it costs no tokens and never reaches the model.
Themes: noir, paper (light), tokyo-night, dracula, catppuccin, solarized, nord, high-contrast, colorblind, and the seasonal packs halloween, christmas, spring (light), summer, autumn. Short forms work too: drac, hc, colourblind, tokyo night, xmas, fall, spooky, and any prefix that names one theme alone (sp is spring, su is summer).
Every look also ships as a Claude Code theme file (themes/<look>.json), listed in /theme as "<Look> (wardrobe)". --match prefers it, so in the terminal the input box border, your message background, the text and the diff colours follow the look too; the desktop app keeps its own theme.
What changes, on the terminal and in the desktop app:
| Where | What wardrobe draws |
|---|---|
| Tool rows | A one-cell stripe in the theme's accent, the theme's icon for the kind of tool (shell, read, edit, write, search, web, agent, todo, MCP), Claude Code's own row, then a status glyph and, for a finished call, the time since Claude asked for it (any wait for your approval included). |
| Folded tool groups | The stripe, one icon per call before the count line; a failed call in the error colour. |
| Diff cards (Edit, Write) | Numbered lines in the theme's add and remove colours, with a +N -N summary. |
| Small shell output | A themed card; stderr lines are marked !, not only coloured. |
| Spinner | The theme's suffix after the word (never over Claude Code's own status message); on a terminal 60 columns or wider, a chip saying what the turn is doing (thinking, writing, using tools). |
| Turn duration | A themed Baked for 3s line (the terminal draws this line; the desktop has its own footer). |
| Above the prompt | The switch band, for six seconds after /wear (see below). |
Five themes made for a time of year, each with its own mark and icons: halloween (pumpkin orange, violet and bone white on near-black, mark ☾), christmas (holly green, berry red, snow white and gold on deep pine, ❄), spring (a light theme: fresh green, blossom pink and sky blue on cream, ❀), summer (sun yellow, sea teal and coral on deep navy, ☼) and autumn (amber, rust, moss and cream on dark brown, ❧). Every pair they paint meets the same 4.5:1 bar as the other themes.
/wear season picks one by the local date and is stored like auto: wardrobe re-checks the calendar once a minute, so the pack turns over at midnight on its own, and /wear names what it wears now and why.
| Dates | Wears |
|---|---|
| Oct 15 to Nov 1 | halloween |
| Dec 1 to Jan 6 | christmas |
| Jan 7 to the end of Feb | nord (the frost theme; the rest of winter has no pack of its own) |
| Mar 1 to May 31 | spring |
| Jun 1 to Aug 31 | summer |
| Sep 1 to Nov 30, Halloween aside | autumn |
Bolt, the modmyclaude mascot, wears each theme: in the gallery beside every card, beside the line that names what is worn, and in the switch band. A costume is an accessory drawn over the sprite, never a change to it: a witch hat for halloween, a santa hat for christmas, a flower crown for spring, sunglasses for summer, a beanie for autumn, a cape and fangs for dracula, cat ears for catppuccin, a headband for tokyo-night, a fedora for noir, reading glasses for paper, a sun visor for solarized, a beanie with a pom for nord, a bow tie for high-contrast; colorblind leaves Bolt plain. Every costume takes at most three colours, all from the theme's own palette, keeps the antenna light clear (each hat has a notch around it), and never uses Bolt's own pink or blush. tests/costume.test.ts holds every one of those rules for every theme and pose.
For six seconds after /wear <theme> (and after /wear auto or /wear season resolves to a theme) a band above the prompt shows the switch: one row filled with the theme's background, carrying its mark, wearing <theme>, its five icons, and Bolt in the theme's costume at the right when the band is 60 columns or wider. It draws under any other mod's band, never while a survey holds the band, and goes away by itself. /wear default raises none.
high-contrast: every colour pair wardrobe paints is at least 7:1 (WCAG AAA).colorblind: added lines are blue and removed lines orange, never red and green; tool icons differ by shape (triangle, disc, diamond, square, cross, bars).tests/themes.test.ts checks every pair of every theme.Every setting has a default, so the notice at install about options not yet set is expected. Set these in /config. A time wardrobe cannot read falls back to its default, and /wear names it.
| Setting | Default | Meaning |
|---|---|---|
dayTheme | paper | What /wear auto wears by day. |
nightTheme | tokyo-night | What /wear auto wears by night. |
dayStarts | 07:00 | When the day starts, 24-hour local time. |
nightStarts | 19:00 | When the night starts. A day that crosses midnight (20:00 to 06:00) works. |
/wear default can put yours back and can tell when you changed it since./config theme row) at session start, before a match and before a restore; it also notices when you change that setting./wear <look> --match, /wear match and the match theme button set it, and /wear default sets it back to the one you had. No files, no processes, no network, no model calls. It never refuses or changes a tool call: its tool hook only notes when a call starts and ends.It changes: tool rows and folded groups, diff cards and small shell output, the spinner's word and suffix, the turn line, the switch band, your prompt rows, Claude's reply rows and command output rows (a stripe, and the look's background where it may paint), the wearing <look> label in the prompt footer, the /wear gallery, and, only when you ask with --match or the match theme button, Claude Code's own theme: in the terminal the input box border, your message background, the text and the diff colours then follow the look.
It cannot change: the desktop app's window frame, sidebar, input box, page background and fonts; Claude Code's own text colours inside the rows, which follow /theme unless you match it; the spinner's word, which keeps Claude Code's own colour; permission dialogs, errors, diffs over the size limits and long shell output, which Claude Code draws itself; the terminal emulator's background and font; VS Code and the mobile app; rows already printed on the terminal's main screen; and a light look's panels on a dark app, which stay stripe-only until the theme is matched.
/theme. Wardrobe styles around them (the stripe, the chips, the band, the gallery); pick a matching /theme for the same reading everywhere./wear./wear says so when one is attached.claude -p run), /wear writes its words and the list to the log instead.This mod was built from prompts/wardrobe.md.
More mods: the full catalogue.
hooks/register.tsx 483 lines1// Wardrobe: restyles what a mod may draw (tool rows, tool results, diff cards,
2// the spinner, the turn-duration line), switchable live with /wear.
3//
4// Display only: it reads and writes its own $.store and nothing else; no files,
5// no processes, no network. Every hook that draws falls back to Claude Code's
6// own drawing when it has nothing to add, and when it fails.
7
8import { atom, read } from 'claude-code'
9import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
10
11import type { WardrobeApp, WardrobeLook } from '../types'
12import { clean, diffCard, shellCard } from './content'
13import { describeLook, diffTree, dressedBolt, groupRow, listText, listTree, messageRow, shellTree, spinnerRow, switchBand, toolRow, turnRow, wearingLabel } from './draw'
14import { AUTO, CHOICES, DEFAULT, parseWear, pinsOf, resolveLook, sameLook, type Settings, settingsFrom } from './look'
15import { pickVerb, themeById, THEMES, type Theme } from './themes'
16
17const LOOK = { plugin: 'wardrobe', key: 'look' } as const
18const TIMING = { plugin: 'wardrobe', key: 'timing' } as const
19const SWITCH = { plugin: 'wardrobe', key: 'switch' } as const
20const VERB = { plugin: 'wardrobe', key: 'verb' } as const
21const APP = { plugin: 'wardrobe', key: 'app' } as const
22
23const look = atom(LOOK, { theme: DEFAULT, choice: DEFAULT, scope: 'global', phase: null, root: '' })
24/** The last `/wear` that put a theme on, for the switch band; null before the first. */
25const switched = atom(SWITCH, null)
26/** The verb this turn wears, picked at turn start from the theme worn then; null until a turn starts. */
27const verb = atom(VERB, null)
28/** Claude Code's own theme tone, read from its theme setting; `unknown` for a custom theme or where the row cannot be read. */
29const app = atom(APP, { tone: 'unknown' })
30
31/** How often the worn theme is re-resolved: auto mode's switch, the season's, a choice made in another session. */
32const TICK_MS = 60_000
33/** How long the switch band stays above the prompt after `/wear`. */
34const BAND_MS = 6_000
35/** A band narrower than this has no room for Bolt beside the words. */
36const BAND_BOLT_MIN_COLUMNS = 60
37
38/** The pane `/wear` lists the themes in. */
39const PANE = 'wardrobe'
40
41type Dollar = Pick<EngineInterface, 'state' | 'store' | 'session' | 'clock' | 'ui' | 'config'>
42
43/**
44 * Where wardrobe draws in place of a component (a result card, the turn line),
45 * it first asks what lies beneath: when another mod drew its own
46 * tree there, that tree stands and wardrobe steps aside.
47 */
48const drewOwn = (below: RenderElement) => below.type !== 'engine'
49
50/** Works out what to wear now and publishes it; every drawing that reads it redraws. */
51async function refresh($: Dollar, settings: Settings): Promise<WardrobeLook> {
52 const root = await $.session.root()
53 const global = await $.store.get('global')
54 const pins = await $.store.get('pins')
55 const now = await $.clock.now()
56 const fresh = resolveLook({ root, global, pins, now, settings })
57 // Compared field by field (state crosses as JSON, so identity never matches):
58 // an unchanged look writes nothing and redraws nothing.
59 const current = await $.state.get(LOOK)
60 if (!sameLook(current.value, fresh)) await $.state.set(LOOK, fresh)
61 return fresh
62}
63
64/**
65 * `/wear <choice>`: changes what this project shows (its pin if it has one,
66 * else the global choice). A theme put on (by name, or by `auto` or `season`
67 * resolving to one) raises the switch band, which one timer takes down.
68 */
69async function wear($: Dollar, settings: Settings, choice: string): Promise<string> {
70 const root = await $.session.root()
71 const pins = pinsOf(await $.store.get('pins'))
72 if (Object.hasOwn(pins, root)) {
73 pins[root] = choice
74 await $.store.set('pins', pins)
75 } else {
76 await $.store.set('global', choice)
77 }
78 const look = await refresh($, settings)
79 if (themeById(look.theme)) {
80 await $.state.set(SWITCH, { theme: look.theme, at: await $.clock.now() })
81 $.clock.after(BAND_MS, () => $.ui.invalidate('ui.render'))
82 }
83 return describeLook(look, settings) + (await elsewhere($, look))
84}
85
86/** The light-look line for what is worn now, where it cannot paint; empty otherwise. */
87async function lightNoteNow($: Dollar): Promise<string> {
88 const worn = themeById((await read($, look)).theme)
89 return worn ? lightNote(worn, (await read($, app)).tone) : ''
90}
91
92/** The tone of a Claude Code theme value: `dark`, `dark-ansi`, `light-daltonized`, or a plugin's `custom:...` (unknown). */
93/** The id Claude Code gives a theme file wardrobe ships: `custom:<plugin>:<slug>`, one per look. */
94const ownThemeId = (id: string) => `custom:wardrobe:${id}`
95
96function toneOf(value: unknown): WardrobeApp['tone'] {
97 if (typeof value !== 'string') return 'unknown'
98 if (value.startsWith('light')) return 'light'
99 if (value.startsWith('dark')) return 'dark'
100 const own = THEMES.find(t => ownThemeId(t.id) === value)
101 return own ? own.tone : 'unknown'
102}
103
104/** Reads Claude Code's theme tone into state; a row that cannot be read leaves it unknown. */
105async function readAppTone($: Pick<EngineInterface, 'config' | 'state'>): Promise<void> {
106 const row = await $.config.list().then(rows => rows.find(r => r.key === 'theme')).catch(() => undefined)
107 await $.state.set(APP, { tone: toneOf(row?.value) })
108}
109
110/**
111 * Whether a look may paint its background on this surface: a dark look always;
112 * a light look only on a terminal whose Claude Code theme is light, since light
113 * panels under the app's light text are unreadable, and the desktop's own theme
114 * cannot be read. Elsewhere the look keeps its stripe and colours, no fill.
115 */
116function fills(theme: Theme, surface: string, tone: WardrobeApp['tone']): boolean {
117 return theme.tone === 'dark' || (surface === 'terminal' && tone === 'light')
118}
119
120/** The one line a light look gets where it cannot paint: what it is for, and how to match the app to it. */
121function lightNote(theme: Theme, tone: WardrobeApp['tone']): string {
122 return theme.tone === 'light' && tone !== 'light' ? ` ${theme.id} is made for a light theme; /wear ${theme.id} --match sets Claude Code's theme to match.` : ''
123}
124
125/** The store key holding the theme Claude Code had before a match, so `/wear default` can put it back. */
126const OWN_THEME = 'ownTheme'
127/** The store key holding the theme wardrobe last set, so a restore can tell when the person changed it since. */
128const MATCHED_THEME = 'matchedTheme'
129
130/** The built-in theme nearest a look: its tone, keeping the person's variant (daltonized, ansi) where that option exists. */
131function nearestTheme(tone: 'dark' | 'light', current: string, options: readonly string[]): string {
132 const suffix = current.startsWith('dark') ? current.slice(4) : current.startsWith('light') ? current.slice(5) : ''
133 const kept = tone + suffix
134 return options.includes(kept) ? kept : tone
135}
136
137/**
138 * `/wear <look> --match` and the gallery's `match theme` button: sets Claude Code's
139 * own `theme` to the built-in nearest the look, as the person could in /config,
140 * and remembers the theme they had so `/wear default` puts it back. Only ever on
141 * their word; a refusal (another plugin's hook, a locked row) is reported, not retried.
142 */
143async function matchTheme($: Dollar, settings: Settings): Promise<string> {
144 const look = await refresh($, settings)
145 const theme = themeById(look.theme)
146 if (!theme) return 'No look is worn, so there is no theme to match. Wear one first: /wear <theme> --match.'
147 const row = (await $.config.list()).find(r => r.key === 'theme')
148 if (!row || typeof row.value !== 'string') return "Claude Code's theme setting was not found; nothing changed."
149 const current = row.value
150 // The look's own theme file when Claude Code lists it (the input box border, the message
151 // background and the diff colours then follow the look); else the nearest built-in.
152 const options = row.options ?? []
153 const target = options.includes(ownThemeId(theme.id)) ? ownThemeId(theme.id) : nearestTheme(theme.tone, current, options)
154 if (target === current) return `Claude Code's theme is already ${current}.`
155 if (row.isLocked) return `Claude Code's theme stays ${current}: the setting is locked.`
156 const own = await $.store.get(OWN_THEME)
157 if (typeof own !== 'string') await $.store.set(OWN_THEME, current)
158 const out = await $.config.set({ key: 'theme', value: target })
159 if (out.deny !== undefined) {
160 if (typeof own !== 'string') await $.store.delete(OWN_THEME)
161 return `Claude Code's theme stays ${current}: ${clean(out.deny)}`
162 }
163 await $.store.set(MATCHED_THEME, target)
164 await $.state.set(APP, { tone: toneOf(target) })
165 // On the desktop app the setting changes the terminal's theme; the app keeps its own look.
166 const attached: readonly string[] = await $.session.surfaces().catch(() => [])
167 const desktopNote = attached.includes('desktop') ? " The desktop app keeps its own look; this changes the terminal's theme." : ''
168 return `Claude Code's theme set to ${target} to match ${theme.id} (was ${current}). /wear default puts it back.${desktopNote}`
169}
170
171/**
172 * `/wear default` after a match: Claude Code's theme goes back to the one the
173 * person had, unless the theme was changed since the match (by them in another
174 * session, or by hand): then their later choice stands and the memory is dropped.
175 */
176async function restoreTheme($: Dollar): Promise<string> {
177 const own = await $.store.get(OWN_THEME)
178 if (typeof own !== 'string') return ''
179 const matched = await $.store.get(MATCHED_THEME)
180 const row = (await $.config.list().catch(() => [])).find(r => r.key === 'theme')
181 const current = typeof row?.value === 'string' ? row.value : undefined
182 if (typeof matched === 'string' && current !== undefined && current !== matched) {
183 await forgetMatch($)
184 return ` Claude Code's theme was changed since the match, so it stays ${clean(current)}.`
185 }
186 const out = await $.config.set({ key: 'theme', value: own })
187 if (out.deny !== undefined) return ` Claude Code's theme stays as it is: ${clean(out.deny)} Run /wear default again to retry.`
188 await forgetMatch($)
189 await $.state.set(APP, { tone: toneOf(own) })
190 return ` Claude Code's theme is back to ${own}.`
191}
192
193/** Forgets the matched theme and the one before it: nothing is left for `/wear default` to restore. */
194async function forgetMatch($: Pick<EngineInterface, 'store'>): Promise<void> {
195 await $.store.delete(OWN_THEME)
196 await $.store.delete(MATCHED_THEME)
197}
198
199/** Names the attached surfaces wardrobe does not restyle, so a theme never seems to fail there. */
200async function elsewhere($: Pick<EngineInterface, 'session'>, look: WardrobeLook): Promise<string> {
201 if (look.choice === DEFAULT) return ''
202 const attached = await $.session.surfaces().catch(() => [])
203 const others = attached.filter(surface => surface !== 'terminal' && surface !== 'desktop')
204 return others.length === 0 ? '' : ` On ${others.join(' and ')}, Claude Code draws its own look.`
205}
206
207/** `/wear pin`: this project keeps what it wears now, whatever the global choice becomes. */
208async function pin($: Dollar, settings: Settings): Promise<string> {
209 const now = await refresh($, settings)
210 const pins = pinsOf(await $.store.get('pins'))
211 pins[now.root] = now.choice
212 await $.store.set('pins', pins)
213 const after = await refresh($, settings)
214 const what = after.choice === DEFAULT ? "Claude Code's own look" : after.choice
215 return `Pinned ${what} to ${clean(after.root)}. Other projects keep the global choice.`
216}
217
218/** `/wear unpin`: this project follows the global choice again. */
219async function unpin($: Dollar, settings: Settings): Promise<string> {
220 const root = await $.session.root()
221 const pins = pinsOf(await $.store.get('pins'))
222 if (!Object.hasOwn(pins, root)) return `${clean(root)} has no pin. ${describeLook(await refresh($, settings), settings)}`
223 delete pins[root]
224 await $.store.set('pins', pins)
225 return `Unpinned ${clean(root)}. ${describeLook(await refresh($, settings), settings)}`
226}
227
228// What /wear says is for the person, never the model: a command's `text` is a
229// row the model reads, so /wear answers `{}` and shows its words itself.
230
231/** A line for the person: a toast, or a log line where nothing draws one (a `-p` run). */
232async function say($: Pick<EngineInterface, 'session' | 'ui'>, text: string, timeoutMs = 4000): Promise<void> {
233 const surfaces = await $.session.surfaces().catch(() => [])
234 if (surfaces.length === 0) $.ui.log(text)
235 else $.ui.toast(text, { timeoutMs })
236}
237
238/** The theme list: a pane in each theme's own colours, or the text where no pane is drawn. */
239async function showList($: Pick<EngineInterface, 'session' | 'ui'>, text: string): Promise<void> {
240 const surfaces = await $.session.surfaces().catch(() => [])
241 const opened = surfaces.length === 0 ? undefined : await $.ui.open({ id: PANE, title: 'Wardrobe' }).catch(() => undefined)
242 if (opened?.isPlaced !== true) $.ui.log(text)
243}
244
245export const register: Register = (on, options) => {
246 const { settings, problems } = settingsFrom(options)
247 // Bad auto settings are named where they matter: the list and /wear auto.
248 const autoNote = (choice: string) => (choice === AUTO && problems.length > 0 ? ` Settings: ${problems.join('; ')}.` : '')
249 let ticker: Timer | undefined
250
251 on('session.start', async ($, e, next) => {
252 // Each step on its own: a failure in one never costs the others, or the session.
253 await $.command
254 .register({
255 name: 'wear',
256 description: 'Restyle Claude Code: list themes, or wear one, auto, pin or unpin',
257 argumentHint: '[theme | auto | pin | unpin]',
258 immediate: true,
259 })
260 .catch(() => undefined)
261 // A store or clock that cannot answer leaves Claude Code's own look on.
262 await refresh($, settings).catch(() => undefined)
263 await readAppTone($).catch(() => undefined)
264 ticker?.cancel()
265 ticker = $.clock.every(TICK_MS, () => {
266 refresh($, settings).catch(() => undefined)
267 })
268 return next(e)
269 })
270
271 // Answers its own command; it only ever changes wardrobe's own store, and it
272 // answers `{}`: nothing it says is added to what the model reads.
273 on('command.run', { command: 'wear' }, async ($, e) => {
274 const command = parseWear(e.args)
275 if (command.kind === 'list') {
276 await showList($, listText(await refresh($, settings), settings, problems, await $.clock.now()))
277 } else if (command.kind === 'unknown') {
278 await say($, `No theme called "${clean(command.word)}". Try one of: ${CHOICES.join(', ')}; or pin, unpin.`, 8000)
279 } else if (command.kind === 'wear') {
280 let text = (await wear($, settings, command.choice)) + autoNote(command.choice)
281 if (command.choice === DEFAULT) text += await restoreTheme($)
282 else if (command.match) text += ' ' + (await matchTheme($, settings))
283 else text += await lightNoteNow($)
284 await say($, text)
285 } else if (command.kind === 'match') {
286 await say($, await matchTheme($, settings), 6000)
287 } else {
288 await say($, command.kind === 'pin' ? await pin($, settings) : await unpin($, settings))
289 }
290 return {}
291 }).catch($ => {
292 $.ui.toast('/wear could not finish. Run /wear to see what is worn.')
293 return {}
294 })
295
296 // Why a display mod hooks tool.call: it is the only place a call's start and
297 // end can be timed, for the timing on its row. It only watches: `e` goes on
298 // unchanged, the result comes back unchanged, it never refuses, and if it
299 // fails the call goes on untouched (the .catch passes, it does not deny).
300 on('tool.call', async ($, e, next) => {
301 if (!e.tool_use_id) return next(e)
302 const ref = { ...TIMING, id: e.tool_use_id }
303 const startedAt = await $.clock.now()
304 await $.state.set(ref, { startedAt, ms: null })
305 const ran = await next(e)
306 await $.state.set(ref, { startedAt, ms: Math.max(0, (await $.clock.now()) - startedAt) })
307 return ran
308 }).catch(($, e, next) => next(e))
309
310 // Tool rows: the theme's icon and a status/timing chip around the engine's own row.
311 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
312 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
313 const theme = themeById((await read($, look)).theme)
314 if (!theme) return next(e)
315 const timing = await read($, { ...TIMING, id: e.props.tool_use_id })
316 return toolRow($.ui.resolve(e), theme, e.props, timing, await next(e), e.viewport?.columns)
317 }).catch(($, e, next) => next(e))
318
319 // A folded run of reads and searches: one icon per call before the engine's count line.
320 // Unfolded, each call is its own ToolUse row and is restyled there.
321 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
322 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
323 if (e.props.isExpanded || e.props.calls.length === 0) return next(e)
324 const theme = themeById((await read($, look)).theme)
325 if (!theme) return next(e)
326 return groupRow($.ui.resolve(e), theme, e.props.calls, await next(e))
327 }).catch(($, e, next) => next(e))
328
329 // Results: diff cards and small plain shell output in the theme's colours;
330 // anything else (errors, big or unusual output) the engine draws.
331 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
332 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
333 if (e.props.isErrored) return next(e)
334 const worn = await read($, look)
335 const theme = themeById(worn.theme)
336 if (!theme) return next(e)
337 const diff = diffCard(e.props.tool, e.props.output)
338 const shell = !diff && e.props.tool === 'Bash' ? shellCard(e.props.output) : null
339 if (!diff && !shell) return next(e)
340 const below = await next(e)
341 if (drewOwn(below)) return below
342 if (diff) return diffTree($.ui.resolve(e), theme, diff, worn.root)
343 return shell ? shellTree($.ui.resolve(e), theme, shell) : below
344 }).catch(($, e, next) => next(e))
345
346 // Transcript rows (transcript rows): the person's prompt, each reply block and a
347 // command's output drawn on the look's background behind a stripe. The engine's row
348 // stays its own (a rewrite of its props is never made); only the look is read,
349 // never the clock, so the desktop has nothing to redraw mid-turn.
350 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
351 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
352 const theme = themeById((await read($, look)).theme)
353 if (!theme) return next(e)
354 return messageRow($.ui.resolve(e), theme, 'UserMessage', await next(e), { surface: e.surface, fill: fills(theme, e.surface, (await read($, app)).tone) })
355 }).catch(($, e, next) => next(e))
356 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
357 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
358 const theme = themeById((await read($, look)).theme)
359 if (!theme) return next(e)
360 return messageRow($.ui.resolve(e), theme, 'AssistantMessage', await next(e), { surface: e.surface, fill: fills(theme, e.surface, (await read($, app)).tone) })
361 }).catch(($, e, next) => next(e))
362 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
363 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
364 const theme = themeById((await read($, look)).theme)
365 if (!theme) return next(e)
366 return messageRow($.ui.resolve(e), theme, 'CommandOutput', await next(e), { surface: e.surface, fill: fills(theme, e.surface, (await read($, app)).tone) })
367 }).catch(($, e, next) => next(e))
368
369 // Claude Code's theme changed in /config (another plugin's set reaches here too; wardrobe's
370 // own calls skip its hooks and update the tone themselves): re-read the tone once it is written.
371 on('config.set', { key: 'theme' }, async ($, e, next) => {
372 const out = await next(e)
373 if (out.deny === undefined) {
374 await $.state.set(APP, { tone: toneOf(out.value) })
375 // A theme the person (or another plugin) chose after a match stands: /wear default will not
376 // undo it. Wardrobe's own writes are not that (the engine keeps them from its hooks; the test
377 // kit hands them over, so the origin is checked too).
378 const isOwn = e.origin.kind === 'plugin' && e.origin.name === 'wardrobe'
379 if (!isOwn && typeof (await $.store.get(OWN_THEME)) === 'string') await forgetMatch($)
380 }
381 return out
382 }).catch(($, e, next) => next(e))
383
384 // One verb per turn for the spinner and the closing line: picked when the
385 // turn starts, from the theme worn then, and held until the next turn starts.
386 on('turn.start', async ($, e, next) => {
387 const theme = themeById((await read($, look)).theme)
388 const pick = theme ? pickVerb(theme.id, e.turnId) : null
389 await $.state.set(VERB, pick ? { turnId: e.turnId, present: pick[0], past: pick[1] } : null)
390 return next(e)
391 }).catch(($, e, next) => next(e))
392
393 // The prompt footer: `wearing <theme>` after the engine's own mode labels, as
394 // long as a theme is worn, so the look stays named after the switch band goes.
395 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
396 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
397 const theme = themeById((await read($, look)).theme)
398 if (!theme) return next(e)
399 return wearingLabel($.ui.resolve(e), theme, e.props.modes.length > 0, await next(e))
400 }).catch(($, e, next) => next(e))
401
402 // The spinner: the theme's suffix after the word; on the terminal, a chip
403 // naming what the turn is doing. Elapsed time and tokens stay the engine's,
404 // and so does any status message of its own ("Running tests..."): while one
405 // shows, its text is left exactly as the engine wrote it.
406 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
407 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
408 const theme = themeById((await read($, look)).theme)
409 if (!theme) return next(e)
410 // The turn's verb replaces the engine's word on the terminal, and on the desktop
411 // only while the row says nothing of its own ("Working"); a status message of
412 // the engine's, and a desktop step ("Creating notes.md"), are left as written.
413 const worn = await read($, verb)
414 const lends = worn !== null && (e.surface === 'terminal' || e.props.word === 'Working')
415 const engine = await next(e.props.message === null ? { ...e, props: { ...e.props, word: lends ? worn.present : e.props.word, suffix: theme.spinnerSuffix } } : e)
416 return e.surface === 'terminal' ? spinnerRow($.ui.resolve(e), theme, e.props.mode, engine, e.viewport?.columns) : engine
417 }).catch(($, e, next) => next(e))
418
419 // The line closing a turn; the engine raises it on the terminal alone.
420 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
421 if (e.surface !== 'terminal') return next(e)
422 const { word, durationMs } = e.props
423 if (typeof word !== 'string' || !Number.isFinite(durationMs) || durationMs < 0) return next(e)
424 const theme = themeById((await read($, look)).theme)
425 if (!theme) return next(e)
426 const below = await next(e)
427 const worn = await read($, verb)
428 return drewOwn(below) ? below : turnRow($.ui.resolve(e), theme, worn ? worn.past : word, durationMs)
429 }).catch(($, e, next) => next(e))
430
431 // The switch band: for a few seconds after /wear, one themed row above the
432 // prompt with Bolt in the theme's costume. What is beneath (other mods'
433 // bands, the engine's own) draws first and is never under a sized Box; a
434 // survey holding the band is left alone. Nothing is drawn when no switch
435 // happened, and the one timer `wear` set takes the band down.
436 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
437 if (e.props.hasSurvey) return next(e)
438 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
439 const last = await read($, switched)
440 const theme = last === null ? undefined : themeById(last.theme)
441 if (last === null || !theme || (await $.clock.now()) - last.at >= BAND_MS) return next(e)
442 const below = await next(e)
443 const { Box } = $.ui.resolve(e)
444 const columns = e.props.bodyColumns
445 const bolt = columns >= BAND_BOLT_MIN_COLUMNS ? dressedBolt($.ui.resolve(e), e.surface, theme, 'tiny', 'wardrobe-bolt') : null
446 return (
447 <Box flexDirection="column">
448 {below}
449 {switchBand($.ui.resolve(e), theme, columns, bolt, fills(theme, e.surface, (await read($, app)).tone))}
450 </Box>
451 )
452 }).catch(($, e, next) => next(e))
453
454 // The `/wear` gallery: a card per theme in its own colours with Bolt in its
455 // costume, the worn one marked, a `wear` button on each.
456 // Box, Text and Button, but for Bolt's Raster on the terminal.
457 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
458 const tone = (await read($, app)).tone
459 const onWear = (choice: string) => {
460 wear($, settings, choice)
461 .then(async text => say($, text + autoNote(choice) + (await lightNoteNow($))))
462 .catch(() => undefined)
463 .finally(() => $.ui.invalidate('ui.render'))
464 }
465 return listTree($.ui.resolve(e), {
466 surface: e.surface,
467 look: await read($, look),
468 settings,
469 problems,
470 columns: e.props.bodyColumns,
471 now: await $.clock.now(),
472 onWear,
473 fills: (theme: Theme) => fills(theme, e.surface, tone),
474 onMatch: (choice: string) => {
475 wear($, settings, choice)
476 .then(async text => say($, text + autoNote(choice) + ' ' + (await matchTheme($, settings)), 6000))
477 .catch(() => undefined)
478 .finally(() => $.ui.invalidate('ui.render'))
479 },
480 })
481 }).catch(($, e, next) => next(e))
482}
483hooks/content.ts 144 lines1// Pure readers of tool results: they decide whether wardrobe can draw a result
2// itself, and return null whenever it cannot, so the engine draws its own.
3
4export type DiffLine = {
5 kind: 'add' | 'remove' | 'context' | 'gap'
6 text: string
7 /** The line's number in the new file (old file for a removal); null for a gap. */
8 no: number | null
9}
10
11export type DiffCard = {
12 path: string
13 verb: 'Updated' | 'Wrote'
14 added: number
15 removed: number
16 userModified: boolean
17 lines: DiffLine[]
18}
19
20/** Past this many lines, or characters, the engine's own card (which can fold and expand) draws instead. */
21export const DIFF_LINE_CAP = 120
22export const DIFF_CHAR_CAP = 8000
23
24/** A shell result is drawn by wardrobe only while it is this small. */
25export const SHELL_LINE_CAP = 8
26export const SHELL_CHAR_CAP = 1500
27
28const isRecord = (value: unknown): value is Record<string, unknown> =>
29 typeof value === 'object' && value !== null && !Array.isArray(value)
30
31// Untrusted text (file contents, shell output, paths, typed words) reaches a
32// drawn tree only through clean(): the engine refuses a whole tree holding a
33// C0 or C1 control character, so one stray byte would cost the drawing.
34
35// ANSI escape sequences, 7-bit and 8-bit: CSI (`ESC [`, U+009B), OSC (`ESC ]`,
36// U+009D) up to its terminator, and the two-character escapes (`ESC 7`, `ESC c`).
37const ANSI =
38 /\u001b\[[0-?]*[ -/]*[@-~]|\u009b[0-?]*[ -/]*[@-~]|(?:\u001b\]|\u009d)[^\u0007\u001b\u009c]*(?:\u0007|\u001b\\|\u009c)|\u001b[ -/]*[0-~]/g
39
40/** Every C0 control but tab and newline, DEL, and every C1 control (U+0080 to U+009F). */
41const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/
42
43/** Text without its ANSI escape sequences. */
44export function stripAnsi(text: string): string {
45 return text.replace(ANSI, '')
46}
47
48/** Whether text still holds a control character a Text cannot take (tab and newline aside). */
49export function hasControl(text: string): boolean {
50 return CONTROL.test(text)
51}
52
53/** One drawable line: ANSI sequences gone, tabs as spaces, any other control character as U+FFFD. */
54export function clean(text: string): string {
55 return stripAnsi(text)
56 .replace(/\t/g, ' ')
57 .replace(/\r/g, '')
58 .replace(/[\u0000-\u001f\u007f-\u009f]/g, '�')
59}
60
61/**
62 * An edit's result (Edit, Write over a file, any tool answering `filePath` and
63 * `structuredPatch`) as a diff card; null for anything else or anything odd.
64 */
65export function diffCard(tool: string, output: unknown): DiffCard | null {
66 if (!isRecord(output)) return null
67 const { filePath, structuredPatch } = output
68 if (typeof filePath !== 'string' || !Array.isArray(structuredPatch) || structuredPatch.length === 0) return null
69 if (output.staged === true) return null // held for review: the engine says so in its own words
70 const lines: DiffLine[] = []
71 let added = 0
72 let removed = 0
73 for (const [index, hunk] of structuredPatch.entries()) {
74 if (!isRecord(hunk) || !Array.isArray(hunk.lines)) return null
75 if (typeof hunk.oldStart !== 'number' || typeof hunk.newStart !== 'number') return null
76 if (index > 0) lines.push({ kind: 'gap', text: '...', no: null })
77 let oldNo = hunk.oldStart
78 let newNo = hunk.newStart
79 for (const raw of hunk.lines) {
80 if (typeof raw !== 'string') return null
81 if (raw.includes('\u0000')) return null // binary content: the engine's to draw
82 const marker = raw.slice(0, 1)
83 const text = clean(raw.slice(1))
84 if (marker === '+') {
85 lines.push({ kind: 'add', text, no: newNo++ })
86 added++
87 } else if (marker === '-') {
88 lines.push({ kind: 'remove', text, no: oldNo++ })
89 removed++
90 } else if (marker === ' ' || marker === '') {
91 lines.push({ kind: 'context', text, no: newNo++ })
92 oldNo++
93 } else if (marker !== '\\') {
94 return null // not a unified-diff line: leave it to the engine
95 }
96 }
97 }
98 if (lines.length > DIFF_LINE_CAP || added + removed === 0) return null
99 if (lines.reduce((n, line) => n + line.text.length, 0) > DIFF_CHAR_CAP) return null
100 return {
101 path: clean(filePath),
102 verb: tool === 'Write' ? 'Wrote' : 'Updated',
103 added,
104 removed,
105 userModified: output.userModified === true,
106 lines,
107 }
108}
109
110// The Bash result fields a plain run carries; any other field set means the
111// engine has something to say (a background task, a timeout, a saved file).
112const PLAIN_SHELL_FIELDS = new Set(['stdout', 'stderr', 'interrupted', 'isImage', 'noOutputExpected', 'dangerouslyDisableSandbox'])
113
114/** A small, plain shell result as lines; null when the engine should draw it. */
115export function shellCard(output: unknown): { stdout: string[]; stderr: string[] } | null {
116 if (!isRecord(output)) return null
117 const { stdout, stderr } = output
118 if (typeof stdout !== 'string' || typeof stderr !== 'string') return null
119 if (output.interrupted === true || output.isImage === true) return null
120 for (const [key, value] of Object.entries(output)) if (value && !PLAIN_SHELL_FIELDS.has(key)) return null
121 // Colour codes are dropped (the theme colours the card); anything else that
122 // controls the terminal (a carriage return, a bell, NUL, DEL, C1) means the
123 // output is not plain text, and the engine draws it.
124 const out = stripAnsi(stdout)
125 const err = stripAnsi(stderr)
126 if (hasControl(out + err)) return null
127 if (out.length + err.length > SHELL_CHAR_CAP) return null
128 const outLines = splitLines(out)
129 const errLines = splitLines(err)
130 if (outLines.length + errLines.length > SHELL_LINE_CAP) return null
131 return { stdout: outLines.map(clean), stderr: errLines.map(clean) }
132}
133
134function splitLines(text: string): string[] {
135 const trimmed = text.replace(/\n$/, '')
136 return trimmed === '' ? [] : trimmed.split('\n')
137}
138
139/** A path shown relative to the project root when it lies under it. */
140export function shortPath(path: string, root: string): string {
141 const base = root.endsWith('/') ? root : `${root}/`
142 return root !== '' && path.startsWith(base) ? path.slice(base.length) : path
143}
144hooks/draw.tsx 514 lines1// The trees wardrobe draws. Each builder takes the surface's element table
2// (Box and Text, which every surface has) and a theme, and returns plain data.
3// Every coloured span is painted on the theme's own background (a chip), so
4// its contrast does not depend on the terminal's.
5
6import type { BoxProps, ButtonProps, ElementConstructor, RasterProps, RenderElement, RenderPropsOf, TextProps, ToolGroupCall } from 'claude-code'
7
8import type { WardrobeLook, WardrobeTiming } from '../types'
9import { BOLT_COLUMNS, boltSprite, cellsSprite } from './bolt-draw'
10import type { BoltSize } from './bolt'
11import type { DiffCard, DiffLine } from './content'
12import { clean, shortPath } from './content'
13import { costumeOf, wearBolt } from './costume'
14import { AUTO, clockText, DEFAULT, formatDuration, formatMs, SEASON, seasonOf, seasonWindow, type Settings } from './look'
15import { KIND_ROLE, kindOf, STATUS_ROLE, THEMES, themeById, type Role, type Status, type Theme } from './themes'
16
17export type Table = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps> }
18
19/** The pane's table: Box and Text, a Button for its `wear` controls, and the terminal's Raster for Bolt. */
20export type PaneTable = Table & { Button: ElementConstructor<ButtonProps>; Raster?: ElementConstructor<RasterProps> }
21
22/** Under this many columns a tool row drops its status and timing chip. */
23const NARROW = 60
24/** Under this many columns the /wear gallery leaves Bolt out; from NARROW on, Bolt is drawn small. */
25const BOLT_MIN_COLUMNS = 40
26
27/** The engine's own result gutter, drawn in the terminal's default colours. */
28const GUTTER = ' ⎿ '
29
30/** The five kinds a theme is shown by: in the switch band and the gallery. */
31const SAMPLE_KINDS = ['shell', 'read', 'edit', 'write', 'search'] as const
32
33function chip(t: Table, theme: Theme, role: Role, text: string): RenderElement {
34 const { Text } = t
35 return (
36 <Text color={theme.palette[role]} backgroundColor={theme.palette.bg}>
37 {text}
38 </Text>
39 )
40}
41
42/** The one-cell stripe opening every tool and group row: the theme's accent, so a row reads as themed before its icon does. */
43function stripe(t: Table, theme: Theme): RenderElement {
44 const { Box, Text } = t
45 return (
46 <Box key="stripe" flexShrink={0}>
47 <Text color={theme.palette.bg} backgroundColor={theme.palette.accent}>
48 {' '}
49 </Text>
50 </Box>
51 )
52}
53
54export function statusOf(call: { isRunning: boolean; isErrored: boolean; isInterrupted: boolean }): Status {
55 if (call.isInterrupted) return 'interrupted'
56 if (call.isErrored) return 'error'
57 return call.isRunning ? 'running' : 'done'
58}
59
60/** A tool row: the stripe, the theme's icon, the engine's own row, then status and time. */
61export function toolRow(
62 t: Table,
63 theme: Theme,
64 props: RenderPropsOf['ToolUse'],
65 timing: WardrobeTiming | undefined,
66 engine: RenderElement,
67 columns: number | undefined,
68): RenderElement {
69 const { Box } = t
70 const kind = kindOf(props.tool)
71 const status = statusOf(props)
72 // A time only for a call that ran to the end: a refused or failed call's
73 // span is mostly the wait at the approval prompt, not the call.
74 const time = status === 'done' && timing?.ms != null ? ` ${formatMs(timing.ms)}` : ''
75 return (
76 <Box flexDirection="row" gap={1}>
77 {stripe(t, theme)}
78 <Box key="icon" flexShrink={0}>
79 {chip(t, theme, KIND_ROLE[kind], ` ${theme.icons[kind]} `)}
80 </Box>
81 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
82 {engine}
83 </Box>
84 {(columns ?? 80) >= NARROW && (
85 <Box key="status" flexShrink={0}>
86 {chip(t, theme, STATUS_ROLE[status], ` ${theme.status[status]}${time} `)}
87 </Box>
88 )}
89 </Box>
90 )
91}
92
93/** A folded group: the stripe, one icon per call (an error in the error colour), then the engine's count line. */
94export function groupRow(t: Table, theme: Theme, calls: ReadonlyArray<ToolGroupCall>, engine: RenderElement): RenderElement {
95 const { Box, Text } = t
96 const shown = calls.slice(0, 12)
97 const more = calls.length - shown.length
98 return (
99 <Box flexDirection="row" gap={1}>
100 {stripe(t, theme)}
101 <Box key="icons" flexShrink={0}>
102 <Text backgroundColor={theme.palette.bg}>
103 {' '}
104 {shown.map(call => {
105 const kind = kindOf(call.tool)
106 const role = call.isErrored ? 'error' : KIND_ROLE[kind]
107 return (
108 <Text color={theme.palette[role]} backgroundColor={theme.palette.bg}>
109 {theme.icons[kind]}
110 </Text>
111 )
112 })}
113 {more > 0 && (
114 <Text color={theme.palette.muted} backgroundColor={theme.palette.bg}>
115 +{more}
116 </Text>
117 )}{' '}
118 </Text>
119 </Box>
120 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
121 {engine}
122 </Box>
123 </Box>
124 )
125}
126
127const LINE_ROLE: Record<DiffLine['kind'], 'add' | 'remove' | 'context'> = {
128 add: 'add',
129 remove: 'remove',
130 context: 'context',
131 gap: 'context',
132}
133
134/** A diff card in the theme's diff colours: a summary chip, then numbered lines. */
135export function diffTree(t: Table, theme: Theme, card: DiffCard, root: string): RenderElement {
136 const { Box, Text } = t
137 const { diff, palette } = theme
138 const width = String(Math.max(0, ...card.lines.map(line => line.no ?? 0))).length
139 const colours = {
140 add: { fg: diff.addFg, bg: diff.addBg, marker: '+' },
141 remove: { fg: diff.removeFg, bg: diff.removeBg, marker: '-' },
142 context: { fg: palette.muted, bg: palette.bg, marker: ' ' },
143 }
144 return (
145 <Box flexDirection="row">
146 <Text>{GUTTER}</Text>
147 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
148 <Box key="summary" flexDirection="row" gap={1}>
149 {chip(t, theme, 'text', ` ${card.verb} ${shortPath(card.path, clean(root))} `)}
150 <Text color={diff.addFg} backgroundColor={diff.addBg}>{` +${card.added} `}</Text>
151 <Text color={diff.removeFg} backgroundColor={diff.removeBg}>{` -${card.removed} `}</Text>
152 {card.userModified && chip(t, theme, 'muted', ' edited by you ')}
153 </Box>
154 <Box key="lines" flexDirection="column">
155 {card.lines.map(line => {
156 const c = colours[LINE_ROLE[line.kind]]
157 const no = line.no === null ? ''.padStart(width) : String(line.no).padStart(width)
158 const body = line.kind === 'gap' ? `${''.padStart(width)} ${line.text}` : `${no} ${c.marker} ${line.text}`
159 return (
160 <Box backgroundColor={c.bg}>
161 <Text color={c.fg} backgroundColor={c.bg}>
162 {body}
163 </Text>
164 </Box>
165 )
166 })}
167 </Box>
168 </Box>
169 </Box>
170 )
171}
172
173/** A small shell result as a card: stdout in the text colour, stderr marked `!`. */
174export function shellTree(t: Table, theme: Theme, card: { stdout: string[]; stderr: string[] }): RenderElement {
175 const { Box, Text } = t
176 const empty = card.stdout.length === 0 && card.stderr.length === 0
177 return (
178 <Box flexDirection="row">
179 <Text>{GUTTER}</Text>
180 <Box key="output" flexDirection="column" flexGrow={1} flexShrink={1} backgroundColor={theme.palette.bg}>
181 {card.stdout.map(line => chip(t, theme, 'text', ` ${line} `))}
182 {card.stderr.map(line => chip(t, theme, 'warn', ` ! ${line} `))}
183 {empty && chip(t, theme, 'muted', ' (no output) ')}
184 </Box>
185 </Box>
186 )
187}
188
189const MODE_LABEL: Record<RenderPropsOf['Spinner']['mode'], string> = {
190 requesting: 'waiting',
191 responding: 'writing',
192 thinking: 'thinking',
193 'tool-input': 'preparing a tool',
194 'tool-use': 'using tools',
195}
196
197/** The engine's spinner line, then a chip naming what the turn is doing; narrow, the line alone. */
198export function spinnerRow(
199 t: Table,
200 theme: Theme,
201 mode: RenderPropsOf['Spinner']['mode'],
202 engine: RenderElement,
203 columns: number | undefined,
204): RenderElement {
205 const { Box } = t
206 if ((columns ?? 80) < NARROW) return engine
207 return (
208 <Box flexDirection="row" gap={1}>
209 {engine}
210 <Box key="mode" flexShrink={0}>
211 {chip(t, theme, 'accent', ` ${theme.mark} ${MODE_LABEL[mode] ?? clean(String(mode))} `)}
212 </Box>
213 </Box>
214 )
215}
216
217/** The line closing a turn: `<mark> Baked for 3s`. */
218export function turnRow(t: Table, theme: Theme, word: string, durationMs: number): RenderElement {
219 const { Box } = t
220 return <Box key="turn">{chip(t, theme, 'muted', ` ${theme.mark} ${clean(word)} for ${formatDuration(durationMs)} `)}</Box>
221}
222
223/** Columns a string takes: every glyph wardrobe draws is single width. */
224const columnsOf = (text: string) => [...text].length
225
226/**
227 * The band shown for a few seconds after `/wear`: one row filled with the
228 * theme's background, its mark, `wearing <name>`, its five icons as chips, and
229 * Bolt in the theme's costume at the right when there is room. The words and
230 * icons are one Text of plain runs, no block glyph among them; Bolt
231 * is its own element, a cell grid (bolt-draw).
232 */
233export function switchBand(t: Table, theme: Theme, columns: number, bolt: RenderElement | null, fill = true): RenderElement {
234 const { Box, Text } = t
235 const { palette } = theme
236 // Where the look may not paint (a light look on a dark app), the band keeps its words and icons on the app's own background.
237 const bg = fill ? palette.bg : undefined
238 const wordColor = fill ? palette.text : palette.accent
239 const mark = ` ${theme.mark} `
240 const words = `wearing ${theme.id} `
241 const icons = SAMPLE_KINDS.map(kind => `${theme.icons[kind]} `)
242 const used = columnsOf(mark) + columnsOf(words) + icons.reduce((n, icon) => n + columnsOf(icon), 0)
243 const room = columns - (bolt === null ? 0 : BOLT_COLUMNS.tiny + 1)
244 const pad = ' '.repeat(Math.max(1, room - used))
245 return (
246 <Box key="wardrobe-band" flexDirection="row" gap={1} flexShrink={0}>
247 <Text backgroundColor={bg}>
248 <Text color={palette.accent} backgroundColor={bg} bold>
249 {mark}
250 </Text>
251 <Text color={wordColor} backgroundColor={bg} bold>
252 {words}
253 </Text>
254 {SAMPLE_KINDS.map((kind, i) => (
255 <Text color={palette[KIND_ROLE[kind]]} backgroundColor={bg}>
256 {icons[i]}
257 </Text>
258 ))}
259 <Text backgroundColor={bg}>{pad}</Text>
260 </Text>
261 {bolt}
262 </Box>
263 )
264}
265
266/** Bolt dressed for `theme` (plain for none), as one element keyed `key`, at `size`. */
267export function dressedBolt(t: Table & { Raster?: ElementConstructor<RasterProps> }, surface: string, theme: Theme | undefined, size: BoltSize, key: string): RenderElement {
268 if (theme === undefined) return boltSprite(t, surface, size === 'small' ? { size: 'small', pose: 'idle' } : { size: 'tiny', pose: 'idle' }, key)
269 return cellsSprite(t, surface, wearBolt(theme, size, 'idle'), key)
270}
271
272const HELP = '/wear <theme> switches now. /wear auto follows the clock, /wear season the calendar. /wear pin keeps this look for this project; /wear unpin lets go.'
273
274/** What is worn, in words, for the gallery header and command replies. */
275export function describeLook(look: WardrobeLook, settings: Settings): string {
276 const where = look.scope === 'project' ? 'in this project (pinned)' : 'everywhere without a pin'
277 if (look.choice === AUTO) {
278 const { dayTheme, nightTheme, dayStarts, nightStarts } = settings
279 return `Wearing auto ${where}: ${dayTheme} from ${clockText(dayStarts)}, ${nightTheme} from ${clockText(nightStarts)}; ${look.theme} now (${look.phase}).`
280 }
281 if (look.choice === SEASON) return `Wearing season ${where}: ${look.theme} now (${seasonWindow(look.theme) ?? 'the rest of the year'}).`
282 if (look.choice === DEFAULT) return `Wearing Claude Code's own look ${where}.`
283 return `Wearing ${look.theme} ${where}.`
284}
285
286/** The sample call every gallery card draws, as a finished Read of 300 ms. */
287export const SAMPLE_CALL: RenderPropsOf['ToolUse'] = {
288 tool_use_id: 'wardrobe-sample',
289 tool: 'Read',
290 input: { file_path: 'src/app.ts' },
291 isRunning: false,
292 isErrored: false,
293 isInterrupted: false,
294}
295export const SAMPLE_TIMING: WardrobeTiming = { startedAt: 0, ms: 300 }
296const SAMPLE_TEXT = 'Read src/app.ts'
297
298/** How big Bolt is drawn in the gallery at this width: small from 60 columns, tiny from 40, none under. */
299export function boltSizeFor(columns: number): BoltSize | null {
300 if (columns >= NARROW) return 'small'
301 return columns >= BOLT_MIN_COLUMNS ? 'tiny' : null
302}
303
304export type Gallery = {
305 surface: string
306 look: WardrobeLook
307 settings: Settings
308 problems: readonly string[]
309 columns: number | undefined
310 /** A `$.clock.now()` reading, for what `season` would wear today. */
311 now: number
312 /** What a card's `wear` button does. */
313 onWear: (choice: string) => void
314 /** What a theme card's `match theme` button does: wear it and set Claude Code's own theme to match. */
315 onMatch: (choice: string) => void
316 /** Whether a look may paint its background here (a light look on a dark app may not). */
317 fills: (theme: Theme) => boolean
318}
319
320const SAMPLE_PROMPT = 'add a /health endpoint'
321const SAMPLE_REPLY = 'Adding it now.'
322
323/** One line of a gallery card's mini session: the look's stripe, then the words on its background where it may paint. */
324function sampleRow(t: Table, theme: Theme, key: string, role: Role, text: string, fill: boolean): RenderElement {
325 const { Box, Text } = t
326 return (
327 <Box key={key} flexDirection="row">
328 <Box flexShrink={0}>
329 <Text color={theme.palette.bg} backgroundColor={theme.palette[role]}>
330 {' '}
331 </Text>
332 </Box>
333 <Text color={fill ? theme.palette.text : undefined} backgroundColor={fill ? theme.palette.bg : undefined}>
334 {' '}
335 {text}
336 {' '}
337 </Text>
338 </Box>
339 )
340}
341
342/**
343 * `/wear` with no argument: a gallery. Every theme is a card: its name and
344 * blurb, a sample tool row drawn exactly as toolRow draws one in that theme,
345 * its diff chips, Bolt in the theme's costume, the worn one marked, and a
346 * `wear` button (no hotkey: the pane can hold the focus). Rows for
347 * Claude Code's own look, `auto` and `season` round it off.
348 */
349export function listTree(t: PaneTable, g: Gallery): RenderElement {
350 const { Box, Text, Button } = t
351 const columns = g.columns ?? 80
352 const size = boltSizeFor(columns)
353 const wearing = (choice: string) => (g.look.choice === choice ? <Text bold>(wearing)</Text> : null)
354 const wearButton = (choice: string) => <Button key={`wear-${choice}`} label="wear" onPress={() => g.onWear(choice)} />
355 const worn = <Text>{describeLook(g.look, g.settings)}</Text>
356 const wornTheme = themeById(g.look.theme)
357 const seasonTheme = seasonOf(g.now)
358
359 /** A row that is not a theme: a name, what it does, the mark and the button. */
360 const plainRow = (choice: string, what: string) => (
361 <Box key={`theme-${choice}`} flexDirection="row" gap={1} marginBottom={1}>
362 <Text bold>{choice}</Text>
363 <Text>{what}</Text>
364 {wearing(choice)}
365 {wearButton(choice)}
366 </Box>
367 )
368
369 // A card is a mini session in the look: the person's prompt, a reply, a tool row with its
370 // diff chips, and the footer label, so the difference shows before choosing. Bolt is an
371 // accent at the end of the head row, never the main surface.
372 const card = (theme: Theme) => {
373 const fill = g.fills(theme)
374 return (
375 <Box key={`theme-${theme.id}`} flexDirection="column" marginBottom={1}>
376 <Box key="head" flexDirection="row" gap={1}>
377 <Text bold>{theme.id}</Text>
378 <Text dimColor>{theme.tone}</Text>
379 {wearing(theme.id)}
380 {wearButton(theme.id)}
381 <Button key={`match-${theme.id}`} label="match theme" onPress={() => g.onMatch(theme.id)} />
382 {size === null ? null : dressedBolt(t, g.surface, theme, 'tiny', `bolt-${theme.id}`)}
383 </Box>
384 <Text>{theme.blurb}</Text>
385 {theme.tone === 'light' ? <Text dimColor>best on a light theme</Text> : null}
386 {sampleRow(t, theme, `you-${theme.id}`, 'accent', `> ${SAMPLE_PROMPT}`, fill)}
387 {sampleRow(t, theme, `reply-${theme.id}`, 'info', SAMPLE_REPLY, fill)}
388 <Box key="sample" flexDirection="row" gap={1}>
389 {toolRow(t, theme, SAMPLE_CALL, SAMPLE_TIMING, <Text>{SAMPLE_TEXT}</Text>, columns)}
390 <Text color={theme.diff.addFg} backgroundColor={theme.diff.addBg}>
391 {' + add '}
392 </Text>
393 <Text color={theme.diff.removeFg} backgroundColor={theme.diff.removeBg}>
394 {' - del '}
395 </Text>
396 </Box>
397 <Text key={`footer-${theme.id}`} color={theme.palette.muted}>
398 {theme.mark} wearing {theme.id}
399 </Text>
400 </Box>
401 )
402 }
403
404 return (
405 <Box flexDirection="column">
406 {size === null ? (
407 worn
408 ) : (
409 <Box key="worn" flexDirection="row" gap={1} marginBottom={1}>
410 {dressedBolt(t, g.surface, wornTheme, 'tiny', 'bolt')}
411 <Box flexDirection="column" flexShrink={1}>
412 {worn}
413 </Box>
414 </Box>
415 )}
416 {plainRow(DEFAULT, "Claude Code's own drawing")}
417 {THEMES.map(card)}
418 {plainRow(AUTO, `${g.settings.dayTheme} from ${clockText(g.settings.dayStarts)}, ${g.settings.nightTheme} from ${clockText(g.settings.nightStarts)}`)}
419 {plainRow(SEASON, `by the calendar: ${seasonTheme} now (${seasonWindow(seasonTheme) ?? 'the rest of the year'})`)}
420 {g.problems.map(problem => (
421 <Text>Setting: {clean(problem)}</Text>
422 ))}
423 <Text>{HELP}</Text>
424 </Box>
425 )
426}
427
428/** The same gallery as text, without the art: what the model reads, and what other surfaces show. */
429export function listText(look: WardrobeLook, settings: Settings, problems: readonly string[], now: number): string {
430 const mark = (choice: string) => (look.choice === choice ? ' (wearing)' : '')
431 const sample = (theme: Theme) =>
432 `${theme.icons.read} ${SAMPLE_TEXT} ${theme.status.done} ${formatMs(SAMPLE_TIMING.ms ?? 0)} +add -del Bolt: ${costumeOf(theme)?.name ?? 'none'}`
433 const season = seasonOf(now)
434 return [
435 describeLook(look, settings),
436 `- ${DEFAULT}: Claude Code's own drawing${mark(DEFAULT)}`,
437 ...THEMES.flatMap(theme => [
438 `- ${theme.id} (${theme.tone}) ${SAMPLE_KINDS.map(kind => theme.icons[kind]).join('')}: ${theme.blurb}${theme.tone === 'light' ? ' (best on a light theme)' : ''}${mark(theme.id)}`,
439 ` ${sample(theme)}`,
440 ]),
441 `- ${AUTO}: ${settings.dayTheme} from ${clockText(settings.dayStarts)}, ${settings.nightTheme} from ${clockText(settings.nightStarts)}${mark(AUTO)}`,
442 `- ${SEASON}: by the calendar: ${season} now (${seasonWindow(season) ?? 'the rest of the year'})${mark(SEASON)}`,
443 ...problems.map(problem => `Setting: ${clean(problem)}`),
444 HELP,
445 ].join('\n')
446}
447
448/** Which transcript rows a look tints, and the palette role of each one's stripe. */
449export type TranscriptRow = 'UserMessage' | 'AssistantMessage' | 'CommandOutput'
450const ROW_ROLE: Record<TranscriptRow, Role> = { UserMessage: 'accent', AssistantMessage: 'info', CommandOutput: 'muted' }
451
452/**
453 * A transcript row (the person's prompt, a reply block, a command's output) in
454 * the look: a one-cell stripe in the row's colour, then the engine's own row on
455 * the theme's background with a cell of padding each side. The engine's node
456 * sits under Boxes with layout-neutral props only, and the
457 * tree reads nothing that changes per tick, so a redraw never changes it
458 *: the whole row is a pure function of the theme.
459 */
460export function messageRow(
461 t: Table,
462 theme: Theme,
463 row: TranscriptRow,
464 engine: RenderElement,
465 opts: { surface: string; fill: boolean },
466): RenderElement {
467 const { Box } = t
468 // The stripe is a one-cell column; a row aligns its children to its full height by default, so it runs beside
469 // every line of the row and its margins, never on the blank line above the text.
470 const stripe = <Box key="stripe" width={1} flexShrink={0} backgroundColor={theme.palette[ROW_ROLE[row]]} />
471 // Both surfaces draw the person's message in a band of their own: a panel around it
472 // reads as a box in a box, so there the stripe alone marks the row.
473 if (row === 'UserMessage') {
474 return (
475 <Box key={`look-${row}`} flexDirection="row" alignItems="stretch">
476 {stripe}
477 <Box key="plain" flexDirection="column" flexGrow={1} flexShrink={1}>
478 {engine}
479 </Box>
480 </Box>
481 )
482 }
483 return (
484 <Box key={`look-${row}`} flexDirection="row" alignItems="stretch">
485 {stripe}
486 {opts.fill ? (
487 <Box key="tint" flexDirection="column" flexGrow={1} flexShrink={1} backgroundColor={theme.palette.bg} paddingX={1}>
488 {engine}
489 </Box>
490 ) : (
491 <Box key="plain" flexDirection="column" flexGrow={1} flexShrink={1} paddingX={1}>
492 {engine}
493 </Box>
494 )}
495 </Box>
496 )
497}
498
499/** The prompt footer's mode labels with `wearing <theme>` after them, quiet, in the theme's muted colour. */
500export function wearingLabel(t: Table, theme: Theme, hasModes: boolean, engine: RenderElement): RenderElement {
501 const { Box, Text } = t
502 return (
503 <Box key="look-modes" flexDirection="row">
504 {engine}
505 <Box key="wearing" flexShrink={0}>
506 <Text color={theme.palette.muted}>
507 {hasModes ? ' · ' : ''}
508 {theme.mark} wearing {theme.id}
509 </Text>
510 </Box>
511 </Box>
512 )
513}
514hooks/look.ts 222 lines1// Pure logic: what /wear means, which theme is worn when, and how times print.
2
3import type { WardrobeLook } from '../types'
4import { THEMES, themeById } from './themes'
5
6/** Claude Code's own drawing: wardrobe restyles nothing. */
7export const DEFAULT = 'default'
8/** A light theme by day and a dark one by night. */
9export const AUTO = 'auto'
10/** The seasonal pack the date calls for (SEASONS). */
11export const SEASON = 'season'
12
13/** Every word `/wear <choice>` accepts as a whole name. */
14export const CHOICES: readonly string[] = [DEFAULT, ...THEMES.map(theme => theme.id), AUTO, SEASON]
15
16const ALIASES: Record<string, string> = {
17 xmas: 'christmas',
18 noel: 'christmas',
19 fall: 'autumn',
20 spooky: 'halloween',
21 hc: 'high-contrast',
22 highcontrast: 'high-contrast',
23 contrast: 'high-contrast',
24 cb: 'colorblind',
25 colourblind: 'colorblind',
26 'colour-blind': 'colorblind',
27 'color-blind': 'colorblind',
28 tokyo: 'tokyo-night',
29 tokyonight: 'tokyo-night',
30 mocha: 'catppuccin',
31 light: 'paper',
32 off: DEFAULT,
33 none: DEFAULT,
34 reset: DEFAULT,
35 plain: DEFAULT,
36}
37
38/** A word as a choice (theme id, `auto` or `default`), or undefined. */
39export function choiceOf(word: string): string | undefined {
40 const w = word.trim().toLowerCase().replace(/[\s_]+/g, '-')
41 if (w === '') return undefined
42 if (CHOICES.includes(w)) return w
43 const alias = ALIASES[w]
44 if (alias !== undefined) return alias
45 // A unique prefix is enough: `drac` is dracula, `c` is ambiguous.
46 const hits = CHOICES.filter(id => id.startsWith(w))
47 return hits.length === 1 ? hits[0] : undefined
48}
49
50/** Whether a stored value is still a choice this version knows. */
51export function isChoice(value: unknown): value is string {
52 return typeof value === 'string' && CHOICES.includes(value)
53}
54
55export type WearCommand =
56 | { kind: 'list' }
57 | { kind: 'wear'; choice: string; match?: true }
58 | { kind: 'match' }
59 | { kind: 'pin' }
60 | { kind: 'unpin' }
61 | { kind: 'unknown'; word: string }
62
63/** `/wear` arguments as one command. */
64export function parseWear(args: string): WearCommand {
65 const parts = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
66 const match = parts.includes('--match')
67 const word = parts.filter(part => part !== '--match').join(' ')
68 if (word === 'match' || (word === '' && match)) return { kind: 'match' }
69 if (word === '' || word === 'list') return { kind: 'list' }
70 if (word === 'pin') return { kind: 'pin' }
71 if (word === 'unpin') return { kind: 'unpin' }
72 const choice = choiceOf(word)
73 return choice === undefined ? { kind: 'unknown', word: args.trim() } : match ? { kind: 'wear', choice, match } : { kind: 'wear', choice }
74}
75
76/** `HH:MM` (24-hour, `7:05` too) as minutes after midnight, or undefined. */
77export function parseClock(text: string): number | undefined {
78 const m = /^\s*(\d{1,2}):(\d{2})\s*$/.exec(text)
79 if (!m) return undefined
80 const hours = Number(m[1])
81 const minutes = Number(m[2])
82 return hours <= 23 && minutes <= 59 ? hours * 60 + minutes : undefined
83}
84
85/** Whether `minute` (after midnight) falls in the day, from `dayStarts` to `nightStarts`. */
86export function isDay(minute: number, dayStarts: number, nightStarts: number): boolean {
87 if (dayStarts === nightStarts) return true // no night at all
88 // A day that crosses midnight (day 20:00, night 06:00) is the wrapped range.
89 return dayStarts < nightStarts
90 ? minute >= dayStarts && minute < nightStarts
91 : minute >= dayStarts || minute < nightStarts
92}
93
94/** Local minutes after midnight for a `$.clock.now()` reading. */
95export function minuteOfDay(ms: number): number {
96 const date = new Date(ms)
97 return date.getHours() * 60 + date.getMinutes()
98}
99
100/**
101 * What `/wear season` wears when, by the local date. The first window that
102 * holds the date wins, so Halloween sits inside autumn; a window may cross the
103 * year's end. The rest of winter (Jan 7 to Feb) has no pack of its own and
104 * wears nord, the frost theme.
105 */
106export const SEASONS: readonly { theme: string; from: readonly [number, number]; to: readonly [number, number]; when: string }[] = [
107 { theme: 'halloween', from: [10, 15], to: [11, 1], when: 'Oct 15 to Nov 1' },
108 { theme: 'christmas', from: [12, 1], to: [1, 6], when: 'Dec 1 to Jan 6' },
109 { theme: 'spring', from: [3, 1], to: [5, 31], when: 'Mar 1 to May 31' },
110 { theme: 'summer', from: [6, 1], to: [8, 31], when: 'Jun 1 to Aug 31' },
111 { theme: 'autumn', from: [9, 1], to: [11, 30], when: 'Sep 1 to Nov 30, Halloween aside' },
112 { theme: 'nord', from: [1, 7], to: [2, 29], when: 'Jan 7 to the end of Feb' },
113]
114
115/** The theme the season calls for at a `$.clock.now()` reading (local date). */
116export function seasonOf(ms: number): string {
117 const date = new Date(ms)
118 const day = (date.getMonth() + 1) * 100 + date.getDate()
119 const key = ([month, dayOfMonth]: readonly [number, number]) => month * 100 + dayOfMonth
120 for (const season of SEASONS) {
121 const from = key(season.from)
122 const to = key(season.to)
123 const inside = from <= to ? day >= from && day <= to : day >= from || day <= to
124 if (inside) return season.theme
125 }
126 return 'nord'
127}
128
129/** The window of the year a theme is the season's, in words, or undefined. */
130export function seasonWindow(theme: string): string | undefined {
131 return SEASONS.find(season => season.theme === theme)?.when
132}
133
134export type Settings = {
135 dayTheme: string
136 nightTheme: string
137 dayStarts: number
138 nightStarts: number
139}
140
141const FALLBACK = { dayTheme: 'paper', nightTheme: 'tokyo-night', dayStarts: '07:00', nightStarts: '19:00' }
142
143/** The userConfig values, checked; anything unusable falls back and is named in `problems`. */
144export function settingsFrom(options: Readonly<Record<string, unknown>>): { settings: Settings; problems: string[] } {
145 const problems: string[] = []
146 const theme = (key: 'dayTheme' | 'nightTheme'): string => {
147 const value = options[key]
148 if (typeof value === 'string' && (value === DEFAULT || themeById(value))) return value
149 problems.push(`${key} "${String(value)}" is not a theme; using ${FALLBACK[key]}`)
150 return FALLBACK[key]
151 }
152 const clock = (key: 'dayStarts' | 'nightStarts'): number => {
153 const value = options[key]
154 const minute = typeof value === 'string' ? parseClock(value) : undefined
155 if (minute !== undefined) return minute
156 problems.push(`${key} "${String(value)}" is not HH:MM; using ${FALLBACK[key]}`)
157 return parseClock(FALLBACK[key]) ?? 0
158 }
159 return {
160 settings: { dayTheme: theme('dayTheme'), nightTheme: theme('nightTheme'), dayStarts: clock('dayStarts'), nightStarts: clock('nightStarts') },
161 problems,
162 }
163}
164
165/**
166 * The project's pin if it has one, else the global choice, else Claude Code's
167 * own look; `auto` and `season` resolved for `now` (a `$.clock.now()` reading).
168 */
169export function resolveLook(input: {
170 root: string
171 global: unknown
172 pins: unknown
173 now: number
174 settings: Settings
175}): WardrobeLook {
176 const pin = pinsOf(input.pins)[input.root]
177 const choice = pin ?? (isChoice(input.global) ? input.global : DEFAULT)
178 const scope = pin === undefined ? 'global' : 'project'
179 if (choice === SEASON) return { theme: seasonOf(input.now), choice, scope, phase: null, root: input.root }
180 if (choice !== AUTO) return { theme: choice, choice, scope, phase: null, root: input.root }
181 const { dayTheme, nightTheme, dayStarts, nightStarts } = input.settings
182 const phase = isDay(minuteOfDay(input.now), dayStarts, nightStarts) ? 'day' : 'night'
183 return { theme: phase === 'day' ? dayTheme : nightTheme, choice, scope, phase, root: input.root }
184}
185
186/** The stored pins, keeping only entries this version understands. */
187export function pinsOf(value: unknown): Record<string, string> {
188 const pins: Record<string, string> = {}
189 if (typeof value !== 'object' || value === null || Array.isArray(value)) return pins
190 for (const [root, choice] of Object.entries(value)) if (isChoice(choice)) pins[root] = choice
191 return pins
192}
193
194export function sameLook(a: WardrobeLook | undefined, b: WardrobeLook): boolean {
195 return a !== undefined && a.theme === b.theme && a.choice === b.choice && a.scope === b.scope && a.phase === b.phase && a.root === b.root
196}
197
198/** `HH:MM` for minutes after midnight. */
199export function clockText(minute: number): string {
200 return `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`
201}
202
203/** A tool call's time: `840ms`, `1.2s`, `14s`, `1m 4s`. */
204export function formatMs(ms: number): string {
205 if (!Number.isFinite(ms) || ms < 0) return ''
206 const rounded = Math.round(ms)
207 if (rounded < 1000) return `${rounded}ms`
208 if (rounded < 9950) return `${(rounded / 1000).toFixed(1)}s`
209 return formatDuration(rounded)
210}
211
212/** A turn's length: `3s`, `1m 4s`, `2m`, `1h 5m`. */
213export function formatDuration(ms: number): string {
214 if (!Number.isFinite(ms) || ms < 0) return ''
215 const seconds = Math.round(ms / 1000)
216 if (seconds < 60) return `${seconds}s`
217 const minutes = Math.floor(seconds / 60)
218 if (minutes < 60) return seconds % 60 === 0 ? `${minutes}m` : `${minutes}m ${seconds % 60}s`
219 const hours = Math.floor(minutes / 60)
220 return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`
221}
222hooks/themes.ts 506 lines1// Themes are plain data: one object per theme. Nothing in this file draws.
2//
3// Every colour a theme paints is painted together with the theme's own
4// background (a "chip"), so the contrast the tests measure is the contrast on
5// screen, whatever the terminal's own background is.
6
7/** What a tool does, for picking its icon. */
8export type Kind =
9 | 'shell'
10 | 'read'
11 | 'edit'
12 | 'write'
13 | 'search'
14 | 'web'
15 | 'agent'
16 | 'todo'
17 | 'mcp'
18 | 'other'
19
20export const KINDS: readonly Kind[] = [
21 'shell',
22 'read',
23 'edit',
24 'write',
25 'search',
26 'web',
27 'agent',
28 'todo',
29 'mcp',
30 'other',
31]
32
33/** Where a tool call stands. Each has its own glyph, so colour is never the only cue. */
34export type Status = 'running' | 'done' | 'error' | 'interrupted'
35
36export const STATUSES: readonly Status[] = ['running', 'done', 'error', 'interrupted']
37
38/** The named colours of a palette; every one is drawn on `bg`. */
39export type Role = 'text' | 'muted' | 'accent' | 'info' | 'ok' | 'warn' | 'error' | 'special'
40
41export const ROLES: readonly Role[] = ['text', 'muted', 'accent', 'info', 'ok', 'warn', 'error', 'special']
42
43export type Theme = {
44 id: string
45 tone: 'dark' | 'light'
46 /** The one-line description `/wear` shows. */
47 blurb: string
48 palette: Record<Role, string> & { bg: string }
49 /** Diff lines: each foreground is drawn on its own background. */
50 diff: { addFg: string; addBg: string; removeFg: string; removeBg: string }
51 icons: Record<Kind, string>
52 status: Record<Status, string>
53 /** The glyph leading the turn-duration line. */
54 mark: string
55 /** Drawn after the spinner's word (the engine's own is one ellipsis). */
56 spinnerSuffix: string
57}
58
59/** Which palette colour each tool kind's icon takes. */
60export const KIND_ROLE: Record<Kind, Role> = {
61 shell: 'accent',
62 read: 'info',
63 edit: 'warn',
64 write: 'ok',
65 search: 'special',
66 web: 'info',
67 agent: 'special',
68 todo: 'muted',
69 mcp: 'accent',
70 other: 'muted',
71}
72
73/** Which palette colour each status takes. */
74export const STATUS_ROLE: Record<Status, Role> = {
75 running: 'warn',
76 done: 'ok',
77 error: 'error',
78 interrupted: 'muted',
79}
80
81// Glyph sets. Every set gives each kind its own glyph; GEO is also a set of
82// different SHAPES (triangle, circle, diamond, square, cross, bars), which is
83// what the colorblind theme relies on. All are single-width text characters,
84// none of them emoji.
85const GEO: Record<Kind, string> = {
86 shell: '▸', // small right triangle
87 read: '●', // circle
88 edit: '◆', // diamond
89 write: '■', // square
90 search: '▲', // up triangle
91 web: '✚', // heavy cross
92 agent: '▼', // down triangle
93 todo: '≡', // three bars
94 mcp: '⊕', // circled plus
95 other: '∴', // three dots
96}
97
98const OUTLINE: Record<Kind, string> = {
99 shell: '›', // single right angle quote
100 read: '○', // white circle
101 edit: '◇', // white diamond
102 write: '□', // white square
103 search: '△', // white up triangle
104 web: '◎', // bullseye
105 agent: '▽', // white down triangle
106 todo: '≡',
107 mcp: '⊙', // circled dot
108 other: '·', // middle dot
109}
110
111const ASCII: Record<Kind, string> = {
112 shell: '$',
113 read: '<',
114 edit: '~',
115 write: '>',
116 search: '?',
117 web: '@',
118 agent: '&',
119 todo: '=',
120 mcp: '%',
121 other: ':',
122}
123
124// The seasonal packs' sets: a solid or an outline set with a season's own
125// glyph for search and the web (an eye and a moon, two snowflakes, a blossom,
126// waves and a sun, a fleuron). Single-width BMP characters, none of them emoji
127// and no stars: a four-point star reads as the AI sparkle (COPY-NOTES).
128const NIGHT: Record<Kind, string> = { ...GEO, search: '◉', web: '☾' } // fisheye (an eye), last-quarter moon
129const FROST: Record<Kind, string> = { ...OUTLINE, search: '❅', web: '❄' } // tight trifoliate snowflake, snowflake
130const BLOOM: Record<Kind, string> = { ...OUTLINE, search: '✿', web: '❀' } // black florette, white florette
131const SHORE: Record<Kind, string> = { ...GEO, search: '≈', web: '☼' } // waves (almost equal to), white sun
132const LEAF: Record<Kind, string> = { ...OUTLINE, search: '✤', web: '❧' } // heavy four-balloon asterisk, rotated floral heart
133
134const MARKS: Record<Status, string> = {
135 running: '…', // ellipsis
136 done: '✓', // check
137 error: '✗', // ballot x
138 interrupted: '⊘', // circled slash
139}
140
141export const THEMES: readonly Theme[] = [
142 {
143 id: 'noir',
144 tone: 'dark',
145 blurb: 'Monochrome ink; status told apart by shape alone',
146 palette: {
147 bg: '#101010',
148 text: '#e8e8e8',
149 muted: '#a8a8a8',
150 accent: '#ffffff',
151 info: '#cfcfcf',
152 ok: '#d8d8d8',
153 warn: '#bdbdbd',
154 error: '#ff8a8a',
155 special: '#e0e0e0',
156 },
157 diff: { addFg: '#ffffff', addBg: '#3a3a3a', removeFg: '#c4c4c4', removeBg: '#1c1c1c' },
158 icons: ASCII,
159 status: MARKS,
160 mark: '■',
161 spinnerSuffix: '...',
162 },
163 {
164 id: 'paper',
165 tone: 'light',
166 blurb: 'Light, warm and quiet, for bright rooms',
167 palette: {
168 bg: '#fbf8f1',
169 text: '#2b2b2b',
170 muted: '#5c5c5c',
171 accent: '#1f5fbf',
172 info: '#0f6a80',
173 ok: '#1e6b34',
174 warn: '#8a5300',
175 error: '#b3261e',
176 special: '#7a3a9e',
177 },
178 diff: { addFg: '#14532d', addBg: '#dcf2df', removeFg: '#8b1a12', removeBg: '#fbe0dd' },
179 icons: OUTLINE,
180 status: MARKS,
181 mark: '§',
182 spinnerSuffix: '…',
183 },
184 {
185 id: 'tokyo-night',
186 tone: 'dark',
187 blurb: 'Cool blues and neon, after the Tokyo Night palette',
188 palette: {
189 bg: '#1a1b26',
190 text: '#c0caf5',
191 muted: '#9aa5ce',
192 accent: '#7aa2f7',
193 info: '#7dcfff',
194 ok: '#9ece6a',
195 warn: '#e0af68',
196 error: '#f7768e',
197 special: '#bb9af7',
198 },
199 diff: { addFg: '#b9f27c', addBg: '#1f3326', removeFg: '#ff9eaf', removeBg: '#3a1f2a' },
200 icons: GEO,
201 status: MARKS,
202 mark: '◆',
203 spinnerSuffix: ' ~',
204 },
205 {
206 id: 'dracula',
207 tone: 'dark',
208 blurb: 'Purple, pink and green on deep slate',
209 palette: {
210 bg: '#282a36',
211 text: '#f8f8f2',
212 muted: '#c0c4d8',
213 accent: '#bd93f9',
214 info: '#8be9fd',
215 ok: '#50fa7b',
216 warn: '#f1fa8c',
217 error: '#ff6e6e',
218 special: '#ff79c6',
219 },
220 diff: { addFg: '#69ff94', addBg: '#1e3a2b', removeFg: '#ff9580', removeBg: '#46222b' },
221 icons: GEO,
222 status: MARKS,
223 mark: '▼',
224 spinnerSuffix: ' ›',
225 },
226 {
227 id: 'catppuccin',
228 tone: 'dark',
229 blurb: 'Soft pastels, after Catppuccin Mocha',
230 palette: {
231 bg: '#1e1e2e',
232 text: '#cdd6f4',
233 muted: '#a6adc8',
234 accent: '#cba6f7',
235 info: '#89dceb',
236 ok: '#a6e3a1',
237 warn: '#f9e2af',
238 error: '#f38ba8',
239 special: '#f5c2e7',
240 },
241 diff: { addFg: '#a6e3a1', addBg: '#253a2c', removeFg: '#f5a3bb', removeBg: '#3e2433' },
242 icons: GEO,
243 status: MARKS,
244 mark: '●',
245 spinnerSuffix: '…',
246 },
247 {
248 id: 'solarized',
249 tone: 'dark',
250 blurb: 'Solarized dark, nudged brighter to pass contrast',
251 palette: {
252 bg: '#002b36',
253 text: '#93a1a1',
254 muted: '#8a9a9c',
255 accent: '#41a7e8',
256 info: '#2fb5aa',
257 ok: '#97ad00',
258 warn: '#c99a00',
259 error: '#ff6e5c',
260 special: '#a6a9e8',
261 },
262 diff: { addFg: '#b3cc1a', addBg: '#0b3a2a', removeFg: '#ff8a7a', removeBg: '#3a1f24' },
263 icons: ASCII,
264 status: MARKS,
265 mark: '⊙',
266 spinnerSuffix: '…',
267 },
268 {
269 id: 'nord',
270 tone: 'dark',
271 blurb: 'Arctic frost blues, after the Nord palette',
272 palette: {
273 bg: '#2e3440',
274 text: '#eceff4',
275 muted: '#d8dee9',
276 accent: '#88c0d0',
277 info: '#8fbcbb',
278 ok: '#a3be8c',
279 warn: '#ebcb8b',
280 error: '#e5939b',
281 special: '#c5a3c0',
282 },
283 diff: { addFg: '#c2dba9', addBg: '#3b4a3c', removeFg: '#f2b8be', removeBg: '#4f3a42' },
284 icons: OUTLINE,
285 status: MARKS,
286 mark: '◇',
287 spinnerSuffix: '…',
288 },
289 {
290 id: 'high-contrast',
291 tone: 'dark',
292 blurb: 'WCAG AAA: at least 7:1 contrast on everything it draws',
293 palette: {
294 bg: '#000000',
295 text: '#ffffff',
296 muted: '#e0e0e0',
297 accent: '#ffff00',
298 info: '#00ffff',
299 ok: '#7fff7f',
300 warn: '#ffd75f',
301 error: '#ff8f8f',
302 special: '#ffafff',
303 },
304 diff: { addFg: '#000000', addBg: '#9dff9d', removeFg: '#000000', removeBg: '#ffb0b0' },
305 icons: GEO,
306 status: MARKS,
307 mark: '■',
308 spinnerSuffix: '...',
309 },
310 {
311 id: 'colorblind',
312 tone: 'dark',
313 blurb: 'Diffs in blue and orange, icons that differ by shape',
314 palette: {
315 bg: '#1b1d23',
316 text: '#e6e6e6',
317 muted: '#b4b8c0',
318 accent: '#56b4e9',
319 info: '#8fd0ff',
320 ok: '#56b4e9',
321 warn: '#f0e442',
322 error: '#ffa94d',
323 special: '#cc79a7',
324 },
325 diff: { addFg: '#9fd7ff', addBg: '#0c2a45', removeFg: '#ffbe6b', removeBg: '#3d2608' },
326 icons: GEO,
327 status: MARKS,
328 mark: '▲',
329 spinnerSuffix: '…',
330 },
331 // The seasonal packs. `/wear season` picks one by the date (look.ts SEASONS).
332 {
333 id: 'halloween',
334 tone: 'dark',
335 blurb: 'Pumpkin orange, violet and bone white on near-black',
336 palette: {
337 bg: '#0f0a16',
338 text: '#f2ead8',
339 muted: '#b7acc4',
340 accent: '#ff8c2b',
341 info: '#c9a6ff',
342 ok: '#a6e85c',
343 warn: '#ffb347',
344 error: '#ff6b6b',
345 special: '#dcb8ff',
346 },
347 diff: { addFg: '#b5f08a', addBg: '#1b2a12', removeFg: '#ffa07a', removeBg: '#3a1a12' },
348 icons: NIGHT,
349 status: MARKS,
350 mark: '☾',
351 spinnerSuffix: ' ~',
352 },
353 {
354 id: 'christmas',
355 tone: 'dark',
356 blurb: 'Holly green, berry red, snow white and gold on deep pine',
357 palette: {
358 bg: '#0e2a1c',
359 text: '#f6faf4',
360 muted: '#b9cdbe',
361 accent: '#ff7b86',
362 info: '#9fdcff',
363 ok: '#8ee08e',
364 warn: '#f2c94c',
365 error: '#ff8a93',
366 special: '#ffd766',
367 },
368 diff: { addFg: '#b4f0b4', addBg: '#174a2a', removeFg: '#ffb3b9', removeBg: '#4a1e26' },
369 icons: FROST,
370 status: MARKS,
371 mark: '❄',
372 spinnerSuffix: '…',
373 },
374 {
375 id: 'spring',
376 tone: 'light',
377 blurb: 'Fresh green, blossom pink and sky blue on cream',
378 palette: {
379 bg: '#fbf7ec',
380 text: '#2f3a2f',
381 muted: '#5e6a5e',
382 accent: '#2e7d32',
383 info: '#1769aa',
384 ok: '#2e7d32',
385 warn: '#8a5a00',
386 error: '#b3261e',
387 special: '#a8307a',
388 },
389 diff: { addFg: '#1b5e20', addBg: '#dff3dc', removeFg: '#8b1a3a', removeBg: '#fbdde6' },
390 icons: BLOOM,
391 status: MARKS,
392 mark: '❀',
393 spinnerSuffix: '…',
394 },
395 {
396 id: 'summer',
397 tone: 'dark',
398 blurb: 'Sun yellow, sea teal and coral on deep navy',
399 palette: {
400 bg: '#0b1e3a',
401 text: '#f1f5fb',
402 muted: '#b4c1d6',
403 accent: '#ffd84d',
404 info: '#4fd8d0',
405 ok: '#5fe0a8',
406 warn: '#ffc14d',
407 error: '#ff8a73',
408 special: '#ff9aa8',
409 },
410 diff: { addFg: '#8ff0c0', addBg: '#0f3a2e', removeFg: '#ffb0a0', removeBg: '#3e1c22' },
411 icons: SHORE,
412 status: MARKS,
413 mark: '☼',
414 spinnerSuffix: ' ~',
415 },
416 {
417 id: 'autumn',
418 tone: 'dark',
419 blurb: 'Amber, rust, moss and cream on dark brown',
420 palette: {
421 bg: '#241a12',
422 text: '#f3e9d7',
423 muted: '#c0b19b',
424 accent: '#f5a623',
425 info: '#9ec5b8',
426 ok: '#a9c46c',
427 warn: '#e8884f',
428 error: '#ff7a66',
429 special: '#e0a884',
430 },
431 diff: { addFg: '#c9d98a', addBg: '#2f3a1a', removeFg: '#ffb098', removeBg: '#4a2418' },
432 icons: LEAF,
433 status: MARKS,
434 mark: '❧',
435 spinnerSuffix: '…',
436 },
437]
438
439/** The theme with this id, or undefined (for `default`, too: no restyle). */
440export function themeById(id: string): Theme | undefined {
441 return THEMES.find(theme => theme.id === id)
442}
443
444/** The kind a tool's name says it is. */
445export function kindOf(tool: string): Kind {
446 if (tool.startsWith('mcp__')) return 'mcp'
447 return TOOL_KIND[tool] ?? 'other'
448}
449
450const TOOL_KIND: Record<string, Kind> = {
451 Bash: 'shell',
452 PowerShell: 'shell',
453 BashOutput: 'shell',
454 KillShell: 'shell',
455 Monitor: 'shell',
456 Read: 'read',
457 NotebookRead: 'read',
458 LSP: 'read',
459 Edit: 'edit',
460 MultiEdit: 'edit',
461 NotebookEdit: 'edit',
462 Write: 'write',
463 Grep: 'search',
464 Glob: 'search',
465 ToolSearch: 'search',
466 WebFetch: 'web',
467 WebSearch: 'web',
468 Agent: 'agent',
469 Task: 'agent',
470 SendMessage: 'agent',
471 TodoWrite: 'todo',
472 TaskCreate: 'todo',
473 TaskUpdate: 'todo',
474 TaskList: 'todo',
475 TaskGet: 'todo',
476}
477
478/**
479 * A verb for the spinner and the turn's closing line, [present, past], per
480 * theme: one is picked when a turn starts and held for the whole turn, never
481 * cycled (a word swapped mid-turn reads as flashing on the desktop). The two
482 * accessibility themes keep the engine's own words.
483 */
484export const VERBS: Readonly<Record<string, readonly (readonly [string, string])[]>> = {
485 noir: [['Tailing', 'Tailed'], ['Sleuthing', 'Sleuthed'], ['Casing', 'Cased']],
486 paper: [['Drafting', 'Drafted'], ['Inking', 'Inked'], ['Sketching', 'Sketched']],
487 'tokyo-night': [['Cruising', 'Cruised'], ['Humming', 'Hummed'], ['Drifting', 'Drifted']],
488 dracula: [['Stalking', 'Stalked'], ['Brooding', 'Brooded'], ['Lurking', 'Lurked']],
489 catppuccin: [['Purring', 'Purred'], ['Napping', 'Napped'], ['Pouncing', 'Pounced']],
490 solarized: [['Sunning', 'Sunned'], ['Shading', 'Shaded'], ['Basking', 'Basked']],
491 nord: [['Skiing', 'Skied'], ['Trekking', 'Trekked'], ['Gliding', 'Glided']],
492 halloween: [['Brewing', 'Brewed'], ['Haunting', 'Haunted'], ['Conjuring', 'Conjured']],
493 christmas: [['Wrapping', 'Wrapped'], ['Gifting', 'Gifted'], ['Carolling', 'Carolled']],
494 spring: [['Blooming', 'Bloomed'], ['Sprouting', 'Sprouted'], ['Planting', 'Planted']],
495 summer: [['Surfing', 'Surfed'], ['Sailing', 'Sailed'], ['Paddling', 'Paddled']],
496 autumn: [['Raking', 'Raked'], ['Harvesting', 'Harvested'], ['Simmering', 'Simmered']],
497}
498
499/** The verb a theme lends a turn: the same answer for the same turn id, null for a theme with none. */
500export function pickVerb(themeId: string, turnId: string): readonly [string, string] | null {
501 const verbs = VERBS[themeId]
502 if (!verbs || verbs.length === 0) return null
503 const h = [...turnId].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7)
504 return verbs[h % verbs.length] ?? null
505}
506hooks/bolt-draw.tsx 90 lines1// Draws Bolt, the modmyclaude mascot (brand/bolt-sprites), on the surface a hook draws for.
2// Same file in every mod that shows Bolt: mods install on their own, so they cannot share code.
3// Terminal: one Raster, a cell grid. Desktop: one code-font cell per character, since its Text is
4// proportional and a row of half-blocks would drift. Elsewhere: coloured Text runs.
5// Bolt is static here: no timer, no redraw of its own. Never draw it over a safety hold or in a toast.
6
7import type { BoxProps, ElementConstructor, RasterProps, RenderElement, TextProps } from 'claude-code'
8
9import { boltCells, boltRuns } from './bolt'
10import type { BoltCell, BoltSmallPose, BoltTinyPose } from './bolt'
11
12type Elements = {
13 Box: ElementConstructor<BoxProps>
14 Text: ElementConstructor<TextProps>
15 Raster?: ElementConstructor<RasterProps>
16}
17
18export type BoltPick = { size: 'small'; pose: BoltSmallPose } | { size: 'tiny'; pose: BoltTinyPose }
19
20/** Columns a pose takes: small is 10, tiny is 3. */
21export const BOLT_COLUMNS = { small: 10, tiny: 3 } as const
22
23/** The cells of a pose: half-blocks, two pixel rows per cell. */
24export function boltOf(pick: BoltPick): BoltCell[][] {
25 return pick.size === 'small' ? boltCells('small', pick.pose) : boltCells('tiny', pick.pose)
26}
27
28/** Bolt as one element under `key`, drawn the way `surface` lays out cells. */
29export function boltSprite(elements: Elements, surface: string, pick: BoltPick, key = 'bolt'): RenderElement {
30 return cellsSprite(elements, surface, boltOf(pick), key)
31}
32
33/** Any cell grid (Bolt, or Bolt in a costume: costume.ts) as one element under `key`, laid out as `surface` lays out cells. */
34export function cellsSprite(elements: Elements, surface: string, cells: readonly (readonly BoltCell[])[], key: string): RenderElement {
35 const { Box, Text, Raster } = elements
36 const columns = cells[0]?.length ?? 0
37 if (surface === 'terminal' && Raster !== undefined) {
38 return <Raster key={key} columns={columns} rows={cells.length} cells={rasterCells(cells)} />
39 }
40 const paint = (cell: BoltCell): TextProps => ({
41 ...(cell.fg !== undefined ? { color: cell.fg } : {}),
42 ...(cell.bg !== undefined ? { backgroundColor: cell.bg } : {}),
43 })
44 if (surface === 'desktop') {
45 return (
46 <Box key={key} flexDirection="column" flexShrink={0}>
47 {cells.map(line => (
48 <Box flexDirection="row" flexWrap="nowrap">
49 {line.map(cell =>
50 cell.fg === undefined && cell.bg === undefined ? <Box width={1} /> : <Box width={1}><Text {...paint(cell)}>{cell.ch}</Text></Box>,
51 )}
52 </Box>
53 ))}
54 </Box>
55 )
56 }
57 return (
58 <Box key={key} flexDirection="column" flexShrink={0}>
59 {cells.map(line => (
60 <Text>
61 {boltRuns(line).map(run => {
62 const text = run.ch.replace(/ /g, ' ')
63 return run.fg === undefined && run.bg === undefined ? text : <Text {...paint(run)}>{text}</Text>
64 })}
65 </Text>
66 ))}
67 </Box>
68 )
69}
70
71/** RasterProps.cells: standard base64 of little-endian u32 [codePoint, fg, bg] per cell, row-major. */
72export function rasterCells(cells: readonly (readonly BoltCell[])[]): string {
73 const DEFAULT = 0x01000000
74 const count = cells.reduce((sum, line) => sum + line.length, 0)
75 const view = new DataView(new ArrayBuffer(count * 12))
76 let at = 0
77 for (const line of cells) {
78 for (const cell of line) {
79 view.setUint32(at, cell.ch.codePointAt(0) ?? 32, true)
80 view.setUint32(at + 4, cell.fg === undefined ? DEFAULT : parseInt(cell.fg.slice(1), 16), true)
81 view.setUint32(at + 8, cell.bg === undefined ? DEFAULT : parseInt(cell.bg.slice(1), 16), true)
82 at += 12
83 }
84 }
85 let binary = ''
86 const bytes = new Uint8Array(view.buffer)
87 for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0)
88 return btoa(binary)
89}
90hooks/bolt.ts 109 lines1// Bolt sprites for mods, v1.0.0. GENERATED by brand/bolt-sprites/tools/build.py: edit design.py, not this file.
2// Copy this file into your mod's hooks/ folder (mods install on their own, so they cannot import from brand/).
3// Rules (brand/BRAND.md): pink is the antenna light only, blush the cheeks only. Never recolour, rotate or stretch.
4// Never draw Bolt over a safety hold, and never in a toast (toasts are uncoloured text).
5
6export const BOLT_PALETTE = {
7 B: '#C8F53C',
8 S: '#9BC21A',
9 O: '#FF5C8A',
10 K: '#FF8FB1',
11 E: '#101114',
12 W: '#FBFBF8',
13 G: '#8E95A3',
14} as const
15
16export type BoltSize = 'small' | 'tiny'
17export type BoltSmallPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep' | 'sweat' | 'think' | 'egg' | 'egg-wobble-l' | 'egg-wobble-r' | 'egg-crack-1' | 'egg-crack-2' | 'egg-crack-3' | 'egg-hatch'
18export type BoltTinyPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep'
19export type BoltPose = BoltSmallPose | BoltTinyPose
20
21/** One text cell: a character and its colours (absent = the surface's own). */
22export type BoltCell = { ch: string; fg?: string; bg?: string }
23
24/** Pixel rows: one palette letter per pixel, '.' empty. Heights are even (two pixel rows per cell). */
25export const BOLT_SPRITES = {
26 small: {
27 'idle': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
28 'run1': ['...OO.....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', 'SS......SS'],
29 'run2': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', '...SSSS...'],
30 'jump': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '...S..S...'],
31 'crash': ['......OO..', '.....S....', '..BBBBBB..', '.BBBBBBBB.', 'BEBEBBEBEB', 'BBEBBBBEBB', 'BEBEBBEBEB', 'BBBBEEBBBB', '.SSSSSSSS.', 'SS......SS'],
32 'cheer': ['S...OO...S', 'S...SS...S', 'SBBBBBBBBS', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBEEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
33 'sleep': ['....SS....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBBBBBBB', '.SSSSSSSS.', '..SS..SS..'],
34 'sweat': ['....OO....', '....SS...G', '..BBBBBB.G', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
35 'think': ['....OO....', '....SS....', '..BBBBBB..', '.BBWEBWEB.', 'BBBEEBEEBB', 'BBBBBBBBBB', 'BKKBBBBKKB', 'BBBBBEBBBB', '.SSSSSSSS.', '..SS..SS..'],
36 'egg': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
37 'egg-wobble-l': ['...OO.....', '...SS.....', '..BWBB....', '.BWBBBB...', '.BBBBBB...', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
38 'egg-wobble-r': ['.....OO...', '.....SS...', '....BWBB..', '...BWBBBB.', '...BBBBBB.', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
39 'egg-crack-1': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBBBBBB.', '.BBEBBBBS.', '..BBBBBS..', '...SSSS...'],
40 'egg-crack-2': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBBBB.', '.BBEBEBBS.', '..BBBBBS..', '...SSSS...'],
41 'egg-crack-3': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBEBB.', '.BBEBEBES.', '..BBBBBS..', '...SSSS...'],
42 'egg-hatch': ['......OO..', '......SS..', '....BWBBBB', '....B.B.B.', '..........', '..........', '.B.B.B.BB.', '.BSSSSSSB.', '..BBBBBS..', '...SSSS...'],
43 },
44 tiny: {
45 'idle': ['.O.', 'BEB', 'BBB', 'S.S'],
46 'run1': ['.O.', 'BEB', 'BBB', 'S.S'],
47 'run2': ['.O.', 'BEB', 'BBB', '.S.'],
48 'jump': ['.O.', 'BEB', 'BBB', '.S.'],
49 'crash': ['...', '.SO', 'BEB', 'SSS'],
50 'cheer': ['SOS', 'BEB', 'BBB', 'S.S'],
51 'sleep': ['.S.', 'BSB', 'BBB', 'S.S'],
52 },
53} as const
54
55/** Letters drawn over a whole cell (cell coordinates). */
56const GLYPHS: Record<string, readonly { row: number; col: number; ch: string; color: keyof typeof BOLT_PALETTE }[]> = {
57 'small/sleep': [{ row: 0, col: 8, ch: 'z', color: 'G' }],
58}
59
60/** The two run frames, in order. Swap them about every 120 ms while running. */
61export const BOLT_RUN = ['run1', 'run2'] as const
62
63function colour(key: string | undefined): string | undefined {
64 if (key === undefined || key === '.') return undefined
65 return (BOLT_PALETTE as Record<string, string>)[key]
66}
67
68/**
69 * Packs a pose into half-block text cells: rows / 2 lines of `cols` cells.
70 * Terminal: draw with Raster (see hatchling's rasterCells). Desktop: one fixed-width cell per character.
71 */
72export function boltCells(size: 'small', pose: BoltSmallPose): BoltCell[][]
73export function boltCells(size: 'tiny', pose: BoltTinyPose): BoltCell[][]
74export function boltCells(size: BoltSize, pose: BoltPose): BoltCell[][] {
75 const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
76 const out: BoltCell[][] = []
77 for (let r = 0; r + 1 < rows.length; r += 2) {
78 const top = rows[r] ?? ''
79 const bottom = rows[r + 1] ?? ''
80 const line: BoltCell[] = []
81 for (let c = 0; c < top.length; c += 1) {
82 const t = colour(top[c])
83 const b = colour(bottom[c])
84 if (t === undefined && b === undefined) line.push({ ch: ' ' })
85 else if (b === undefined) line.push({ ch: '\u2580', fg: t })
86 else if (t === undefined) line.push({ ch: '\u2584', fg: b })
87 else if (t === b) line.push({ ch: '\u2588', fg: t })
88 else line.push({ ch: '\u2580', fg: t, bg: b })
89 }
90 out.push(line)
91 }
92 for (const glyph of GLYPHS[`${size}/${pose}`] ?? []) {
93 const line = out[glyph.row]
94 if (line !== undefined && glyph.col < line.length) line[glyph.col] = { ch: glyph.ch, fg: BOLT_PALETTE[glyph.color] }
95 }
96 return out
97}
98
99/** Merges a row of cells into runs of one style, for coloured Text spans. */
100export function boltRuns(line: readonly BoltCell[]): BoltCell[] {
101 const runs: BoltCell[] = []
102 for (const cell of line) {
103 const last = runs[runs.length - 1]
104 if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.ch += cell.ch
105 else runs.push({ ...cell })
106 }
107 return runs
108}
109hooks/costume.ts 223 lines1// Bolt in costume: a hat, glasses or a cape drawn OVER the brand sprite
2// (bolt.ts) before its pixels are packed into cells, never a change to a pixel
3// of Bolt's own. Each theme has one costume, in at most three colours taken
4// from the theme's own palette, so the costume matches what is worn.
5//
6// Rules (brand/BRAND.md, brand/bolt-sprites/README.md), held by costume.test.ts:
7// the antenna light and the stem under it stay uncovered, so every hat has a
8// notch around them; the pink and the blush are Bolt's alone, never a costume's.
9
10import { BOLT_PALETTE, BOLT_SPRITES, boltCells } from './bolt'
11import type { BoltCell, BoltPose, BoltSize, BoltSmallPose, BoltTinyPose } from './bolt'
12import type { Role, Theme } from './themes'
13
14/** One costume pixel: where on the sprite, and which letter of the costume's paint. */
15export type Stitch = { row: number; col: number; key: string }
16
17/** A costume's paint: each letter names a colour of the theme's palette, or its background. */
18export type Paint = Record<string, Role | 'bg'>
19
20export type Costume = {
21 /** What it is, for the gallery and the docs. */
22 name: string
23 paint: Paint
24 small: readonly Stitch[]
25 tiny: readonly Stitch[]
26}
27
28/** The pixels of the antenna (the light, and the stem under it) no costume covers. */
29export const ANTENNA: Record<BoltSize, readonly (readonly [number, number])[]> = {
30 small: [
31 [0, 4],
32 [0, 5],
33 [1, 4],
34 [1, 5],
35 ],
36 tiny: [[0, 1]],
37}
38
39/** A costume uses this many colours at most. */
40export const MAX_COLOURS = 3
41
42/** Bolt's own pink and blush, never a costume colour. */
43export const BOLT_ONLY = [BOLT_PALETTE.O, BOLT_PALETTE.K] as const
44
45const at = (key: string, ...cells: readonly (readonly [number, number])[]): Stitch[] => cells.map(([row, col]) => ({ row, col, key }))
46const run = (key: string, row: number, from: number, to: number): Stitch[] =>
47 Array.from({ length: to - from + 1 }, (_, i) => ({ row, col: from + i, key }))
48
49// The small sprite (idle), for reading the stitches below:
50// row 0 ....OO.... the light (cols 4-5)
51// row 1 ....SS.... the stem
52// row 2 ..BBBBBB.. the top of the head
53// row 3 .BBBBBBBB.
54// row 4 BBWEBBWEBB the eyes (cols 2-3 and 6-7)
55// row 5 BBEEBBEEBB
56// row 6 BKKBBBBKKB the cheeks
57// row 7 BBBBEEBBBB the mouth (cols 4-5)
58// row 8 .SSSSSSSS.
59// row 9 ..SS..SS.. the feet
60// The tiny sprite: `.O.` / `BEB` / `BBB` / `S.S`.
61
62/** One costume per theme id; a theme with none wears Bolt plain. */
63export const COSTUMES: Record<string, Costume> = {
64 noir: {
65 name: 'fedora',
66 paint: { C: 'muted', B: 'text' },
67 small: [...at('C', [0, 2], [0, 3], [0, 6], [0, 7]), ...at('C', [1, 2], [1, 3], [1, 6], [1, 7]), ...run('B', 2, 0, 9)],
68 tiny: at('B', [0, 0], [0, 2]),
69 },
70 paper: {
71 name: 'reading glasses',
72 paint: { F: 'accent' },
73 small: at('F', [4, 1], [4, 4], [4, 5], [4, 8]),
74 tiny: at('F', [1, 0], [1, 2]),
75 },
76 'tokyo-night': {
77 name: 'headband',
78 paint: { H: 'accent', T: 'info' },
79 small: [...run('H', 2, 2, 7), ...at('T', [2, 1], [2, 8])],
80 tiny: at('H', [0, 0], [0, 2]),
81 },
82 dracula: {
83 name: 'cape and fangs',
84 paint: { C: 'accent', R: 'error', W: 'text' },
85 small: [
86 ...at('C', [3, 0], [4, 0], [5, 0], [6, 0], [7, 0], [8, 0]),
87 ...at('C', [3, 9], [4, 9], [5, 9], [6, 9], [7, 9], [8, 9]),
88 ...at('R', [2, 1], [2, 8]),
89 ...at('W', [8, 4], [8, 5]),
90 ],
91 tiny: at('C', [2, 0], [2, 2]),
92 },
93 catppuccin: {
94 name: 'cat ears',
95 paint: { E: 'accent', I: 'warn' },
96 small: [...at('E', [0, 1], [0, 8], [1, 1], [1, 8]), ...at('I', [1, 2], [1, 7])],
97 tiny: at('E', [0, 0], [0, 2]),
98 },
99 solarized: {
100 name: 'sun visor',
101 paint: { V: 'warn', B: 'accent' },
102 small: [...at('B', [1, 2], [1, 3], [1, 6], [1, 7]), ...run('V', 2, 0, 9)],
103 tiny: at('V', [0, 0], [0, 2]),
104 },
105 nord: {
106 name: 'beanie with a pom',
107 paint: { P: 'text', K: 'accent', F: 'info' },
108 small: [...at('P', [0, 2]), ...at('K', [0, 3], [0, 6], [0, 7]), ...at('K', [1, 1], [1, 2], [1, 3], [1, 6], [1, 7], [1, 8]), ...run('F', 2, 1, 8)],
109 tiny: at('K', [0, 0], [0, 2]),
110 },
111 'high-contrast': {
112 name: 'bow tie',
113 paint: { Y: 'accent', K: 'text' },
114 small: [...at('Y', [8, 2], [8, 3], [8, 6], [8, 7]), ...at('K', [8, 4], [8, 5])],
115 tiny: at('Y', [2, 0], [2, 2]),
116 },
117 colorblind: {
118 name: 'none',
119 paint: {},
120 small: [],
121 tiny: [],
122 },
123 halloween: {
124 name: 'witch hat',
125 paint: { V: 'info', B: 'accent' },
126 small: [...at('V', [0, 3], [0, 6]), ...at('B', [1, 2], [1, 3], [1, 6], [1, 7]), ...run('V', 2, 1, 8)],
127 tiny: at('V', [0, 0], [0, 2]),
128 },
129 christmas: {
130 name: 'santa hat',
131 paint: { R: 'error', W: 'text' },
132 small: [...at('W', [0, 2]), ...at('R', [0, 3], [0, 6], [0, 7]), ...at('R', [1, 1], [1, 2], [1, 3], [1, 6], [1, 7], [1, 8]), ...run('W', 2, 1, 8)],
133 tiny: at('R', [0, 0]).concat(at('W', [0, 2])),
134 },
135 spring: {
136 name: 'flower crown',
137 paint: { P: 'special', G: 'ok' },
138 small: [...at('P', [2, 1], [2, 3], [2, 5], [2, 7]), ...at('G', [2, 2], [2, 4], [2, 6], [2, 8])],
139 tiny: at('P', [0, 0], [0, 2]),
140 },
141 summer: {
142 name: 'sunglasses',
143 paint: { D: 'bg', F: 'accent' },
144 small: [...run('D', 4, 1, 3), ...run('D', 4, 6, 8), ...run('D', 5, 1, 3), ...run('D', 5, 6, 8), ...at('F', [4, 4], [4, 5])],
145 tiny: at('F', [0, 0], [0, 2]),
146 },
147 autumn: {
148 name: 'beanie',
149 paint: { A: 'accent', R: 'warn' },
150 small: [...at('A', [0, 2], [0, 3], [0, 6], [0, 7]), ...at('A', [1, 1], [1, 2], [1, 3], [1, 6], [1, 7], [1, 8]), ...run('R', 2, 1, 8)],
151 tiny: at('A', [0, 0], [0, 2]),
152 },
153}
154
155/** The costume a theme wears, or undefined (Bolt plain). */
156export function costumeOf(theme: Pick<Theme, 'id'>): Costume | undefined {
157 return COSTUMES[theme.id]
158}
159
160/** Whether a sprite pixel is the antenna: the light anywhere, the stem in the top two rows. */
161function isAntenna(letter: string, row: number): boolean {
162 return letter === 'O' || (letter === 'S' && row <= 1)
163}
164
165/** The colour a costume letter paints, out of the theme's palette. */
166export function paintOf(theme: Theme, costume: Costume, key: string): string | undefined {
167 const role = costume.paint[key]
168 if (role === undefined) return undefined
169 return role === 'bg' ? theme.palette.bg : theme.palette[role]
170}
171
172/** Two pixel colours as one half-block cell (bolt.ts packs the sprite the same way). */
173function pack(top: string | undefined, bottom: string | undefined): BoltCell {
174 if (top === undefined && bottom === undefined) return { ch: ' ' }
175 if (bottom === undefined) return { ch: '▀', fg: top }
176 if (top === undefined) return { ch: '▄', fg: bottom }
177 if (top === bottom) return { ch: '█', fg: top }
178 return { ch: '▀', fg: top, bg: bottom }
179}
180
181/**
182 * The pixel rows of a pose with the theme's costume over them: each row a list
183 * of colours (undefined where empty). A stitch lands only inside the sprite and
184 * never on the antenna, whatever the pose puts there.
185 */
186export function dressedPixels(theme: Theme, size: BoltSize, pose: BoltPose): { grid: (string | undefined)[][]; touched: Set<string> } {
187 const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
188 const palette = BOLT_PALETTE as Record<string, string>
189 const grid = rows.map(row => [...row].map(letter => (letter === '.' ? undefined : palette[letter])))
190 const touched = new Set<string>()
191 const costume = costumeOf(theme)
192 if (!costume) return { grid, touched }
193 for (const stitch of costume[size]) {
194 const line = grid[stitch.row]
195 const letter = rows[stitch.row]?.[stitch.col]
196 if (line === undefined || letter === undefined || isAntenna(letter, stitch.row)) continue
197 const colour = paintOf(theme, costume, stitch.key)
198 if (colour === undefined) continue
199 line[stitch.col] = colour
200 touched.add(`${stitch.row}/${stitch.col}`)
201 }
202 return { grid, touched }
203}
204
205/** The half-block cells; any other character is a glyph bolt.ts drew over the sprite (a sleeping z), which a costume never covers. */
206const BLOCKS = new Set([' ', '\u2580', '\u2584', '\u2588'])
207
208/**
209 * Bolt dressed for the theme, as cells for bolt-draw. Only the cells a stitch
210 * touched are packed afresh; every other cell is the sprite's own, and so is
211 * any glyph bolt.ts draws over a cell, whatever a stitch did beneath it.
212 */
213export function wearBolt(theme: Theme, size: 'small', pose: BoltSmallPose): BoltCell[][]
214export function wearBolt(theme: Theme, size: 'tiny', pose: BoltTinyPose): BoltCell[][]
215export function wearBolt(theme: Theme, size: BoltSize, pose: BoltPose): BoltCell[][]
216export function wearBolt(theme: Theme, size: BoltSize, pose: BoltPose): BoltCell[][] {
217 const base = size === 'small' ? boltCells('small', pose as BoltSmallPose) : boltCells('tiny', pose as BoltTinyPose)
218 const { grid, touched } = dressedPixels(theme, size, pose)
219 if (touched.size === 0) return base
220 const hit = (r: number, c: number) => touched.has(`${2 * r}/${c}`) || touched.has(`${2 * r + 1}/${c}`)
221 return base.map((line, r) => line.map((cell, c) => (hit(r, c) && BLOCKS.has(cell.ch) ? pack(grid[2 * r]?.[c], grid[2 * r + 1]?.[c]) : cell)))
222}
223types/index.d.ts 52 lines1// The values wardrobe keeps in $.state for the session. Plain JSON data.
2
3/** What is being worn right now, and why. */
4export type WardrobeLook = {
5 /** The theme drawn: a theme id, or `default` for Claude Code's own look. */
6 theme: string
7 /** What was chosen: a theme id, `auto`, `season` or `default`. */
8 choice: string
9 /** Where the choice came from: the global one, or this project's pin. */
10 scope: 'global' | 'project'
11 /** In auto mode, which half of the day it is; null otherwise. */
12 phase: 'day' | 'night' | null
13 /** The project root the look was resolved for (pins are keyed by it). */
14 root: string
15}
16
17/** One tool call's timing, from `tool.call`; `ms` is null while it runs. */
18export type WardrobeTiming = {
19 startedAt: number
20 ms: number | null
21}
22
23/** The verb this turn wears on the spinner and its closing line, picked once when the turn started; null between themes or for a theme with none. */
24export type WardrobeVerb = {
25 turnId: string
26 present: string
27 past: string
28}
29
30/** Claude Code's own theme, as far as a mod can read it: its tone decides whether a light look may paint light panels. */
31export type WardrobeApp = {
32 tone: 'dark' | 'light' | 'unknown'
33}
34
35/** The last `/wear` that put a theme on: what, and when (a `$.clock.now()` reading), for the switch band. */
36export type WardrobeSwitch = {
37 theme: string
38 at: number
39}
40
41declare module 'claude-code' {
42 interface PluginState {
43 wardrobe: {
44 look: WardrobeLook
45 timing: StateFamily<WardrobeTiming>
46 switch: WardrobeSwitch | null
47 verb: WardrobeVerb | null
48 app: WardrobeApp
49 }
50 }
51}
52