Terminal skins for Claude Code: color themes plus a banner, spinner and finish sound per theme. Ships Cyberpunk Neon and the four Catppuccin flavors.

Terminal skins for Claude Code. Each skin is a color theme plus its own prompt banner, spinner animation and finish sound, and the extras switch on and off with the theme you pick in /theme.
| Skin | Banner | Spinner | Sound |
|---|---|---|---|
| Cyberpunk Neon | ASCII NEON logo | neon colors, glitchy letters | chiptune arpeggio |
| Catppuccin Latte, Frappé, Macchiato, Mocha | ASCII cat | the flavor's pastel palette | soft chime |
Any other theme keeps Claude Code's normal spinner and prompt.
/plugin install skinforge --marketplace sanjesh17/skinforge
Then pick a skin with /theme. Themes load when a session starts, so restart after installing.
/skin glitch|scan|pulse spinner preset
/skin band on|off prompt banner
/skin sound on|off finish sound
Settings last for the session. The default preset is glitch.
themes/ (see the existing ones for the token list).SKINS in hooks/register.tsx: a pattern that matches the theme name, a palette, a banner, a sound and a pulse glyph set.The theme files use the plugin themes key, which is marked experimental and may change. The spinner, banner and sound are Claude Code mod hooks and need a version with mod support. The finish sound uses afplay, so it plays on macOS only.
hooks/register.tsx 243 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, Register, Timer } from 'claude-code'
3
4import type { Preset } from '../types'
5
6type Skin = {
7 tag: string
8 palette: string[]
9 logo: string[]
10 sound: string
11 isGlitchy: boolean
12 pulse: string
13}
14
15const staticText = {
16 unknown: 'Usage: /skin glitch|scan|pulse, /skin band on|off, /skin sound on|off',
17 preset: 'Spinner preset: ',
18 band: 'Prompt band ',
19 sound: 'Finish sound ',
20}
21
22const CAT_LOGO = [' /\\_/\\ ', '( o.o )', ' > ^ < ']
23const CYBER_LOGO = ['╔╗╔ ╔═╗ ╔═╗ ╔╗╔', '║║║ ║╣ ║ ║ ║║║', '╝╚╝ ╚═╝ ╚═╝ ╝╚╝']
24const CYBER: Skin = {
25 tag: '//GRID',
26 palette: ['#ff2a6d', '#05d9e8', '#f9f002', '#b967ff', '#00ff9f'],
27 logo: CYBER_LOGO,
28 sound: 'sounds/done.wav',
29 isGlitchy: true,
30 pulse: '◢◣◤◥',
31}
32const catppuccin = (tag: string, palette: string[]): Skin => ({
33 tag,
34 palette,
35 logo: CAT_LOGO,
36 sound: 'sounds/chime.wav',
37 isGlitchy: false,
38 pulse: '·•●•',
39})
40
41// mauve, pink, sapphire, green, peach, lavender
42const SKINS: { match: RegExp; skin: Skin }[] = [
43 { match: /cyber/i, skin: CYBER },
44 { match: /latte/i, skin: catppuccin('latte', ['#8839ef', '#ea76cb', '#209fb5', '#40a02b', '#fe640b', '#7287fd']) },
45 { match: /frapp/i, skin: catppuccin('frappe', ['#ca9ee6', '#f4b8e4', '#85c1dc', '#a6d189', '#ef9f76', '#babbf1']) },
46 { match: /macchiato/i, skin: catppuccin('macchiato', ['#c6a0f6', '#f5bde6', '#7dc4e4', '#a6da95', '#f5a97f', '#b7bdf8']) },
47 { match: /mocha/i, skin: catppuccin('mocha', ['#cba6f7', '#f5c2e7', '#74c7ec', '#a6e3a1', '#fab387', '#b4befe']) },
48]
49
50const GLITCH_CHARS = '▓▒░█▌▐/\\#@%'
51const SCAN = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃', '▂']
52const PRESETS: Preset[] = ['glitch', 'scan', 'pulse']
53const FRAME_MS = 120
54const MIN_SOUND_MS = 10_000
55const TIME_LENGTH = 8
56
57const frame = atom({ plugin: 'skinforge', key: 'frame' } as const, 0)
58const preset = atom({ plugin: 'skinforge', key: 'preset' } as const, 'glitch')
59const isBandOn = atom({ plugin: 'skinforge', key: 'isBandOn' } as const, true)
60const theme = atom({ plugin: 'skinforge', key: 'theme' } as const, '')
61const spinnerText = atom({ plugin: 'skinforge', key: 'spinnerText' } as const, '')
62const isSoundOn = atom({ plugin: 'skinforge', key: 'isSoundOn' } as const, true)
63
64const skinFor = (value: string): Skin | null =>
65 SKINS.find(({ match }) => match.test(value))?.skin ?? null
66
67const glitchChar = (char: string, i: number, n: number): string =>
68 (n * 7 + i * 13) % 23 === 0 && char !== ' '
69 ? (GLITCH_CHARS[(n + i) % GLITCH_CHARS.length] ?? char)
70 : char
71
72const spinnerTree = (
73 { Box, Text }: Pick<Elements['terminal'], 'Box' | 'Text'>,
74 skin: Skin,
75 n: number,
76 style: Preset,
77 word: string,
78 suffix: string,
79) => {
80 const { palette } = skin
81
82 if (style === 'scan') {
83 const bar = [0, 1, 2].map(i => SCAN[(n + i * 3) % SCAN.length]).join('')
84 return (
85 <Box>
86 <Text color={palette[2]}>{bar} </Text>
87 <Text color={palette[0]} bold>{word}</Text>
88 <Text color={palette[2]}>{suffix}</Text>
89 </Box>
90 )
91 }
92
93 if (style === 'pulse') {
94 const color = palette[Math.floor(n / 4) % palette.length]
95 return (
96 <Box>
97 <Text color={color} bold={n % 8 < 4} dimColor={n % 8 >= 6}>
98 {skin.pulse[n % skin.pulse.length]} {word}{suffix}
99 </Text>
100 </Box>
101 )
102 }
103
104 return (
105 <Box>
106 <Text color={palette[1]}>▌</Text>
107 {[...word].map((char, i) => (
108 <Text color={palette[(i + n) % palette.length]} bold>
109 {skin.isGlitchy ? glitchChar(char, i, n) : char}
110 </Text>
111 ))}
112 <Text color={palette[2]}>{suffix}▐</Text>
113 </Box>
114 )
115}
116
117const toggle = (word: string): boolean | null =>
118 word === 'on' ? true : word === 'off' ? false : null
119
120export const register: Register = on => {
121 let timer: Timer | null = null
122 let depth = 0
123 let startedAt = 0
124
125 on('session.start', async ($, e, next) => {
126 const rows = await $.config.list()
127 const current = rows.find(row => row.key === 'theme')
128 await update($, theme, () => String(current?.value ?? ''))
129 await $.command.register({
130 name: 'skin',
131 description: 'Theme skin: spinner preset, prompt banner, finish sound',
132 })
133
134 return next(e)
135 })
136
137 on('config.set', { key: 'theme' }, async ($, e, next) => {
138 const result = await next(e)
139 await update($, theme, () => String(e.value))
140
141 return result
142 })
143
144 on('command.run', { command: 'skin' }, async ($, e) => {
145 const [first = '', second = ''] = e.args.trim().toLowerCase().split(/\s+/)
146 const flag = toggle(second)
147
148 if (PRESETS.includes(first as Preset)) {
149 await update($, preset, () => first as Preset)
150 return { text: staticText.preset + first }
151 }
152 if (first === 'band' && flag !== null) {
153 await update($, isBandOn, () => flag)
154 return { text: staticText.band + second }
155 }
156 if (first === 'sound' && flag !== null) {
157 await update($, isSoundOn, () => flag)
158 return { text: staticText.sound + second }
159 }
160
161 return { text: staticText.unknown }
162 })
163
164 on('turn.start', async ($, e, next) => {
165 if (depth === 0) {
166 startedAt = await $.clock.now()
167 if (skinFor(await read($, theme))) {
168 timer = $.clock.every(FRAME_MS, () => {
169 void update($, frame, n => n + 1)
170 })
171 }
172 }
173 depth += 1
174
175 return next(e)
176 })
177
178 on('turn.complete', async ($, e, next) => {
179 depth = Math.max(0, depth - 1)
180 if (depth === 0) {
181 timer?.cancel()
182 timer = null
183 const skin = skinFor(await read($, theme))
184 const elapsed = (await $.clock.now()) - startedAt
185 if (skin && elapsed >= MIN_SOUND_MS && (await read($, isSoundOn))) {
186 void $.audio.play({ asset: skin.sound })
187 }
188 }
189
190 return next(e)
191 })
192
193 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
194 const skin = skinFor(await read($, theme))
195 if (!skin) {
196 return next(e)
197 }
198
199 const word = e.props.message ?? e.props.word
200 const ui = $.ui.resolve(e)
201
202 if (await read($, isBandOn)) {
203 // the banner draws the spinner under itself; keep it current from here
204 const text = word + e.props.suffix
205 if (text !== (await read($, spinnerText))) {
206 void update($, spinnerText, () => text)
207 }
208 return <ui.Box />
209 }
210
211 return spinnerTree(ui, skin, await read($, frame), await read($, preset), word, e.props.suffix)
212 })
213
214 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
215 const skin = skinFor(await read($, theme))
216 if (e.props.hasSurvey || !skin || !(await read($, isBandOn))) {
217 return next(e)
218 }
219
220 const ui = $.ui.resolve(e)
221 const { Box, Text } = ui
222 const n = await read($, frame)
223 const time = new Date(await $.clock.now()).toTimeString().slice(0, TIME_LENGTH)
224 const style = await read($, preset)
225 const info = ` ${skin.tag} ${time} ${style} ${SCAN[n % SCAN.length]}`
226
227 const working = e.props.isWorking
228 ? spinnerTree(ui, skin, n, style, await read($, spinnerText), '')
229 : null
230
231 return (
232 <Box flexDirection="column" paddingTop={1}>
233 {skin.logo.map((line, i) => (
234 <Text color={skin.palette[(i + Math.floor(n / 3)) % skin.palette.length]} bold>
235 {i === 1 ? line + info : line}
236 </Text>
237 ))}
238 {working}
239 </Box>
240 )
241 })
242}
243types/index.d.ts 15 lines1export type Preset = 'glitch' | 'scan' | 'pulse'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'skinforge': {
6 frame: number
7 preset: Preset
8 isBandOn: boolean
9 isSoundOn: boolean
10 theme: string
11 spinnerText: string
12 }
13 }
14}
15