SLOPSHOPPER

themes

Color themes for Claude Code (Catppuccin, Dracula, Nord, Tokyo Night, Gruvbox, Rosé Pine and more). Each new session takes the next theme, so sessions are easy…

newspinnercommandtoastpromptprocess
★ 3v0.1.0MITupdated 2026-10-15omeerdvrn/o-mods/mods/themes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · themes
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /themes ⎿ themes: This session: Catppuccin Mocha. Each new session opens in the next theme of the rotation. ⎿ themes: ⎿ themes: ● catppuccin-mocha Catppuccin Mocha ⎿ themes: ○ tokyo-night Tokyo Night ⎿ themes: ○ gruvbox-dark Gruvbox Dark ⎿ themes: ○ nord Nord ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

themes

Color themes for the Claude Code interface, applied automatically when a session starts.

Typing /themes changes the colors of the whole interface, from Catppuccin to Dracula, Gruvbox, Synthwave and Nord. The footer names each theme.

Themes: Catppuccin Mocha, Macchiato, Frappé and Latte, Tokyo Night, Gruvbox Dark, Nord, Dracula, Rosé Pine, Everforest, Kanagawa, One Dark, Solarized Dark, Synthwave '84 (pairs with night-drive).

How it works

  • Each new session opens in the next theme of the rotation, so side-by-side sessions are easy to tell apart. The footer names the session's theme (◐ Tokyo Night). Catppuccin Latte, a light theme, is left out of the rotation; pick it by name.
  • Claude Code's config API takes only its built-in themes, so the switcher works the way custom themes do: it writes one theme file, ~/.claude/themes/themes.json, and points the theme setting in ~/.claude/settings.json at custom:themes (once; the old file is kept as settings.json.themes.bak). Claude Code reloads that file live when it changes. The very first time, when ~/.claude/themes did not exist yet, restart Claude Code once.
  • Claude Code keeps one theme setting for the whole machine. When another session changes it, the session you type in takes its own theme back on your next prompt.
  • The themes color Claude's interface (text, accents, borders, diffs, message backgrounds). The terminal's own background stays as your terminal sets it.

Commands

CommandDoes
/themesLists the themes and shows this session's
/themes <name>Switches this session (/themes mocha, /themes tokyo night)
/themes pin <name>Every new session opens in that theme
/themes rotateBack to a new theme for each session (the default)
/themes offStops switching and restores the theme you had before

The themes also appear in /theme, marked as from themes.

Editing the themes

Palettes live in scripts/build-themes.py; run python3 scripts/build-themes.py to rewrite themes/*.json.

Source 3 files
hooks/register.ts 169 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { ROTATION, THEMES, findTheme, nameOf } from './themes'
5import type { Slug } from './themes'
6
7/** `rotate`: each new session takes the next theme. `pinned`: every session takes one. `off`: hands off. */
8type Mode = 'rotate' | 'pinned' | 'off'
9
10const MODE_KEY = 'mode'
11const PINNED_KEY = 'pinned'
12const NEXT_KEY = 'next'
13/** The theme setting from before the switcher first changed it, put back by /themes off. */
14const ORIGINAL_KEY = 'original'
15/** The theme now in ~/.claude/themes/themes.json, shared by every session. */
16const APPLIED_KEY = 'applied'
17/** The theme setting that names the switcher's theme file. */
18const SETTING = 'custom:themes'
19
20const current = atom({ plugin: 'themes', key: 'current' } as const, null as string | null)
21
22async function modeOf($: EngineInterface): Promise<Mode> {
23  const mode = await $.store.get(MODE_KEY)
24  return mode === 'pinned' || mode === 'off' ? mode : 'rotate'
25}
26
27async function themeSetting($: EngineInterface): Promise<string | undefined> {
28  const row = (await $.config.list()).find(one => one.key === 'theme')
29  return typeof row?.value === 'string' ? row.value : undefined
30}
31
32function scriptOf($: EngineInterface): string {
33  return `${$.plugin.root.replace(/\/\.claude-plugin\/?$/, '')}/scripts/apply.py`
34}
35
36/**
37 * Makes `slug` the theme. Claude Code's config API takes only its built-in themes, so the theme goes the way
38 * custom themes do: one file in ~/.claude/themes that the switcher rewrites (Claude Code reloads it live),
39 * and the theme setting pointed at it once. Remembers the person's own theme the first time.
40 * Returns why it failed, or undefined.
41 */
42async function apply($: EngineInterface, slug: string): Promise<string | undefined> {
43  if ((await $.store.get(APPLIED_KEY)) === slug && (await themeSetting($)) === SETTING) return undefined
44  const before = await themeSetting($)
45  if (before !== undefined && !before.startsWith('custom:themes') && (await $.store.get(ORIGINAL_KEY)) === undefined) {
46    await $.store.set(ORIGINAL_KEY, before)
47  }
48  const ran = await $.process.run(['python3', scriptOf($), 'use', slug], { timeoutMs: 10_000 })
49  if (ran.exitCode !== 0) return ran.stderr.trim().split('\n').pop() || 'the theme file could not be written'
50  await $.store.set(APPLIED_KEY, slug)
51  // Claude Code watches ~/.claude/themes only when it existed at startup: the first time, it needs one restart.
52  if (ran.stdout.includes('created themes folder')) {
53    await $.ui.toast('Themes are installed. Restart Claude Code once; from then on they switch live.')
54  }
55  return undefined
56}
57
58/** The theme a new session opens with: the pinned one, or the next in the rotation. */
59async function themeForNewSession($: EngineInterface): Promise<Slug | null> {
60  const mode = await modeOf($)
61  if (mode === 'off') return null
62  if (mode === 'pinned') {
63    const pinned = findTheme(String((await $.store.get(PINNED_KEY)) ?? ''))
64    if (pinned) return pinned
65  }
66  const next = Number((await $.store.get(NEXT_KEY)) ?? 0) || 0
67  await $.store.set(NEXT_KEY, (next + 1) % ROTATION.length)
68  return ROTATION[next % ROTATION.length]!
69}
70
71async function useTheme($: EngineInterface, slug: string): Promise<string | undefined> {
72  await update($, current, () => slug)
73  return apply($, slug)
74}
75
76function listText(mine: string | null, mode: Mode, pinned: unknown): string {
77  const rows = THEMES.map(theme => `${theme.slug === mine ? '●' : '○'} ${theme.slug.padEnd(22)}${theme.name}`)
78  const how =
79    mode === 'off'
80      ? 'Switcher is off: sessions keep your own theme.'
81      : mode === 'pinned'
82        ? `Every new session opens in ${nameOf(String(pinned))}.`
83        : 'Each new session opens in the next theme of the rotation.'
84  return [
85    `This session: ${mine ? nameOf(mine) : 'your own theme'}. ${how}`,
86    '',
87    ...rows,
88    '',
89    '/themes <theme> for this session · /themes pin <theme> for every session · /themes rotate · /themes off',
90  ].join('\n')
91}
92
93export const register: Register = on => {
94  on('session.start', async ($, e, next) => {
95    // Not awaited: registering makes Claude Code re-describe every command (~0.7 s), which would hold startup.
96    // Typed as /themes in the terminal; the command file is what the desktop app lists in its menu.
97    void $.command.register({
98      name: 'themes',
99      description: 'Color theme for this session, or pin one for every session',
100      argumentHint: '[list|<theme>|pin <theme>|rotate|off]',
101    }).catch(() => undefined)
102    // A reload keeps the session's theme; only a new session takes a new one.
103    const kept = await read($, current)
104    const slug = kept ?? (await themeForNewSession($))
105    // Before the session finishes starting (about 50 ms), so it opens in its theme instead of relying on a
106    // live reload of the theme file.
107    if (slug !== null) {
108      const failed = await useTheme($, slug).catch(error => String(error))
109      if (failed) void $.ui.toast(`Theme not applied: ${failed}`)
110    }
111
112    return next(e)
113  })
114
115  // The theme setting is shared by every session on the machine: the one you type in takes its own back.
116  on('prompt.submit', async ($, e, next) => {
117    const mine = await read($, current)
118    if (mine !== null) await apply($, mine).catch(() => undefined)
119
120    return next(e)
121  }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
122
123  on('command.run', { command: ['themes', 'themes:themes'] }, async ($, e) => {
124    const [verb = '', ...rest] = e.args.trim().split(/\s+/)
125    const mode = await modeOf($)
126    const mine = await read($, current)
127
128    if (verb === '' || verb === 'list') return { text: listText(mine, mode, await $.store.get(PINNED_KEY)) }
129
130    if (verb === 'off') {
131      await $.store.set(MODE_KEY, 'off')
132      await update($, current, () => null)
133      const original = await $.store.get(ORIGINAL_KEY)
134      const back = typeof original === 'string' ? original : 'dark'
135      await $.store.set(APPLIED_KEY, null)
136      await $.process.run(['python3', scriptOf($), 'restore', back], { timeoutMs: 10_000 })
137      return { text: `Switcher off. Theme back to ${back}.` }
138    }
139
140    if (verb === 'rotate') {
141      await $.store.set(MODE_KEY, 'rotate')
142      return { text: 'Each new session now opens in the next theme of the rotation.' }
143    }
144
145    if (verb === 'pin') {
146      const slug = findTheme(rest.join(' '))
147      if (!slug) return { text: `No theme "${rest.join(' ')}". Run /themes to see them.` }
148      await $.store.set(MODE_KEY, 'pinned')
149      await $.store.set(PINNED_KEY, slug)
150      const refused = await useTheme($, slug)
151      return { text: refused ? `Pinned ${nameOf(slug)}, but the theme was not changed: ${refused}` : `Every session now opens in ${nameOf(slug)}.` }
152    }
153
154    const slug = findTheme(e.args)
155    if (!slug) return { text: `No theme "${e.args.trim()}". Run /themes to see them.` }
156    if (mode === 'off') await $.store.set(MODE_KEY, 'rotate')
157    const refused = await useTheme($, slug)
158    return { text: refused ? `Theme not changed: ${refused}` : `This session is now ${nameOf(slug)}.` }
159  })
160
161  // The footer names the session's theme, so it is clear which is which.
162  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
163    const mine = await read($, current)
164    if (mine === null) return next(e)
165
166    return next({ ...e, props: { ...e.props, modes: [...e.props.modes, `◐ ${nameOf(mine)}`] } })
167  })
168}
169
hooks/themes.ts 44 lines
1/** The themes this mod ships in themes/<slug>.json, in rotation order. */
2export const THEMES = [
3  { slug: 'catppuccin-mocha', name: 'Catppuccin Mocha' },
4  { slug: 'tokyo-night', name: 'Tokyo Night' },
5  { slug: 'gruvbox-dark', name: 'Gruvbox Dark' },
6  { slug: 'nord', name: 'Nord' },
7  { slug: 'dracula', name: 'Dracula' },
8  { slug: 'rose-pine', name: 'Rosé Pine' },
9  { slug: 'everforest', name: 'Everforest' },
10  { slug: 'kanagawa', name: 'Kanagawa' },
11  { slug: 'one-dark', name: 'One Dark' },
12  { slug: 'catppuccin-macchiato', name: 'Catppuccin Macchiato' },
13  { slug: 'solarized-dark', name: 'Solarized Dark' },
14  { slug: 'catppuccin-frappe', name: 'Catppuccin Frappé' },
15  { slug: 'synthwave', name: "Synthwave '84" },
16  { slug: 'catppuccin-latte', name: 'Catppuccin Latte', isLight: true },
17] as const
18
19export type Slug = (typeof THEMES)[number]['slug']
20
21/** Light themes sit out the rotation: on a dark terminal their text would be dark on dark. */
22export const ROTATION: readonly Slug[] = THEMES.filter(theme => !('isLight' in theme)).map(theme => theme.slug)
23
24export const SETTING_PREFIX = 'custom:themes:'
25
26export function settingFor(slug: string): string {
27  return `${SETTING_PREFIX}${slug}`
28}
29
30export function nameOf(slug: string): string {
31  return THEMES.find(theme => theme.slug === slug)?.name ?? slug
32}
33
34/** A theme by slug, or by a loose spelling of its name ("mocha", "Tokyo Night", "rosepine"). */
35export function findTheme(asked: string): Slug | undefined {
36  const plain = (text: string) => text.toLowerCase().normalize('NFD').replace(/[^a-z0-9]/g, '')
37  const wanted = plain(asked)
38  if (wanted === '') return undefined
39  const exact = THEMES.find(theme => plain(theme.slug) === wanted || plain(theme.name) === wanted)
40  if (exact) return exact.slug
41  const partial = THEMES.filter(theme => plain(theme.slug).includes(wanted))
42  return partial.length === 1 ? partial[0]!.slug : undefined
43}
44
types/index.d.ts 12 lines
1/** A theme's slug, as themes/<slug>.json names it. */
2export type ThemeSlug = string
3
4declare module 'claude-code' {
5  interface PluginState {
6    'themes': {
7      /** This session's theme slug; null when the switcher is off. */
8      current: ThemeSlug | null
9    }
10  }
11}
12