Gives each skins skin its own animated spinner row and turn footer: a small sprite, an effect on the spinner word, a themed clock and tag, and themed done…

Gives every skins skin its own spinner row and turn footer. Each skin gets a small sprite that loops, an effect on the spinner word, a themed clock or tag, and, for 11 of them, themed done words in the footer.
/plugin install skin-spinners --marketplace dboeter/cc-plugins
Answer y to add the marketplace, then pick a scope. It needs the skins mod, skins@hellosverre-mods, installed first. It names skins as a dependency, but Claude Code installs a dependency from another marketplace only by hand.
skins draws the spinner row and the footer too, and when two mods hook the same row, the one loaded first draws it. Claude Code loads plugins in the order of enabledPlugins in ~/.claude/settings.json, and an install adds its key at the end, below skins. Move "skin-spinners@dboeter": true above "skins@hellosverre-mods": true, then restart Claude Code. Left below skins, this mod draws no footer, and draws the spinner row only while /skin shimmer off is set, because skins then hands the row on.
Nothing to switch on. Pick a skin with /skin and the spinner row and the footer under each reply change with it.
| Skin | Sprite | Word | Clock and tag | Footer |
|---|---|---|---|---|
| retro-terminal | modem lights that follow the turn | typed out in glowing phosphor, a blinking cursor, a refresh beam | [mm:ss], then 38911 BYTES FREE counting down | READY. |
| cyberpunk-neon | a packet along a trace | decoding from hex | t+0x0c | yes |
| space-gray | a moon orbiting, a pulse on the path | signal that drops letters | T+mm:ss | yes |
| tokyo-night | a skyline with lit windows and rain | split-flap board | ON TIME, then minutes late, then the last train | yes |
| dracula | a bat across the moon | a red band | countdown to sunrise | yes |
| ayu | the sun rising between ripples | warming, with glints | a day bar | yes |
| gruvbox | a tape reel and its button | scrolling gradient | m:ss · side A, then side B; a conveyor | yes |
| kanagawa | waves toward Fuji | a crest | lines that arrive at 20, 40 and 60 s | yes |
| nord | a lighthouse and snow | a slow wide band | a ship coming in | yes |
| rose-pine | a breathing pine | breathing colour | clock after 30 s | yes |
| everforest | three flowers blooming | sunlit gradient | trail distance and climb | yes |
| catppuccin, night-owl, github, solarized, one-dark, noir, mono | one each | skins' shimmer band (noir: blinds) | the default | skins' own |
These keep skins' own row instead of this mod's:
off, /skin calm, ascii icons, a light palette;With /skin words set to claude, Claude Code's own words are kept and only the look changes.
bun plugins/skin-spinners/preview.ts [--skin a,b] [--mode <mode>] [--at <seconds>] [--speed <n>] [--frames <n>] [--footer]
Draws every theme in 24-bit colour, one row per skin, redrawn every 90 ms until Ctrl-C. --at starts at an elapsed time, --speed runs the clock faster to see effects that take minutes, --frames prints that many frames per skin as lines and exits, --footer adds each footer.
A theme is an entry of THEMES in hooks/themes.ts, built from the helpers in hooks/effects.ts; no code outside that file branches on a skin's name. runs.ts, effects.ts, themes.ts and row.ts import nothing from Claude Code, so the tests and the preview call them directly. hooks/register.tsx is the only file that reads skins' state and draws.
hooks/register.tsx 126 lines1import { atom, read, update } from 'claude-code'
2import type { Register, StateDollar, Timer } from 'claude-code'
3
4import { footerRow, spinnerRow } from './row'
5import { THEMES } from './themes'
6import { FRAME_MS, hash, pick, type Outcome, type Tick } from './runs'
7
8const OUTCOMES_KEPT = 20
9// a turn that starts this long after the last prompt submitted while idle is not that prompt's
10const STALE_MS = 60_000
11// the frame moves for about a second after the last spinner this mod drew
12const IDLE_TICKS = 11
13
14const frameAtom = atom({ plugin: 'skin-spinners', key: 'frame' } as const, 0)
15const startedAtAtom = atom({ plugin: 'skin-spinners', key: 'startedAt' } as const, 0)
16const outcomesAtom = atom({ plugin: 'skin-spinners', key: 'outcomes' } as const, {})
17const prefsRef = { plugin: 'skins', key: 'prefs' } as const
18const customRef = { plugin: 'skins', key: 'custom' } as const
19const turnsRef = { plugin: 'skins', key: 'turns' } as const
20const isLightRef = { plugin: 'skins', key: 'isLight' } as const
21
22const outcomeKey = (durationMs: number) => `d${durationMs}`
23// the newest last, so the trim keeps the newest
24const keep = (outcomes: Record<string, Outcome>, durationMs: number, reason: Outcome) => {
25 const key = outcomeKey(durationMs)
26 const rest = Object.entries(outcomes).filter(([k]) => k !== key)
27 return Object.fromEntries([...rest, [key, reason] as [string, Outcome]].slice(-OUTCOMES_KEPT))
28}
29
30// The skin to draw, or undefined when the row stays skins' (skins missing or off, calm, ascii icons, a light palette, a skin with no theme here).
31const active = async ($: StateDollar) => {
32 const prefs = await read($, prefsRef)
33 if (prefs === undefined || prefs.skin === 'off' || prefs.calm !== null || prefs.icons === 'ascii') return undefined
34 if ((await read($, isLightRef)) === true) return undefined
35 const theme = Object.hasOwn(THEMES, prefs.skin) ? THEMES[prefs.skin] : undefined
36 return theme === undefined ? undefined : { prefs, theme, skin: prefs.skin }
37}
38
39export const register: Register = on => {
40 let ticker: Timer | undefined
41 let idleTicks = 0
42 let submittedAt = 0
43
44 on('session.start', ($, e, next) => {
45 ticker?.cancel()
46 ticker = $.clock.every(FRAME_MS, () => {
47 if (idleTicks++ < IDLE_TICKS) void update($, frameAtom, frame => frame + 1)
48 })
49 return next(e)
50 })
51
52 // The seconds of UserPromptSubmit hooks come before turn.start, with the spinner already up, so the clock starts here.
53 // A prompt typed during a turn carries that turn's id and leaves the clock alone.
54 on('prompt.submit', async ($, e, next) => {
55 if (e.turnId === undefined) {
56 const now = await $.clock.now()
57 submittedAt = now
58 await update($, startedAtAtom, () => now)
59 }
60 return next(e)
61 })
62
63 on('turn.start', async ($, e, next) => {
64 if (e.agentId === undefined) {
65 const now = await $.clock.now()
66 if (submittedAt === 0 || now - submittedAt > STALE_MS) await update($, startedAtAtom, () => now)
67 submittedAt = 0
68 }
69 return next(e)
70 })
71
72 // startedAt is left as it is: a spinner still up as the turn ends keeps its clock
73 on('turn.complete', async ($, e, next) => {
74 if (e.agentId === undefined) await update($, outcomesAtom, outcomes => keep(outcomes, e.durationMs, e.reason))
75 return next(e)
76 })
77
78 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
79 if (e.surface !== 'terminal' || e.props.message !== null) return next(e)
80 const look = await active($)
81 if (look === undefined) return next(e)
82 const { prefs, theme, skin } = look
83
84 const startedAt = await read($, startedAtAtom)
85 const custom = (await read($, customRef))?.[skin]
86 const seed = String(startedAt || e.props.word)
87 const word = prefs.words === 'claude' && e.props.word !== '' ? e.props.word : pick([...theme.words, ...(custom?.spinner ?? [])], seed)
88 const now = await $.clock.now()
89 const frame = await read($, frameAtom)
90 idleTicks = 0
91
92 const t: Tick = { frame, elapsedMs: startedAt === 0 ? frame * FRAME_MS : now - startedAt, mode: e.props.mode, seed: hash(seed) }
93 const { Text } = $.ui.resolve(e)
94 return (
95 <Text>
96 {spinnerRow(theme, t, word, e.props.suffix).map(run => (
97 <Text color={run.color} backgroundColor={run.bg} weight={run.bold ? 'bold' : undefined} italic={run.italic}>{run.text}</Text>
98 ))}
99 </Text>
100 )
101 })
102
103 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
104 if (e.surface !== 'terminal') return next(e)
105 const look = await active($)
106 const done = look?.theme.done
107 if (look === undefined || done === undefined) return next(e)
108 const { prefs, theme, skin } = look
109
110 const outcome = (await read($, outcomesAtom))[outcomeKey(e.props.durationMs)] ?? 'answer'
111 const custom = (await read($, customRef))?.[skin]
112 const list = outcome === 'aborted' ? done.aborted : outcome === 'answer' ? [...done.words, ...(custom?.done ?? [])] : done.error
113 const word = prefs.words === 'claude' ? e.props.word : pick(list, String(e.props.durationMs))
114 const stats = (await read($, turnsRef))?.[String(e.props.durationMs)]
115
116 const { Text } = $.ui.resolve(e)
117 return (
118 <Text>
119 {footerRow(theme.muted, done, word, e.props.durationMs, stats).map(run => (
120 <Text color={run.color} backgroundColor={run.bg} weight={run.bold ? 'bold' : undefined} italic={run.italic}>{run.text}</Text>
121 ))}
122 </Text>
123 )
124 })
125}
126hooks/row.ts 32 lines1import type { Done, Theme } from './themes'
2import { formatDuration, merge, timer, type Run, type Tick } from './runs'
3
4export type TurnStats = { tools: number; added: number; removed: number }
5
6const after = (runs: Run[]): Run[] => (runs.length === 0 ? [] : [{ text: ' ' }, ...runs])
7
8// the sprite, the word, the suffix (the Spinner's, unless the theme has its own), then the clock and the tag; a part that draws nothing leaves its separator out
9export const spinnerRow = (theme: Theme, t: Tick, word: string, suffix: string): Run[] =>
10 merge([
11 ...theme.sprite(t),
12 { text: ' ' },
13 ...theme.word(word, t),
14 { text: theme.suffix ?? suffix, color: theme.muted },
15 ...after(theme.clock === undefined ? timer(t.elapsedMs, theme.muted) : theme.clock(t)),
16 ...after(theme.tag?.(t) ?? []),
17 ])
18
19// skins' footer with the theme's mark and line swapped in
20export const footerRow = (muted: string, done: Done, word: string, durationMs: number, stats: TurnStats | undefined): Run[] => {
21 const { ok, err } = done
22 const line = done.line?.(word, durationMs) ?? [{ text: `${word} in ${formatDuration(durationMs)}` }]
23 const hasDiff = stats !== undefined && stats.added + stats.removed > 0
24 return merge([
25 done.mark,
26 { text: ' ' },
27 ...line.map(run => ({ color: muted, ...run })),
28 ...(stats === undefined || stats.tools === 0 ? [] : [{ text: ` · ${stats.tools} tool${stats.tools === 1 ? '' : 's'}`, color: muted }]),
29 ...(hasDiff ? [{ text: ' · ', color: muted }, { text: `+${stats.added}`, color: ok }, { text: ` −${stats.removed}`, color: err }] : []),
30 ])
31}
32hooks/themes.ts 285 lines1import {
2 batMoon, band, blinds, blinker, breathing, crest, flat, flowers, glint, grove, harbour, hexDecode, mirrored, modem, orbit, packet,
3 signal, skyline, spin, splitFlap, sunlit, sunrise, tape, typewriter, waves, type Sprite, type Word,
4} from './effects'
5import { cycle, mix, mmss, merge, pad2, pixels, rand, timer, type Run, type Tick } from './runs'
6
7export type Done = {
8 mark: Run
9 ok: string // +added
10 err: string // −removed
11 words: readonly string[]
12 aborted: readonly string[]
13 error: readonly string[] // for 'error' and 'refusal'
14 line?: (word: string, durationMs: number) => Run[]
15}
16
17export type Theme = {
18 muted: string // suffix and default clock
19 sprite: Sprite // the same width on every frame and in every mode
20 word: Word // the same width on every frame, for a given word
21 words: readonly string[] // spinner words: the skin's own, then the extras
22 suffix?: string // default: the Spinner's props.suffix
23 clock?: (t: Tick) => Run[] // default: formatDuration(elapsedMs) in muted; [] hides it
24 tag?: (t: Tick) => Run[] // drawn after the clock
25 done?: Done // absent: the footer stays skins'
26}
27
28const say = (text: string, color: string, style: Partial<Run> = {}): Run[] => [{ text, color, ...style }]
29const mark = (color: string): Run => ({ text: '◆', color })
30
31// A skin without a banner: the band in the skin's own colours.
32const plain = (c: { user: string; fg: string; muted: string }, sprite: Sprite, words: readonly string[]): Theme => ({
33 muted: c.muted,
34 sprite,
35 word: band({ head: c.fg, near: c.user, base: c.muted }),
36 words,
37})
38
39const GRAPH = ['#161b22', '#0e4429', '#006d32', '#26a641', '#39d353']
40const STRIPES = [['#fabd2f', '#fe8019'], ['#fb4934', '#d3869b'], ['#83a598', '#8ec07c']] as const
41const stripe = (j: number, t: Tick) => cycle(STRIPES, j + Math.floor(t.frame / 3))
42
43export const THEMES: Readonly<Record<string, Theme>> = {
44 'retro-terminal': {
45 muted: '#74b386',
46 sprite: modem({ on: '#33ff66', off: '#1f4d2b' }),
47 word: typewriter({ ink: '#33ff66', dim: '#74b386', glow: '#c2f5cc' }),
48 words: ['Dialing up', 'Handshaking', 'Spooling', 'Seeking track 0', 'Rewinding tape', 'Defragmenting', 'Paging in', 'Polling'],
49 suffix: '',
50 clock: t => say(`[${mmss(t.elapsedMs)}]`, '#74b386'),
51 // the C64's start-up line, losing 16 bytes a second
52 tag: t => {
53 const free = 38911 - 16 * Math.floor(t.elapsedMs / 1000)
54 return free > 0 ? say(`${free} BYTES FREE`, '#74b386') : say('?OUT OF MEMORY', '#ff5050')
55 },
56 done: { ok: '#66ff99', err: '#ff5050', mark: { text: 'READY.', color: '#33ff66' }, words: ['Loaded', 'Spooled', 'Dialed in', 'Compiled'], aborted: ['BREAK'], error: ['?SYNTAX ERROR'] },
57 },
58
59 'cyberpunk-neon': {
60 muted: '#8f93bf',
61 sprite: packet({ trace: '#0fa3b8', packet: '#ff2bd6', flash: '#d6fffa' }),
62 word: hexDecode({ done: '#ff2bd6', hex: '#05d9e8', glitch: '#fcee0a' }),
63 words: ['Jacking in', 'Breaching ICE', 'Spoofing', 'Overclocking', 'Netrunning', 'Decrypting', 'Ghosting'],
64 clock: t => say(`t+0x${Math.floor(t.elapsedMs / 1000).toString(16).padStart(2, '0')}`, '#8f93bf'),
65 done: { ok: '#39ff88', err: '#ff3860', mark: mark('#ff2bd6'), words: ['Jacked out', 'Zeroed', 'Ghosted', 'Patched'], aborted: ['Disconnected'], error: ['Flatlined'] },
66 },
67
68 'space-gray': {
69 muted: '#9aa0ab',
70 sprite: orbit({ planet: '#3ddcff', path: '#9aa0ab', body: '#eceef3', moon: '#3ddcff', pulse: '#ff6eb3' }),
71 word: signal('#eceef3', '#9aa0ab'),
72 words: ['Transmitting', 'Station-keeping', 'Slingshotting', 'Orbiting', 'Clearing the ring plane', 'Burning', 'Awaiting reply'],
73 clock: t => say(`T+${mmss(t.elapsedMs)}`, '#9aa0ab'),
74 done: {
75 ok: '#3dff8b', err: '#ff4d5e',
76 mark: mark('#ff6eb3'),
77 words: ['Copy, go for code', 'Ring plane clear', 'Signal acquired', 'Splashdown'],
78 aborted: ['Scrubbed'],
79 error: ['Signal lost'],
80 line: (word, ms) => [{ text: `${word} · T+${mmss(ms)}` }],
81 },
82 },
83
84 'tokyo-night': {
85 muted: '#737aa2',
86 sprite: skyline({ heights: [1, 2, 1, 2, 2, 1], mast: 3, wall: '#3b4261', lit: '#e0af68', antenna: '#f7768e', rain: mix('#7aa2f7', '#1a1b26', 0.5) }),
87 word: splitFlap({ landed: '#c0caf5', spinning: '#e0af68', flicker: '#7dcfff' }),
88 words: ['Neon-ing', 'Drizzling', 'Glowing', 'Wiring', 'Night-driving', 'Rain-checking', 'Departing', 'Boarding', 'Transferring', 'Minding the gap'],
89 tag: t =>
90 t.elapsedMs < 60000 ? say('ON TIME', '#9ece6a')
91 : t.elapsedMs < 600000 ? say(`+${Math.floor(t.elapsedMs / 60000)} MIN`, '#ff9e64')
92 : say('LAST TRAIN · BOARDING', '#e0af68', { bold: true }),
93 done: {
94 ok: '#9ece6a', err: '#f7768e',
95 mark: mark('#7aa2f7'),
96 words: ['Lit up', 'Wired', 'Shipped', 'Landed', 'Arrived', 'Caught the last train'],
97 aborted: ['Missed the train'],
98 error: ['Service suspended'],
99 },
100 },
101
102 dracula: {
103 muted: '#6272a4',
104 sprite: batMoon({ moon: '#bd93f9', shade: '#8a6ad1', bat: '#6272a4', cut: '#282a36' }),
105 word: band({ head: '#ff5555', near: '#ff79c6', base: '#6272a4' }),
106 words: ['Brooding', 'Haunting', 'Conjuring', 'Lurking', 'Scheming', 'Unliving', 'Entering freely', 'Counting', 'Coffin-napping', 'Turning to mist'],
107 clock: t => {
108 const left = Math.max(0, 6 * 3600 + 12 * 60 - Math.floor(t.elapsedMs / 1000))
109 if (left === 0) return say('sunrise', '#f1fa8c')
110 return [
111 { text: 'sunrise in ', color: '#6272a4' },
112 { text: `${Math.floor(left / 3600)}h ${pad2(Math.floor(left / 60) % 60)}m ${pad2(left % 60)}s`, color: '#f1fa8c' },
113 ]
114 },
115 done: { ok: '#50fa7b', err: '#ff5555', mark: mark('#bd93f9'), words: ['Risen', 'Conjured', 'Bitten', 'Vanquished'], aborted: ['Fled at dawn'], error: ['Staked'] },
116 },
117
118 ayu: {
119 muted: '#626a73',
120 sprite: sunrise({ from: '#e6b450', to: '#ff8f40', water: '#39bae6' }),
121 word: glint('#e6b450', '#ff8f40', '#fff3d6'),
122 words: ['Dawning', 'Glinting', 'Warming', 'Kindling', 'Shining', 'Ripening', 'Rising and shipping', 'Catching first light'],
123 tag: t => {
124 const at = Math.min(8, Math.floor(t.elapsedMs / 30000))
125 return merge([
126 { text: '━'.repeat(at), color: '#e6b450' },
127 { text: '●', color: '#ff8f40' },
128 { text: '┄'.repeat(8 - at), color: '#626a73' },
129 ])
130 },
131 done: { ok: '#aad94c', err: '#f07178', mark: mark('#e6b450'), words: ['Dawned', 'Shone', 'Warmed', 'Lit', 'Risen and shipped'], aborted: ['Clouded over'], error: ['Eclipsed'] },
132 },
133
134 gruvbox: {
135 muted: '#928374',
136 sprite: tape({ reel: '#ebdbb2', button: '#fabd2f' }),
137 word: mirrored(['#fabd2f', '#fe8019', '#fb4934']),
138 words: ['Brewing', 'Roasting', 'Simmering', 'Toasting', 'Percolating', 'Kindling', 'Grooving', 'Dropping the needle', 'Rewinding with a pencil'],
139 clock: t => {
140 const s = Math.floor(t.elapsedMs / 1000)
141 return say(`${Math.floor(s / 60)}:${pad2(s % 60)} · side ${s >= 45 * 60 ? 'B' : 'A'}`, '#928374')
142 },
143 tag: t => pixels([[0, 1, 2, 3].map(j => stripe(j, t)[0]), [0, 1, 2, 3].map(j => stripe(j, t)[1])]),
144 done: {
145 ok: '#b8bb26', err: '#fb4934',
146 mark: mark('#fabd2f'),
147 words: ['Brewed', 'Roasted', 'Toasted', 'Served', 'Side A done', 'Far out'],
148 aborted: ['Tape jammed'],
149 error: ['Needle skipped'],
150 },
151 },
152
153 kanagawa: {
154 muted: '#727169',
155 sprite: waves({ sea: '#7e9cd8', crest: '#7fb4ca', mountain: '#dcd7ba' }),
156 word: crest({ head: '#dcd7ba', rising: '#7fb4ca', foam: '#727169', sea: '#7e9cd8' }),
157 words: ['Cresting', 'Inking', 'Brushing', 'Swelling', 'Breaking', 'Ebbing', 'Rowing', 'Bowing into the foam'],
158 tag: t =>
159 t.elapsedMs < 20000 ? []
160 : t.elapsedMs < 40000 ? say('great wave bares its claws', '#727169', { italic: true })
161 : t.elapsedMs < 60000 ? say('oarsmen bow into the foam', '#727169', { italic: true })
162 : say('fuji does not move', '#dcd7ba', { italic: true }),
163 done: { ok: '#98bb6c', err: '#e46876', mark: mark('#7e9cd8'), words: ['Crested', 'Inked', 'Brushed', 'Broken'], aborted: ['Ebbed'], error: ['Capsized'] },
164 },
165
166 nord: {
167 muted: '#7b88a1',
168 sprite: harbour({ dark: '#4c566a', tower: '#bf616a', flake: '#d8dee9', flash: '#eceff4' }),
169 word: band({ head: '#eceff4', near: '#88c0d0', base: '#7b88a1' }, 2, 2),
170 words: ['Drifting', 'Frosting', 'Chilling', 'Thawing', 'Skiing', 'Gliding', 'Keeping watch', 'Trimming the wick', 'Sounding the horn'],
171 tag: t => {
172 const ship = Math.max(0, 5 - Math.floor(t.elapsedMs / 15000))
173 return merge([
174 { text: '─'.repeat(ship), color: '#3b4252' },
175 { text: '•', color: '#ebcb8b' },
176 { text: '─'.repeat(5 - ship), color: '#3b4252' },
177 ])
178 },
179 done: {
180 ok: '#a3be8c', err: '#bf616a',
181 mark: mark('#88c0d0'),
182 words: ['Frozen', 'Thawed', 'Charted', 'Crossed', 'Ship sighted', 'Safe harbour'],
183 aborted: ['Fog bound'],
184 error: ['Ran aground'],
185 },
186 },
187
188 'rose-pine': {
189 muted: '#6e6a86',
190 sprite: grove({ shade: '#191724', glow: '#ebbcba', pine: '#31748f', leaf: '#9ccfd8' }),
191 word: breathing('#ebbcba', '#c4a7e7'),
192 words: ['Blooming', 'Pruning', 'Steeping', 'Petaling', 'Wilting', 'Climbing', 'Pining', 'Lounging'],
193 suffix: '⁓',
194 clock: t => (t.elapsedMs < 30000 ? [] : timer(t.elapsedMs, '#6e6a86')),
195 done: { ok: '#9ccfd8', err: '#eb6f92', mark: mark('#c4a7e7'), words: ['Bloomed', 'Pruned', 'Steeped', 'Planted'], aborted: ['Wilted'], error: ['Frostbitten'] },
196 },
197
198 everforest: {
199 muted: '#859289',
200 sprite: flowers({ sprout: '#a7c080', bud: '#83c092', blooms: ['#d699b6', '#e67e80', '#dbbc7f'] }),
201 word: sunlit('#a7c080', '#dbbc7f', '#d3c6aa'),
202 words: ['Foraging', 'Rooting', 'Sprouting', 'Hiking', 'Mossing', 'Branching', 'Switchbacking', 'Trailblazing', 'Pitching camp'],
203 tag: t => {
204 if (t.elapsedMs >= 20 * 60000) return say('camp before dusk', '#e69875')
205 const km = t.elapsedMs / 100000
206 return say(`trail ${km.toFixed(1)} km · ↑ ${Math.round(km * 73.8)} m`, '#859289')
207 },
208 done: {
209 ok: '#a7c080', err: '#e67e80',
210 mark: mark('#a7c080'),
211 words: ['Grown', 'Rooted', 'Foraged', 'Camped', 'Summited', 'Made camp'],
212 aborted: ['Turned back'],
213 error: ['Lost the trail'],
214 },
215 },
216
217 catppuccin: plain(
218 { user: '#cba6f7', fg: '#cdd6f4', muted: '#7f849c' },
219 blinker([{ text: '=^.^=', color: '#cba6f7' }], [{ text: '=^-^=', color: '#cba6f7' }]),
220 ['Purring', 'Napping', 'Pouncing', 'Kneading', 'Stretching', 'Nuzzling'],
221 ),
222
223 'night-owl': plain(
224 { user: '#82aaff', fg: '#d6deeb', muted: '#637777' },
225 blinker(
226 [{ text: '(o', color: '#82aaff' }, { text: ',', color: '#ecc48d' }, { text: 'o)', color: '#82aaff' }],
227 [{ text: '(-', color: '#82aaff' }, { text: ',', color: '#ecc48d' }, { text: '-)', color: '#82aaff' }],
228 ),
229 ['Hooting', 'Perching', 'Swooping', 'Watching', 'Prowling', 'Gliding'],
230 ),
231
232 github: plain(
233 { user: '#58a6ff', fg: '#e6edf3', muted: '#7d8590' },
234 t =>
235 merge(
236 [0, 1, 2, 3, 4].map((x): Run => ({
237 text: '■',
238 color: cycle(GRAPH, Math.floor(rand(t.seed, Math.floor(t.frame / 6) + x) * GRAPH.length)),
239 })),
240 ),
241 ['Committing', 'Rebasing', 'Forking', 'Pushing', 'Cherry-picking', 'Stashing'],
242 ),
243
244 solarized: plain(
245 { user: '#268bd2', fg: '#93a1a1', muted: '#586e75' },
246 spin('○◔◑◕●◕◑◔', '#b58900', 2),
247 ['Orbiting', 'Basking', 'Flaring', 'Eclipsing', 'Rising', 'Setting'],
248 ),
249
250 'one-dark': plain(
251 { user: '#61afef', fg: '#abb2bf', muted: '#5c6370' },
252 t => {
253 const filled = Math.min(5, Math.floor(t.frame / 3) % 7)
254 return merge([
255 { text: '[', color: '#5c6370' },
256 { text: '■'.repeat(filled), color: '#98c379' },
257 { text: '□'.repeat(5 - filled), color: '#3e4451' },
258 { text: ']', color: '#5c6370' },
259 ])
260 },
261 ['Compiling', 'Linting', 'Atomizing', 'Bundling', 'Indexing', 'Parsing'],
262 ),
263
264 noir: {
265 ...plain(
266 { user: '#f5f5f5', fg: '#ededed', muted: '#8c8c8c' },
267 t => {
268 const glow = cycle([0, 1, 2, 3, 2, 1], Math.floor(t.frame / 2))
269 return [{ text: cycle([...'·∙•●•∙'], Math.floor(t.frame / 2)), color: mix('#8c8c8c', '#f5f5f5', glow / 3) }]
270 },
271 ['Thinking', 'Working', 'Composing', 'Considering', 'Weighing'],
272 ),
273 word: blinds('#ededed', '#8c8c8c'),
274 },
275
276 mono: {
277 ...plain(
278 { user: '#e0e0e0', fg: '#e0e0e0', muted: '#7a7a7a' },
279 spin('|/-\\', '#e0e0e0'),
280 ['Working', 'Thinking', 'Reading', 'Writing', 'Checking'],
281 ),
282 word: flat('#e0e0e0'),
283 },
284}
285hooks/runs.ts 103 lines1export type Mode = 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
2export type Outcome = 'answer' | 'aborted' | 'refusal' | 'error'
3export type Run = { text: string; color?: string; bg?: string; bold?: boolean; italic?: boolean }
4// Time enters the pure modules only here: the mod's own 90 ms frame, the turn's elapsed time, and a seed that keeps one turn's randomness the same
5export type Tick = { frame: number; elapsedMs: number; mode: Mode; seed: number }
6export type Pixel = string | null
7
8export const FRAME_MS = 90
9
10export const clamp = (n: number) => Math.min(1, Math.max(0, n))
11export const wrap = (n: number, m: number) => ((n % m) + m) % m
12// the item at i, wrapped round; the cast is for noUncheckedIndexedAccess, the lists are never empty
13export const cycle = <T>(items: readonly T[], i: number): T => items[wrap(i, items.length)] as T
14export const pad2 = (n: number) => String(n).padStart(2, '0')
15export const mmss = (ms: number) => {
16 const s = Math.floor(ms / 1000)
17 return `${pad2(Math.floor(s / 60))}:${pad2(s % 60)}`
18}
19
20// djb2, as skins' pick hashes its seed
21export const hash = (seed: string): number => {
22 let h = 5381
23 for (let i = 0; i < seed.length; i++) h = (h * 33 + seed.charCodeAt(i)) >>> 0
24 return h
25}
26
27// the same seed always picks the same item, so a row keeps its word when it redraws
28export const pick = <T>(items: readonly T[], seed: string): T => cycle(items, hash(seed))
29
30// a deterministic hash of the seed and the numbers, in [0, 1)
31export const rand = (seed: number, ...ns: number[]): number => {
32 let h = seed | 0
33 for (const n of ns) {
34 h = Math.imul(h ^ (n | 0), 0x9e3779b1)
35 h ^= h >>> 15
36 h = Math.imul(h, 0x85ebca6b)
37 h ^= h >>> 13
38 }
39 return (h >>> 0) / 4294967296
40}
41
42const rgb = (color: string) => [1, 3, 5].map(i => parseInt(color.slice(i, i + 2), 16))
43
44export const mix = (a: string, b: string, t: number): string => {
45 const [from, to] = [rgb(a), rgb(b)]
46 const k = clamp(t)
47 return `#${from.map((v, i) => Math.round(v + ((to[i] ?? 0) - v) * k).toString(16).padStart(2, '0')).join('')}`
48}
49
50// a colour along stops spread evenly over [0, 1]
51export const ramp = (stops: readonly string[], p: number): string => {
52 const x = clamp(p) * (stops.length - 1)
53 const i = Math.max(0, Math.min(Math.floor(x), stops.length - 2))
54 return mix(cycle(stops, i), cycle(stops, i + 1), x - i)
55}
56
57export function formatDuration(ms: number): string {
58 const seconds = Math.round(ms / 1000)
59 if (seconds < 1) return '<1s'
60 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
61}
62
63export const timer = (ms: number, color: string): Run[] => [{ text: formatDuration(ms), color }]
64
65const graphemes = new Intl.Segmenter()
66
67// what a person reads as one letter: an emoji with its modifiers, or a letter with its accents, is one, however many code points it has
68export const letters = (text: string): string[] => Array.from(graphemes.segment(text), s => s.segment)
69
70// East Asian wide and fullwidth letters, and emoji drawn as emoji, take two terminal cells
71const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩\u{20000}-\u{3fffd}]|\p{Emoji_Presentation}|\p{Regional_Indicator}|️/u
72
73export const cells = (text: string): number => letters(text).reduce((n, letter) => n + (WIDE.test(letter) ? 2 : 1), 0)
74
75export const width = (runs: readonly Run[]) => runs.reduce((n, r) => n + cells(r.text), 0)
76
77const same = (a: Run, b: Run) => a.color === b.color && a.bg === b.bg && a.bold === b.bold && a.italic === b.italic
78
79// joins neighbours that look the same and drops empty ones
80export const merge = (runs: readonly Run[]): Run[] => {
81 const out: Run[] = []
82 for (const run of runs) {
83 if (run.text === '') continue
84 const last = out.at(-1)
85 if (last !== undefined && same(last, run)) out[out.length - 1] = { ...last, text: last.text + run.text }
86 else out.push(run)
87 }
88 return out
89}
90
91// Two rows of N pixels as N cells of half blocks. Claude Code does not paint the terminal's background, so an empty pixel stays null:
92// filling it with a theme's background would draw a dark box on the person's terminal.
93export const pixels = (grid: readonly [readonly Pixel[], readonly Pixel[]]): Run[] => {
94 const [top, bottom] = grid
95 return top.map((upper, i): Run => {
96 const lower = bottom[i] ?? null
97 if (upper === null && lower === null) return { text: ' ' }
98 if (lower === null) return { text: '▀', color: upper ?? undefined }
99 if (upper === null) return { text: '▄', color: lower }
100 return { text: '▀', color: upper, bg: lower }
101 })
102}
103hooks/effects.ts 259 lines1import { FRAME_MS, cells, cycle, letters, merge, mix, pixels, rand, ramp, type Pixel, type Run, type Tick } from './runs'
2
3export type Word = (word: string, t: Tick) => Run[]
4export type Sprite = (t: Tick) => Run[]
5type Style = Partial<Run>
6
7// A word letter by letter. The callback gives a colour, or a style that can also swap the letter.
8// A swapped-in letter is padded to the cells of the one it replaces, so a wide letter does not shift the row.
9export const paint = (word: string, style: (letter: string, i: number) => Style | string): Run[] =>
10 merge(letters(word).map((letter, i) => {
11 const s = style(letter, i)
12 if (typeof s === 'string') return { text: letter, color: s }
13 const text = s.text === undefined ? letter : s.text + ' '.repeat(Math.max(0, cells(letter) - cells(s.text)))
14 return { ...s, text }
15 }))
16
17// The slot of `period` frames this frame is in, when it falls in the `length` frames that start at a jittered point of that slot; else undefined.
18// `salt` keeps two effects of one theme from firing together.
19export const pulse = (t: Tick, period: number, length: number, salt: number): number | undefined => {
20 const slot = Math.floor(t.frame / period)
21 const start = Math.floor(rand(t.seed, slot, salt) * (period - length + 1))
22 const at = t.frame - slot * period
23 return at >= start && at < start + length ? slot : undefined
24}
25
26// one of `n` things, chosen by the slot a pulse fired in
27const chosen = (t: Tick, slot: number, salt: number, n: number) => Math.floor(rand(t.seed, slot, salt) * n)
28
29const lettersOf = (word: string) => letters(word).length
30
31// Words
32
33export const flat = (color: string): Word => word => paint(word, () => color)
34
35// skins' shimmer: the head letter in one colour, the letters within `reach` of it in a second, the rest in a base colour
36export const band = (c: { head: string; near: string; base: string }, reach = 1, step = 1): Word => (word, t) => {
37 const head = (Math.floor(t.frame / step) % (lettersOf(word) + 8)) - 4
38 return paint(word, (_, i) => {
39 const d = Math.abs(i - head)
40 return d === 0 ? c.head : d <= reach ? c.near : c.base
41 })
42}
43
44// a gradient that scrolls one letter per 3 frames, mirrored so it has no seam
45export const mirrored = (stops: readonly string[]): Word => (word, t) => {
46 const len = lettersOf(word)
47 const shift = Math.floor(t.frame / 3)
48 return paint(word, (_, i) => {
49 const q = (i + shift) % (2 * len)
50 return ramp(stops, (q <= len ? q : 2 * len - q) / len)
51 })
52}
53
54// Phosphor: the letter just typed glows and the one before it cools; once the word is typed, the cursor blinks and a refresh beam crosses it about every 3.5 s.
55export const typewriter = (c: { ink: string; dim: string; glow: string }): Word => (word, t) => {
56 const all = letters(word)
57 const len = all.length
58 const typed = Math.min(len, Math.floor(t.elapsedMs / FRAME_MS))
59 const isCursorOn = t.elapsedMs < len * FRAME_MS || Math.floor((t.elapsedMs - len * FRAME_MS) / 530) % 2 === 0
60 const hot = typed < len ? typed - 1 : (t.frame % (len + 36)) - 1
61 const isFlicker = pulse(t, 44, 2, 1) !== undefined
62 return merge([
63 ...all.slice(0, typed).map((letter, i): Run => ({
64 text: letter,
65 color: isFlicker ? c.dim : i === hot ? c.glow : i === hot - 1 ? mix(c.glow, c.ink, 0.5) : c.ink,
66 })),
67 { text: isCursorOn ? '█' : ' ', color: c.ink },
68 { text: ' '.repeat(cells(all.slice(typed).join(''))) },
69 ])
70}
71
72const GLITCH = [...'▓▒░#%&$§¥']
73
74export const hexDecode = (c: { done: string; hex: string; glitch: string }): Word => (word, t) => {
75 const decoded = letters(word).flatMap((letter, i) => (letter !== ' ' && t.elapsedMs >= 180 * (i + 1) ? [i] : []))
76 const slot = pulse(t, 33, 1, 2)
77 const glitched = slot === undefined || decoded.length === 0 ? -1 : (decoded[chosen(t, slot, 3, decoded.length)] ?? -1)
78 return paint(word, (letter, i) => {
79 if (letter === ' ') return {}
80 if (i === glitched) return { text: cycle(GLITCH, chosen(t, slot ?? 0, 4, GLITCH.length)), color: c.glitch }
81 if (t.elapsedMs >= 180 * (i + 1)) return c.done
82 const code = (letter.codePointAt(0) ?? 0).toString(16).padStart(2, '0')
83 return { text: t.frame % 2 === 0 ? code.slice(0, 1) : code.slice(-1), color: c.hex }
84 })
85}
86
87// a drop-out is rare most of the time and common in a short burst about every 4.5 s; denser than this, the word stops reading as a word
88export const signal = (ink: string, lost: string): Word => (word, t) => {
89 const p = pulse(t, 50, 4, 6) === undefined ? 0.015 : 0.35
90 return paint(word, (letter, i) => (letter !== ' ' && rand(t.seed, t.frame, i) < p ? { text: '·', color: lost } : ink))
91}
92
93const FLAP = [...'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789']
94
95export const splitFlap = (c: { landed: string; spinning: string; flicker: string }): Word => (word, t) => {
96 const tick = Math.floor(t.elapsedMs / FRAME_MS)
97 const slot = pulse(t, 55, 1, 5)
98 const flicker = slot === undefined ? -1 : chosen(t, slot, 6, lettersOf(word))
99 return paint(word, (letter, i) => {
100 if (letter === ' ') return {}
101 if (tick < 2 + 2 * i) return { text: cycle(FLAP, Math.floor(rand(t.seed, tick, i) * FLAP.length)), color: c.spinning }
102 return i === flicker ? c.flicker : c.landed
103 })
104}
105
106// all letters one colour, warming over the first 2 minutes, with one letter glinting about every 1.5 s
107export const glint = (from: string, to: string, spark: string): Word => (word, t) => {
108 const warm = mix(from, to, t.elapsedMs / 120000)
109 const slot = pulse(t, 16, 1, 7)
110 const at = slot === undefined ? -1 : chosen(t, slot, 8, lettersOf(word))
111 return paint(word, (_, i) => (i === at ? { color: spark, bold: true } : warm))
112}
113
114// a band moving right with a lopsided crest: the head, two letters rising ahead of it, two of foam behind
115export const crest = (c: { head: string; rising: string; foam: string; sea: string }): Word => (word, t) => {
116 const head = (t.frame % (lettersOf(word) + 8)) - 4
117 return paint(word, (_, i) => {
118 const d = i - head
119 return d === 0 ? c.head : d === 1 || d === 2 ? c.rising : d === -1 || d === -2 ? c.foam : c.sea
120 })
121}
122
123// a gradient with a highlight that sways back and forth, one letter per 2 frames
124export const sunlit = (from: string, to: string, light: string): Word => (word, t) => {
125 const len = lettersOf(word)
126 const period = 2 * (len - 1)
127 const q = period > 0 ? Math.floor(t.frame / 2) % period : 0
128 const at = q < len ? q : period - q
129 return paint(word, (_, i) => (i === at ? light : ramp([from, to], len > 1 ? i / (len - 1) : 0)))
130}
131
132// 0 to 1 and back over 4 s
133export const breath = (ms: number) => (1 - Math.cos((2 * Math.PI * ms) / 4000)) / 2
134
135export const breathing = (from: string, to: string): Word => (word, t) => flat(mix(from, to, breath(t.elapsedMs)))(word, t)
136
137export const blinds = (a: string, b: string): Word => (word, t) =>
138 paint(word, (_, i) => (Math.floor((i + Math.floor(t.frame / 3)) / 2) % 2 === 0 ? a : b))
139
140// Sprites
141
142export const spin = (frames: string, color: string, every = 1): Sprite => t => [{ text: cycle([...frames], Math.floor(t.frame / every)), color }]
143
144// shut for 3 frames about every 4 s
145export const blinker = (open: Run[], shut: Run[]): Sprite => t => (pulse(t, 44, 3, 9) === undefined ? open : shut)
146
147export const packet = (c: { trace: string; packet: string; flash: string }): Sprite => t => {
148 const step = Math.floor(t.frame / 2) % 5
149 if (step === 4) return merge([{ text: '────', color: c.trace }, { text: '◆', color: c.flash }])
150 return merge([
151 { text: '─'.repeat(step), color: c.trace },
152 { text: '●', color: c.packet },
153 { text: '─'.repeat(3 - step), color: c.trace },
154 { text: '┤', color: c.trace },
155 ])
156}
157
158// A modem's lights: carrier and off-hook steady, send and receive flickering by what the turn is doing, and the terminal-ready light
159// pulsing slowly while the model thinks.
160export const modem = (c: { on: string; off: string }): Sprite => t => {
161 const busy = (n: number) => rand(t.seed, t.frame, n) < 0.6
162 const isSending = t.mode === 'requesting' || t.mode === 'tool-input' || t.mode === 'tool-use'
163 const isReceiving = t.mode === 'responding' || t.mode === 'tool-use'
164 const lights = [true, true, isSending && busy(1), isReceiving && busy(2), t.mode !== 'thinking' || Math.floor(t.frame / 6) % 2 === 0]
165 return merge(lights.map((isOn): Run => ({ text: '●', color: isOn ? c.on : c.off })))
166}
167
168// a planet, a path with a pulse on it, and a moon that orbits a body in four phases
169export const orbit = (c: { planet: string; path: string; body: string; moon: string; pulse: string }): Sprite => t => {
170 const phase = Math.floor(t.frame / 4) % 4
171 const reach = Math.floor(t.frame / 2) % 4
172 const at = t.mode === 'requesting' ? 1 + reach : t.mode === 'responding' ? 4 - reach : 0
173 const slot = (isMoon: boolean): Run => (isMoon ? { text: '•', color: c.moon } : { text: ' ' })
174 return merge([
175 { text: '⊕', color: c.planet },
176 ...[1, 2, 3, 4].map((i): Run => (i === at ? { text: '•', color: c.pulse } : { text: '·', color: c.path })),
177 slot(phase === 0),
178 { text: phase === 3 ? '⊙' : '⊖', color: c.body },
179 slot(phase === 2),
180 ])
181}
182
183export const skyline = (c: { heights: readonly number[]; mast: number; wall: string; lit: string; antenna: string; rain: string }): Sprite => t => {
184 const roll = Math.floor(t.frame / 22)
185 const isFlash = t.elapsedMs % 1000 < 200
186 const cell = (x: number, y: number): Pixel => {
187 if (y < 2 - (c.heights[x] ?? 0)) return (Math.floor(t.frame / 2) + x * 5) % 6 === y ? c.rain : null
188 if (x === c.mast && y === 0 && isFlash) return c.antenna
189 return rand(t.seed, roll, x, y) < 0.3 ? c.lit : c.wall
190 }
191 const row = (y: number) => c.heights.map((_, x) => cell(x, y))
192 return pixels([row(0), row(1)])
193}
194
195// a moon, and a bat that flaps across it and leaves a silhouette where it passes; the bat is the dracula banner's SMALL_BAT
196export const batMoon = (c: { moon: string; shade: string; bat: string; cut: string }): Sprite => t => {
197 const wings = Math.floor(t.frame / 2) % 2 === 0 ? ['##.##', '..#..'] : ['..#..', '##.##']
198 // from just off the left edge (x = -5) to just off the right one (x = 9)
199 const left = (Math.floor(t.frame / 2) % 15) - 5
200 const row = (y: number): Pixel[] =>
201 Array.from({ length: 9 }, (_, x) => {
202 const moon = x === 4 ? c.moon : x === 3 || x === 5 ? c.shade : null
203 return wings[y]?.[x - left] === '#' ? (moon === null ? c.bat : c.cut) : moon
204 })
205 return pixels([row(0), row(1)])
206}
207
208export const sunrise = (c: { from: string; to: string; water: string }): Sprite => t => {
209 const level = Math.min(7, Math.floor(t.elapsedMs / 4000))
210 const ripple = (phase: number): Run => ({ text: Math.floor(t.frame / 4) % 2 === phase ? '~' : '-', color: c.water })
211 return [ripple(0), { text: cycle([...'▁▂▃▄▅▆▇█'], level), color: mix(c.from, c.to, level / 7) }, ripple(1)]
212}
213
214// a tape reel and its button, by what the turn is doing
215export const tape = (c: { reel: string; button: string }): Sprite => t => {
216 const [button, turn]: [string, number] =
217 t.mode === 'tool-use' ? ['⏵⏵', t.frame]
218 : t.mode === 'requesting' ? ['⏴⏴', -t.frame]
219 : t.mode === 'thinking' ? ['▮▮', 0]
220 : ['⏵ ', Math.floor(t.frame / 2)]
221 return [{ text: cycle([...'◴◷◶◵'], turn), color: c.reel }, { text: ' ' }, { text: button, color: c.button }]
222}
223
224export const waves = (c: { sea: string; crest: string; mountain: string }): Sprite => t => {
225 const shift = Math.floor(t.frame / 2)
226 return merge([
227 ...Array.from({ length: 7 }, (_, x): Run => {
228 const text = cycle([...'▁▂▃▅▆▅▃▂'], x - shift)
229 return { text, color: text === '▆' ? c.crest : c.sea }
230 }),
231 { text: '▲', color: c.mountain },
232 ])
233}
234
235export const harbour = (c: { dark: string; tower: string; flake: string; flash: string }): Sprite => t => {
236 const isFlash = t.elapsedMs % 10000 < 400
237 const flakes = [0, 1, 2].map((j): Run => {
238 const text = cycle([...'▀▄ '], Math.floor(t.frame / 3) - j)
239 return text === ' ' ? { text } : { text, color: isFlash ? c.flash : c.flake }
240 })
241 return merge([{ text: '▀', color: isFlash ? c.flash : c.dark, bg: c.tower }, ...flakes])
242}
243
244export const grove = (c: { shade: string; glow: string; pine: string; leaf: string }): Sprite => t => {
245 const b = breath(t.elapsedMs)
246 const shade = mix(c.shade, c.glow, 0.35 + 0.65 * b)
247 return merge([{ text: '░▒', color: shade }, { text: '▲', color: mix(c.pine, c.leaf, b) }, { text: '▒░', color: shade }])
248}
249
250// each flower blooms over 18 frames, a third of a bloom behind its neighbour
251export const flowers = (c: { sprout: string; bud: string; blooms: readonly string[] }): Sprite => t =>
252 [0, 1, 2].map((j): Run => {
253 const n = t.frame + 6 * j
254 const phase = n % 18
255 if (phase < 4) return { text: '.', color: c.sprout }
256 if (phase < 8) return { text: '∘', color: c.bud }
257 return { text: '✿', color: cycle(c.blooms, Math.floor(n / 18)) }
258 })
259types/index.d.ts 14 lines1// The mod's state contract. frame: its own 90 ms tick count, which moves only while it draws a spinner. startedAt: epoch ms when the
2// latest main-loop turn began, counted from the prompt that started it where there was one; 0 before the first. outcomes: how each main-loop turn ended, for the footer's word, the newest 20.
3// Keyed `d<durationMs>`, not by the bare number: a key that reads as an integer is kept in numeric order, so trimming to the
4// newest 20 would drop the shortest turns, and an aborted turn is usually short.
5declare module 'claude-code' {
6 interface PluginState {
7 'skin-spinners': {
8 frame: number
9 startedAt: number
10 outcomes: Record<string, 'answer' | 'aborted' | 'refusal' | 'error'>
11 }
12 }
13}
14