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…

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

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).
◐ Tokyo Night). Catppuccin Latte, a light theme, is left out of the rotation; pick it by name.~/.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.| Command | Does |
|---|---|
/themes | Lists 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 rotate | Back to a new theme for each session (the default) |
/themes off | Stops switching and restores the theme you had before |
The themes also appear in /theme, marked as from themes.
Palettes live in scripts/build-themes.py; run python3 scripts/build-themes.py to rewrite themes/*.json.
hooks/register.ts 169 lines1import { 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}
169hooks/themes.ts 44 lines1/** 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}
44types/index.d.ts 12 lines1/** 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