SLOPSHOPPER

skins

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

newpanespinnercommandtimer
v0.1.0no licenseupdated 2026-10-10gauravruhela07/claude-mods/skins
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skins
│ ┃ Skins ✕ › fix the failing auth test and add an audit log call │ ┃ [ Apply ] matrix ░▒▓█ Decrypting_ │ ┃ [ Apply ] pirate _-~^ Plundering... ⏺ Read(src/auth.ts) │ ┃ [ Apply ] zen ·∙•∙ Breathing… ⎿ Read 6 lines │ ┃ [ Apply ] retro |/-\ PROCESSING... ⏺ Update(src/auth.ts) │ ┃ [ Apply ] space ·✧✦✧ Launching… ⎿ Added 2 lines, removed 1 line │ ┃ [ Apply ] ● off the engine's default loo ⏺ Bash(bun test) │ ┃ ● active · /skin <name> works too · kept for ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /skin │ ⎿ skins: Skins: │ ⎿ skins: │ ⎿ skins: - matrix: `░▒▓█ Decrypting_` green code rain │ ⎿ skins: - pirate: `_-~^ Plundering...` high seas, plain ASCII │ ⎿ skins: - zen: `·∙•∙ Breathing…` slow and quiet │ ⎿ skins: - retro: `|/-\ PROCESSING...` 8-bit, ALL CAPS │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Skins
[ Apply ] matrix ░▒▓█ Decrypting_ [ Apply ] pirate _-~^ Plundering... [ Apply ] zen ·∙•∙ Breathing… [ Apply ] retro |/-\ PROCESSING... [ Apply ] space ·✧✦✧ Launching… [ Apply ] ● off the engine's default look ● active · /skin <name> works too · kept for your next …
README

claude-mods

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.

ModWhat it doesHow to use it
savvy-progressA 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]`
skinsRe-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
filetreeA 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-taxHow 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-radiusFor 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
reflectClaude 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-theatreRecords 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

Install

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.

Try one from a clone, without installing

git clone https://github.com/gauravruhela07/claude-mods
claude --plugin-dir claude-mods/filetree

Notes

  • Cost: only 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 prices are first-party API list prices as of 2026-10-11, in 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.
  • blast-radius finds dependents with a text search (rg, falling back to grep) of import/require/from/include/use lines. It can miss aliased imports and Go's multi-line import blocks.
  • These mods use the Claude Code function-hooks API, which is early access and can change between releases. They were built and tested on Claude Code 2.1.29x.

Develop

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 filetree
Source 3 files
hooks/register.tsx 200 lines
1import { 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}
200
hooks/skins.ts 195 lines
1import 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}
195
types/index.d.ts 47 lines
1/** 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