Skins for Claude Code: /skin swaps the spinner's glyphs, verbs and color, the turn's closing line and the prompt hints.

Seven mods for Claude Code. Each one is a plugin of function hooks: it draws panes, status lines and toasts inside Claude Code, in the terminal and in the desktop app's Code tab.
| Mod | What it does | How to use it | |
|---|---|---|---|
| savvy-progress | A live band above the prompt while Claude works: what it is doing now ("Reading format.ts · 4s"), elapsed time, steps, files read and changed, errors, and a progress bar: the real task list when Claude keeps one, otherwise a time bar guessed from how long your past turns took (clearly labelled a guess). A one-line summary after each turn. | Shows by itself. `/savvy-progress [on\ | off]` |
| skins | Re-skins the spinner (glyphs, verbs, color), the turn's closing line and the prompt hints. Skins: matrix, pirate, zen, retro, space. Your choice is remembered. | /skin (picker), /skin matrix, /skin off | |
| filetree | A live file-tree pane of the project. Marks the files Claude read (R), changed (M) and created (A) this session, and opens the folders they live in. | /filetree, /filetree touched, /filetree all, /filetree close | |
| cache-tax | How well the prompt cache works, and the "tax": the money paid above the ideal where every input token is a cache read. Status line cache 94% · tax $0.12, a per-turn table, and a toast with the likely cause when the cache busts. | /cache-tax, /cache-tax reset | |
| blast-radius | For each file Claude edits, the other files that import it; a flag on wide-reaching shell commands (git reset --hard, rm -rf, force push, ...). Shows only, never blocks. | /blast-radius, /blast-radius session, /blast-radius clear | |
| reflect | Claude critiques its own recent work: what may be wrong or missed, risky assumptions, what to check next. One button puts the follow-ups in your prompt box (it never sends them). Optional auto mode, off by default. | /reflect [focus], /reflect show, `/reflect auto on\ | off` |
| replay-theatre | Records the session and plays it back like a film: scene by scene, step by step, with play/pause, step, speed and a timeline. | /replay, /replay last, /replay copy; keys k play, j/l step, h/n scene, s speed, r restart |
In a Claude Code terminal session, install any mod by name:
/plugin install savvy-progress --marketplace gauravruhela07/claude-mods
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). Repeat for each mod you want: skins, filetree, cache-tax, blast-radius, reflect, replay-theatre.
A mod installed from a terminal at the user scope also loads in the desktop app's Code tab.
git clone https://github.com/gauravruhela07/claude-mods
claude --plugin-dir claude-mods/filetree
reflect calls the model, and only when you run /reflect or turn auto mode on. It asks over the session's own transcript, so most of it is served from the prompt cache.cache-tax/hooks/prices.ts. Claude Code does not report 5-minute and 1-hour cache writes apart, so all writes use the 5-minute price.rg, falling back to grep) of import/require/from/include/use lines. It can miss aliased imports and Go's multi-line import blocks.Each mod folder holds .claude-plugin/plugin.json, hooks/ (the hooks module), types/index.d.ts (its state contract) and tests/.
claude plugin validate filetree
claude plugin test filetreehooks/register.tsx 200 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SkinChoice } from '../types'
5import {
6 asChoice,
7 frameStart,
8 glyphAt,
9 listing,
10 preview,
11 rewordBackground,
12 rewordHint,
13 skinNamed,
14 SKINS,
15 TICK_MS,
16 USAGE,
17 verbAt,
18} from './skins'
19
20const PANE = 'skins'
21
22const chosen = atom({ plugin: 'skins', key: 'skin' } as const, 'off')
23const tick = atom({ plugin: 'skins', key: 'tick' } as const, 0)
24
25/** Sets the skin for this session's drawings and for the sessions after it. */
26const choose = async ($: Pick<EngineInterface, 'state' | 'store'>, choice: SkinChoice) => {
27 await update($, chosen, () => choice)
28 await $.store.set('skin', choice)
29}
30
31export const register: Register = on => {
32 // The animation only ticks while a turn runs; a reload resets this and the next turn sets it again.
33 let isTurnRunning = false
34 let lastTick = -1
35
36 on('session.start', async ($, e, next) => {
37 await $.command.register({
38 name: 'skin',
39 description: 'Swap the look of Claude Code: pick a skin, or /skin off for the default',
40 argumentHint: '[matrix|pirate|zen|retro|space|off]',
41 immediate: true,
42 })
43 const stored = asChoice(await $.store.get('skin'))
44 await update($, chosen, () => stored)
45
46 $.clock.every(TICK_MS, async () => {
47 if (!isTurnRunning) return
48 const skin = skinNamed(await read($, chosen))
49 if (skin === undefined) return
50 const at = frameStart(skin, await $.clock.now())
51 if (at === lastTick) return
52 lastTick = at
53 await update($, tick, () => at)
54 })
55
56 return next(e)
57 })
58
59 on('turn.start', ($, e, next) => {
60 isTurnRunning = true
61
62 return next(e)
63 })
64
65 on('turn.complete', ($, e, next) => {
66 if (e.agentId === undefined) isTurnRunning = false
67
68 return next(e)
69 })
70
71 on('command.run', { command: 'skin' }, async ($, e) => {
72 const words = e.args.trim().toLowerCase().split(/\s+/).filter(word => word !== '')
73
74 if (words.length === 0) {
75 await $.ui.open({ id: PANE, title: 'Skins' })
76
77 return { text: listing(await read($, chosen)) }
78 }
79
80 const word = words.length === 1 ? (words[0] ?? '') : ''
81
82 if (word === 'off') {
83 await choose($, 'off')
84
85 return { text: "Skin off: the engine's default look is back." }
86 }
87
88 const skin = skinNamed(word)
89
90 if (skin === undefined) return { text: USAGE }
91
92 await choose($, skin.name)
93
94 return { text: `Skin set: ${skin.name}` }
95 })
96
97 // The picker: one row per skin, a button to apply it, its name and a sample in its color.
98 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
99 const { Box, Text, Button } = $.ui.resolve(e)
100 const active = await read($, chosen)
101 const width = Math.max(24, e.props.bodyColumns)
102 const mark = (isActive: boolean) => (isActive ? '●' : ' ')
103
104 const rows = SKINS.map((skin, i) => {
105 const isActive = skin.name === active
106
107 return (
108 <Box key={`row-${skin.name}`} flexDirection="row" width={width}>
109 <Button key={`use-${skin.name}`} label="Apply" hotkey={String(i + 1)} onPress={() => choose($, skin.name)} />
110 <Text bold={isActive}>{` ${mark(isActive)} ${skin.name.padEnd(7)} `}</Text>
111 <Text color={skin.color} wrap="truncate-end">
112 {preview(skin)}
113 </Text>
114 </Box>
115 )
116 })
117
118 return (
119 <Box flexDirection="column">
120 {rows}
121 <Box key="row-off" flexDirection="row" width={width}>
122 <Button key="use-off" label="Apply" hotkey="0" onPress={() => choose($, 'off')} />
123 <Text bold={active === 'off'}>{` ${mark(active === 'off')} ${'off'.padEnd(7)} `}</Text>
124 <Text dimColor wrap="truncate-end">
125 the engine's default look
126 </Text>
127 </Box>
128 <Box key="help" width={width}>
129 <Text dimColor wrap="truncate-end">
130 {'● active · /skin <name> works too · kept for your next sessions'}
131 </Text>
132 </Box>
133 </Box>
134 )
135 })
136
137 // The spinner. Its glyph and color are no props, and its time and tokens are in no prop either:
138 // so the verb and suffix are rewritten as props, and the skin's colored glyph is drawn in front
139 // of the engine's own line, which keeps everything it shows.
140 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
141 const skin = skinNamed(await read($, chosen))
142 if (skin === undefined) return next(e)
143
144 const t = await read($, tick)
145 const { word, message, suffix } = e.props
146 // On the desktop the word says what the step does (`Creating notes.md`): real, so kept.
147 const isStepWord = e.surface === 'desktop' && word !== 'Working'
148 const drawn = await next({
149 ...e,
150 props: {
151 ...e.props,
152 word: isStepWord ? word : verbAt(skin, t),
153 suffix: message === null ? skin.suffix : suffix,
154 },
155 })
156 const { Box, Text } = $.ui.resolve(e)
157
158 return (
159 <Box flexDirection="row">
160 <Text color={skin.color}>{glyphAt(skin, t)} </Text>
161 {drawn}
162 </Box>
163 )
164 })
165
166 // `Worked for 12s` -> `Decrypted for 12s`: the word only, the duration stays the engine's.
167 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
168 const skin = skinNamed(await read($, chosen))
169 if (skin === undefined) return next(e)
170
171 return next({ ...e, props: { ...e.props, word: skin.doneWord } })
172 })
173
174 // Only a whole line we know is reworded: a rewrite replaces the line, and pills must stay live.
175 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
176 const skin = skinNamed(await read($, chosen))
177 const hint = skin === undefined ? undefined : rewordHint(skin, e.props.hint)
178 if (hint === undefined) return next(e)
179
180 return next({ ...e, props: { ...e.props, hint } })
181 })
182
183 // `(ctrl+b to run in background)` in the skin's voice, the person's own key kept.
184 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
185 const skin = skinNamed(await read($, chosen))
186 const hint = skin === undefined ? undefined : rewordBackground(skin, e.props.hint)
187 if (hint === undefined) return next(e)
188
189 return next({ ...e, props: { ...e.props, hint } })
190 })
191
192 // The footer's mode labels gain the skin's name; the engine's own labels stay first.
193 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
194 const skin = skinNamed(await read($, chosen))
195 if (skin === undefined) return next(e)
196
197 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, `${skin.name} skin`] } })
198 })
199}
200hooks/skins.ts 195 lines1import type { Skin, SkinChoice, SkinName } from '../types'
2
3/** The animation tick; every skin's `frameMs` is a multiple of it. */
4export const TICK_MS = 250
5
6/** How long one verb stays before the next one rotates in. */
7export const VERB_MS = 4000
8
9export const SKINS: readonly Skin[] = [
10 {
11 name: 'matrix',
12 blurb: 'green code rain',
13 glyphs: ['░', '▒', '▓', '█', '▓', '▒'],
14 frameMs: 250,
15 verbs: [
16 'Decrypting',
17 'Jacking in',
18 'Tracing the call',
19 'Bending the spoon',
20 'Following the white rabbit',
21 'Reading the code rain',
22 'Dodging agents',
23 'Rewriting the construct',
24 'Patching the Matrix',
25 ],
26 suffix: '_',
27 color: 'success',
28 doneWord: 'Decrypted',
29 hints: {
30 shortcuts: "? for the operator's manual",
31 interrupt: 'esc to jack out',
32 background: 'to fork it into the background',
33 },
34 },
35 {
36 name: 'pirate',
37 blurb: 'high seas, plain ASCII',
38 glyphs: ['_', '-', '~', '^', '~', '-'],
39 frameMs: 500,
40 verbs: [
41 'Plundering',
42 'Hoisting the sails',
43 'Swabbing the deck',
44 'Charting a course',
45 'Weighing anchor',
46 'Splicing the mainbrace',
47 'Hunting for treasure',
48 'Battening the hatches',
49 'Raising the Jolly Roger',
50 ],
51 suffix: '...',
52 color: 'warning',
53 doneWord: 'Plundered',
54 hints: {
55 shortcuts: "? for the ship's charts",
56 interrupt: 'esc to drop anchor',
57 background: 'to send it below deck',
58 },
59 },
60 {
61 name: 'zen',
62 blurb: 'slow and quiet',
63 glyphs: ['·', '∙', '•', '∙'],
64 frameMs: 750,
65 verbs: [
66 'Breathing',
67 'Letting go',
68 'Sitting with it',
69 'Noticing',
70 'Being present',
71 'Raking the sand',
72 'Listening',
73 'Settling',
74 'Returning to the breath',
75 ],
76 suffix: '…',
77 color: 'subtle',
78 doneWord: 'Breathed',
79 hints: {
80 shortcuts: '? for gentle guidance',
81 interrupt: 'esc to pause and breathe',
82 background: 'to let it run quietly in the background',
83 },
84 },
85 {
86 name: 'retro',
87 blurb: '8-bit, ALL CAPS',
88 glyphs: ['|', '/', '-', '\\'],
89 frameMs: 250,
90 verbs: [
91 'PROCESSING',
92 'LOADING TAPE',
93 'REWINDING',
94 'COMPILING',
95 'PLEASE WAIT',
96 'READING SECTOR',
97 'INSERT DISK 2',
98 'DIALING UP',
99 'POKING MEMORY',
100 ],
101 suffix: '...',
102 color: 'claude',
103 doneWord: 'PROCESSED',
104 hints: {
105 shortcuts: '? FOR SHORTCUTS',
106 interrupt: 'ESC TO BREAK',
107 background: 'TO RUN IN BACKGROUND',
108 },
109 },
110 {
111 name: 'space',
112 blurb: 'mission control',
113 glyphs: ['·', '✧', '✦', '✧'],
114 frameMs: 500,
115 verbs: [
116 'Launching',
117 'Orbiting',
118 'Docking',
119 'Calibrating thrusters',
120 'Plotting a trajectory',
121 'Scanning the stars',
122 'Reaching escape velocity',
123 'Slingshotting the moon',
124 'Calling ground control',
125 ],
126 suffix: '…',
127 color: 'suggestion',
128 doneWord: 'Landed',
129 hints: {
130 shortcuts: '? for mission control',
131 interrupt: 'esc to abort mission',
132 background: 'to put it in a background orbit',
133 },
134 },
135]
136
137export const NAMES: readonly SkinName[] = SKINS.map(skin => skin.name)
138
139/** The skin of that name; undefined for `off` and for any other word. */
140export const skinNamed = (name: string): Skin | undefined => SKINS.find(skin => skin.name === name)
141
142/** A stored value read back as a choice; anything unknown is `off`. */
143export const asChoice = (value: unknown): SkinChoice =>
144 typeof value === 'string' && skinNamed(value) !== undefined ? (value as SkinName) : 'off'
145
146const at = <T>(list: readonly T[], index: number, fallback: T): T =>
147 list[((index % list.length) + list.length) % list.length] ?? fallback
148
149/** The glyph frame showing at time `tick`. */
150export const glyphAt = (skin: Skin, tick: number): string => at(skin.glyphs, Math.floor(tick / skin.frameMs), '·')
151
152/** The verb showing at time `tick`; it rotates every VERB_MS. */
153export const verbAt = (skin: Skin, tick: number): string => at(skin.verbs, Math.floor(tick / VERB_MS), 'Working')
154
155/** The tick a skin's animation stands at, at time `now`. */
156export const frameStart = (skin: Skin, now: number): number => now - (now % skin.frameMs)
157
158/** The picker's and the listing's sample: the frames, then the first verb. */
159export const preview = (skin: Skin): string => `${skin.glyphs.slice(0, 4).join('')} ${skin.verbs[0] ?? ''}${skin.suffix}`
160
161/**
162 * The hint line in the skin's voice, or undefined when it is not one of the
163 * plain lines we know: a rewrite replaces the whole line, so a line with pills
164 * (live parts) is left to the engine.
165 */
166export const rewordHint = (skin: Skin, hint: string): string | undefined => {
167 const line = hint.trim()
168
169 if (line === '? for shortcuts') return skin.hints.shortcuts
170 if (line === 'esc to interrupt') return skin.hints.interrupt
171
172 return undefined
173}
174
175/** `(ctrl+b to run in background)` in the skin's voice, the person's key kept. */
176export const rewordBackground = (skin: Skin, hint: string): string | undefined => {
177 const match = /^\((.+?) to run in background\)$/.exec(hint.trim())
178
179 return match === null ? undefined : `(${match[1]} ${skin.hints.background})`
180}
181
182export const USAGE = `Usage: /skin (pick one), /skin <${NAMES.join('|')}>, or /skin off. Nothing changed.`
183
184/** The command's text answer: every skin, its sample, the active one marked. */
185export const listing = (active: SkinChoice): string => {
186 const rows = SKINS.map(
187 skin => `- ${skin.name}${skin.name === active ? ' (active)' : ''}: \`${preview(skin)}\` ${skin.blurb}`,
188 )
189 rows.push(`- off${active === 'off' ? ' (active)' : ''}: the engine's default look`)
190
191 return [`Skins:`, '', ...rows, '', 'Apply one with /skin <name> or a button in the Skins pane; /skin off goes back.'].join(
192 '\n',
193 )
194}
195types/index.d.ts 47 lines1/** The skins this mod ships. */
2export type SkinName = 'matrix' | 'pirate' | 'zen' | 'retro' | 'space'
3
4/** What `/skin` can be set to: a skin, or `off` for the engine's own look. */
5export type SkinChoice = SkinName | 'off'
6
7/** The theme keys a skin draws in (they follow the person's theme). */
8export type SkinColor = 'success' | 'warning' | 'subtle' | 'claude' | 'suggestion'
9
10/** One skin: how the spinner, the closing line and the hints look and talk. */
11export type Skin = {
12 name: SkinName
13 /** One line for the picker and the listing. */
14 blurb: string
15 /** The spinner's frames, single-cell glyphs (no emoji). */
16 glyphs: readonly string[]
17 /** How long one frame shows, in ms (a multiple of the 250 ms tick). */
18 frameMs: number
19 /** Present-tense verbs the spinner rotates through (8 or more). */
20 verbs: readonly string[]
21 /** Drawn right after the verb in place of the engine's ellipsis. */
22 suffix: string
23 color: SkinColor
24 /** Past-tense word of the closing line: `<doneWord> for 12s`. */
25 doneWord: string
26 /** Hint lines reworded in the skin's voice; the key named stays the same. */
27 hints: {
28 /** Replaces the whole `? for shortcuts` line. */
29 shortcuts: string
30 /** Replaces the whole `esc to interrupt` line. */
31 interrupt: string
32 /** `(<key> <background>)` replaces `(<key> to run in background)`. */
33 background: string
34 }
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 'skins': {
40 /** The skin in use, mirrored from `$.store` key `skin`. */
41 skin: SkinChoice
42 /** The animation clock: ms since the epoch, floored to the skin's frame. */
43 tick: number
44 }
45 }
46}
47