SLOPSHOPPER

wardrobe

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

newpanebandspinnerrowsguard
★ 1v0.1.1MITupdated 2026-10-13modmyclaude/claude-code-mods/mods/wardrobe
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wardrobe
│ ┃ Wardrobe ✕ › fix the failing auth test and add an audit log call │ ┃ ▄▀▄ Wearing Claude Code's own look everywher │ ┃ ▀▀▀ without a pin. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ default Claude Code's own drawing (wearing) ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ noir dark [ wear ] [ match theme ] ▀▀▀ ⏺ Bash(bun test) │ ┃ ▀▀▀ ⎿ 3 pass, 1 fail │ ┃ Monochrome ink; status told apart by shape │ ┃ alone ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ > add a /health endpoint │ ┃ Adding it now. ✻ Worked for 42s · done 4:20 PM │ ┃ < Read src/app.ts │ ┃ ■ wearing noir › /wear │ ┃ │ ┃ paper light [ wear ] [ match theme ] ▄▀▄ │ ┃ ▀▀▀ │ ┃ Light, warm and quiet, for bright rooms │ ┃ best on a light theme │ ┃ > add a /health endpoint │ ┃ Adding it now. │ ┃ ○ Read src/app.ts │ ┃ § wearing paper │ ┃ │ ┃ tokyo-night dark [ wear ] [ match theme ] ▀▀ │ ┃ ▀▀ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Wardrobe
▄▀▄ Wearing Claude Code's own look everywhere without a pin. ▀▀▀ default Claude Code's own drawing (wearing) [ wear ] noir dark [ wear ] [ match theme ] ▀▀▀ ▀▀▀ Monochrome ink; status told apart by shape alone > add a /health endpoint Adding it now. < Read src/app.ts ■ wearing noir paper light [ wear ] [ match theme ] ▄▀▄ ▀▀▀ Light, warm and quiet, for bright rooms best on a light theme > add a /health endpoint Adding it now. ○ Read src/app.ts § wearing paper tokyo-night dark [ wear ] [ match theme ] ▀▀▀ ▀▀▀ Cool blues and neon, after the Tokyo Night palette > add a /health endpoint Adding it now. ● Read src/app.ts ◆ wearing tokyo-night dracula dark [ wear ] [ match theme ] ▄▀▄ ▀▀▀ Purple, pink and green on deep slate > add a /health endpoint Adding it now. ● Read src/app.ts ▼ wearing dracula catppuccin dark [ wear ] [ match theme ] ▀▀▀ ▀▀▀ Soft pastels, after Catppuccin Mocha > add a /health endpoint Adding it now. ● Read src/app.ts ● wearing catppuccin solarized dark [ wear ] [ match theme ] ▀▀▀ ▀▀▀ Solarized dark, nudged brighter to pass contrast > add a /health endpoint Adding it now. < Read src/app.ts ⊙ wearing solarized nord dark [ wear ] [ match theme ] ▀▀▀ ▀▀▀ Arctic frost blues, after the Nord palette > add a /health endpoint Adding it now. ○ Read src/app.ts ◇ wearing nord high-contrast dark [ wear ] [ match theme ] ▄▀▄ ▀▀▀ WCAG AAA: at least 7:1 contrast on everything it draws > add a /health endpoint Adding it now. ● Read src/app.ts ■ wearing high-contrast colorblind dark [ wear ] [ match theme ] ▄▀▄ ▀▀▀ Diffs in blue and orange, icons that differ by shape > add a /health endpoint Adding it now. ● Read src/app.ts ▲ wearing colorblind halloween dark [ wear ] [ match theme ] ▀▀▀ ▀▀▀ Pumpkin orange, violet and bone white on near-black > add a /health endpoint Adding it now. ● Read src/app.ts ☾ wearing halloween christmas dark [ wear ] [ match theme ] ▀▀▀ ▀▀▀ Holly green, berry red, snow white and gold on deep pine > add a /health endpoint Adding it now. ○ Read src/app.ts ❄ wearing christmas spring light [ wear ] [ match theme ] ▀▀▀ ▀▀▀ Fresh green, blossom pink and sky blue on cream best on a light theme > add a /health endpoint Adding it now. ○ Read src/app.ts ❀ wearing spring summer dark [ wear ] [ match theme ] ▀▀▀ ▀▀▀ Sun yellow, sea teal and coral on deep navy > add a /health endpoint Adding it now. ● Read src/app.ts ☼ wearing summer autumn dark [ wear ] [ match theme ] ▀▀▀ ▀▀▀ Amber, rust, moss and cream on dark brown > add a /health endpoint Adding it now. ○ Read src/app.ts ❧ wearing autumn auto paper from 07:00, tokyo-night from 19:00 [ wear ] season by the calendar: autumn now (Sep 1 to Nov 30, Hallowe aside) /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.
README

<img src="../../brand/pixel-icons/svg/wardrobe.svg" width="64" height="64" alt="">

Wardrobe

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

Install

/plugin install wardrobe --marketplace modmyclaude/claude-code-mods

Use it

CommandWhat it does
/wearOpens 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 autoA day theme by day and a night theme by night (see Settings).
/wear seasonThe seasonal pack the calendar calls for, re-checked daily (see Seasonal packs).
/wear pinThis project keeps what it wears now; other projects keep the global choice. While pinned, /wear <theme> changes this project's pin.
/wear unpinThis project follows the global choice again.
/wear <theme> --matchWears 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 matchThe same for the theme worn now. The gallery's match theme button does both at once.
/wear defaultBack 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:

WhereWhat wardrobe draws
Tool rowsA 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 groupsThe 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 outputA themed card; stderr lines are marked !, not only coloured.
SpinnerThe 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 durationA themed Baked for 3s line (the terminal draws this line; the desktop has its own footer).
Above the promptThe switch band, for six seconds after /wear (see below).

Seasonal packs

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.

DatesWears
Oct 15 to Nov 1halloween
Dec 1 to Jan 6christmas
Jan 7 to the end of Febnord (the frost theme; the rest of winter has no pack of its own)
Mar 1 to May 31spring
Jun 1 to Aug 31summer
Sep 1 to Nov 30, Halloween asideautumn

Bolt in costume

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.

The switch band

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.

Accessibility

  • 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).
  • Every other theme, the seasonal packs included: at least 4.5:1 (WCAG AA). tests/themes.test.ts checks every pair of every theme.
  • Every theme gives each tool kind and each status its own glyph, so colour is never the only cue.
  • Every coloured span is painted on the theme's own background, so the contrast holds whatever your terminal's background is.

Settings

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.

SettingDefaultMeaning
dayThemepaperWhat /wear auto wears by day.
nightThemetokyo-nightWhat /wear auto wears by night.
dayStarts07:00When the day starts, 24-hour local time.
nightStarts19:00When the night starts. A day that crosses midnight (20:00 to 06:00) works.

What it can touch

  • Its own store: the global choice, the per-project pins (keyed by the path of the folder the session started in), and, once you have matched a theme, the Claude Code theme you had before and the one it set, so /wear default can put yours back and can tell when you changed it since.
  • For the session, in memory: each tool call's start and end time, for its row.
  • Reads the project root, the attached surfaces, the clock, and Claude Code's own theme setting (the /config theme row) at session start, before a match and before a restore; it also notices when you change that setting.
  • Writes Claude Code's theme setting only when you ask: /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.

What a look changes, and what it cannot

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.

Limits

  • It restyles what Claude Code lets a mod draw. Claude Code's own text keeps its colours: the tool's own row inside wardrobe's stripe, icon and status, Read and Grep results, the prompt, dialogs and the transcript all follow Claude Code's own /theme. Wardrobe styles around them (the stripe, the chips, the band, the gallery); pick a matching /theme for the same reading everywhere.
  • Tool rows wrap Claude Code's own row instead of replacing it, so expanding a row and its output keep working.
  • Claude Code draws these itself: errors, diffs over 120 lines or 8,000 characters, binary diffs, edits held for review, and shell output over 8 lines or holding a control character other than a colour code (a progress bar's carriage return, a bell, NUL).
  • Untrusted text it draws (paths, diff lines, shell output, settings) is cleaned first: colour codes are dropped and any other control character shows as U+FFFD, since Claude Code refuses a drawing that holds one.
  • Where another mod draws its own tool result or turn line, wardrobe steps aside and that drawing stands. Tool rows, tool groups and the spinner wrap whatever is beneath them.
  • A pin belongs to the folder the session started in: a subfolder or a worktree of the same repo is pinned separately.
  • On the terminal's main screen, rows already printed keep their look; new rows, and everything in the fullscreen view, follow /wear.
  • VS Code and the mobile app are not restyled; /wear says so when one is attached.
  • Where nothing draws a toast or a pane (a claude -p run), /wear writes its words and the list to the log instead.
  • Auto mode and season mode check the clock once a minute, so a switch can land up to a minute late. A choice made in another session reaches this one the same way.
  • The switch band is a plain row above the prompt: it never covers a safety hold or a survey, and Bolt is left out of it under 60 columns.
  • Tool calls made before wardrobe loaded show no time, and a refused or failed call shows its status but no time.
  • Not verified: the paint itself in a real terminal and in the desktop app. The tests check the drawn trees against each surface's rules, not pixels.

Built from a prompt

This mod was built from prompts/wardrobe.md.

More mods: the full catalogue.

Source 9 files
hooks/register.tsx 483 lines
1// 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}
483
hooks/content.ts 144 lines
1// 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}
144
hooks/draw.tsx 514 lines
1// 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}
514
hooks/look.ts 222 lines
1// 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}
222
hooks/themes.ts 506 lines
1// 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}
506
hooks/bolt-draw.tsx 90 lines
1// 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}
90
hooks/bolt.ts 109 lines
1// 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}
109
hooks/costume.ts 223 lines
1// 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}
223
types/index.d.ts 52 lines
1// 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