SLOPSHOPPER

skin-spinners

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…

newspinnerprompttimer
v0.1.3MITupdated 2026-10-22dboeter/cc-plugins/plugins/skin-spinners
A shopper browsing a rack in a slop shop
README

skin-spinners

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.

Install

/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.

Use

Nothing to switch on. Pick a skin with /skin and the spinner row and the footer under each reply change with it.

SkinSpriteWordClock and tagFooter
retro-terminalmodem lights that follow the turntyped out in glowing phosphor, a blinking cursor, a refresh beam[mm:ss], then 38911 BYTES FREE counting downREADY.
cyberpunk-neona packet along a tracedecoding from hext+0x0cyes
space-graya moon orbiting, a pulse on the pathsignal that drops lettersT+mm:ssyes
tokyo-nighta skyline with lit windows and rainsplit-flap boardON TIME, then minutes late, then the last trainyes
draculaa bat across the moona red bandcountdown to sunriseyes
ayuthe sun rising between rippleswarming, with glintsa day baryes
gruvboxa tape reel and its buttonscrolling gradientm:ss · side A, then side B; a conveyoryes
kanagawawaves toward Fujia crestlines that arrive at 20, 40 and 60 syes
norda lighthouse and snowa slow wide banda ship coming inyes
rose-pinea breathing pinebreathing colourclock after 30 syes
everforestthree flowers bloomingsunlit gradienttrail distance and climbyes
catppuccin, night-owl, github, solarized, one-dark, noir, monoone eachskins' shimmer band (noir: blinds)the defaultskins' own

These keep skins' own row instead of this mod's:

  • the desktop, VS Code and the mobile app (this mod draws the terminal only);
  • a skin set to off, /skin calm, ascii icons, a light palette;
  • a skin this mod has no theme for. Custom skins keep skins' row, except space-gray, cyberpunk-neon and retro-terminal, which have a theme here and take the custom skin's own spinner and done words as extras.

With /skin words set to claude, Claude Code's own words are kept and only the look changes.

What it does on your machine

  • State: the frame count, the time the latest turn began, and how the last 20 turns ended, in the mod's own state. Nothing is written to disk.
  • Claude: nothing is added to the prompt or the context.

Preview

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.

Develop

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.

Source 6 files
hooks/register.tsx 126 lines
1import { 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}
126
hooks/row.ts 32 lines
1import 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}
32
hooks/themes.ts 285 lines
1import {
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}
285
hooks/runs.ts 103 lines
1export 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}
103
hooks/effects.ts 259 lines
1import { 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  })
259
types/index.d.ts 14 lines
1// 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