Switchable themes for Claude Code: padded prompt/reply boxes, coloured markdown and a matching status line. /theme to switch.

Switchable themes for the Claude Code terminal UI:
inline code, bold, links, bullets, quotes)/ui-theme to pick a theme from a list and switch everything at once<table> <tr> <th>Before: Claude Code default</th> <th>After: <code>claude-warm</code></th> </tr> <tr> <td><img src="docs/before.png" alt="Claude Code without ui-themes"></td> <td><img src="docs/after-claude-warm.png" alt="Claude Code with the claude-warm theme"></td> </tr> </table>
Replies get a padded charcoal box and themed markdown, prompts get a padded terracotta box, and the status line follows the theme.
<b>Also included: <code>night-blue</code></b>, one <code>/ui-theme next</code> away<br><br> <img src="docs/after-night-blue.png" alt="Claude Code with the night-blue theme" width="50%">
I like coding agents like Pi, OpenCode and Oh My Pi. Their terminal UIs are clean, structured and light. Claude Code's felt messy and heavy next to them, and I didn't enjoy using it.
But I still use Claude Code. Claude's models are what I want to work with, and my Claude subscription only works through the Claude Code CLI, not through other harnesses. So I was stuck with a UI I didn't like.
When Claude Code added mods, I started tuning it to feel more like those agents: padded message boxes, readable markdown colours, a compact status line, and themes I can switch in one command. It isn't a copy of any of them, and it isn't finished, but it's already much nicer to live in every day.
If you're in the same spot, using Claude Code for the models while missing the feel of other agent CLIs, this plugin is for you.
Ships with three themes:
| Theme | Look |
|---|---|
claude-warm (default) | Terracotta prompts, charcoal replies, Claude orange and honey text |
night-blue | Navy prompts, near-black replies, bright blue and cyan text |
titanium | Oh My Pi inspired: steel blue prompts, brushed titanium replies |
/plugin marketplace add yugal1107/ui-themes
/plugin install ui-themes@ui-themes
Restart Claude Code once after installing.
| Command | What it does |
|---|---|
/ui-theme | Open a picker: arrow keys to choose, Enter to apply, Esc to close |
/ui-theme list | Show the current theme and the available ones as text |
/ui-theme <name> | Switch theme |
/ui-theme next | Cycle to the next theme |
/ui-theme statusline | Show how to enable the matching status line |
/ui-theme statusline update | Apply a new status line script from a plugin update (your old copy is kept as .bak) |
The status line is optional. Add this to ~/.claude/settings.json:
"statusLine": { "type": "command", "command": "bash ~/.claude/ui-themes/statusline.sh" }
It needs jq, and its icons need a Nerd Font in your terminal.
The script is copied to ~/.claude/ui-themes/statusline.sh once and is never overwritten by plugin updates, because it runs with your permissions on every refresh. When an update ships a new version, you get a message at startup; run /ui-theme statusline update to apply it. Your edits to the script are kept until you do.
Themes live in ~/.claude/ui-themes/<name>.json (the built-in ones are copied there on first start). On Windows that is %USERPROFILE%\.claude\ui-themes. Copy one, rename it and change the colours; it shows up in /ui-theme right away.
{
"name": "claude-warm",
"prompt": { "bg": "#4a2a1c" },
"reply": { "bg": "#1a1a1a" },
"markdown": {
"code": "#e0a458", "bold": "#f5e6d3", "heading": "#d97757", "link": "#d97757",
"bullet": "#d97757", "quote": "#a8998a", "rule": "#3a3029"
},
"statusline": {
"model": "#d97757", "modelOpus": "#d97757", "modelSonnet": "#e0a458",
"modelHaiku": "#a3b18a", "modelFable": "#c49bbb", "dir": "#f5e6d3", "git": "#a3b18a", "dirty": "#e0a458",
"label": "#a8998a", "muted": "#8a7b6e", "ok": "#a3b18a", "warn": "#e0a458",
"hot": "#e5534b", "sep": "#5c4a3d"
}
}
/theme is Claude Code's own command and is left untouched; this plugin uses /ui-theme.MIT © 2026 Yugal Burde
hooks/register.tsx 242 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { MD, renderMarkdown } from './markdown'
4
5// Themes live in ~/.claude/ui-themes/<name>.json and the active name in ~/.claude/ui-themes/active.
6// The built-in themes and the status line script are copied there on first start and never
7// overwritten. When a plugin update ships a different status line script, the person is told and
8// applies it with `/ui-theme statusline update` (#2). The status line script reads the same files.
9
10type Theme = {
11 name: string
12 prompt: { bg: string }
13 reply: { bg: string }
14 markdown: Partial<typeof MD>
15}
16
17const theme: Theme = {
18 name: 'claude-warm',
19 prompt: { bg: '#4a2a1c' },
20 reply: { bg: '#1a1a1a' },
21 markdown: {},
22}
23
24let dir = ''
25
26// Windows has no HOME; the home folder is in USERPROFILE (#4). With neither set, stop rather
27// than write to the root of the current drive; the built-in theme still renders.
28async function themesDir($: EngineInterface) {
29 if (!dir) {
30 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
31 if (!home) throw new Error('no home folder: neither HOME nor USERPROFILE is set')
32 dir = `${home}/.claude/ui-themes`
33 }
34 return dir
35}
36
37// Copy built-in themes and the status line script if missing; never overwrite the person's copies.
38async function seed($: EngineInterface) {
39 const d = await themesDir($)
40 const shipped = `${$.plugin.root}/themes`
41 for (const f of await $.fs.list(shipped)) {
42 if (f.name.endsWith('.json') && !(await $.fs.exists(`${d}/${f.name}`))) {
43 await $.fs.write(`${d}/${f.name}`, await $.fs.read(`${shipped}/${f.name}`))
44 }
45 }
46 if (!(await $.fs.exists(`${d}/statusline.sh`))) await installStatusline($)
47 if (!(await $.fs.exists(`${d}/active`))) await $.fs.write(`${d}/active`, 'claude-warm\n')
48}
49
50// --- status line script: installed once, updated only on request (#2) ---
51
52// The marker holds a hash of the shipped script last installed, so an update is told apart
53// from the person's own edits to their copy.
54const MARKER = '.statusline.installed'
55
56function hash(text: string) {
57 let h = 5381
58 for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) >>> 0
59 return h.toString(16)
60}
61
62async function pluginVersion($: EngineInterface) {
63 try { return (JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string }).version ?? '?' } catch { return '?' }
64}
65
66async function shippedStatusline($: EngineInterface) {
67 return await $.fs.read(`${$.plugin.root}/statusline/statusline.sh`)
68}
69
70async function installStatusline($: EngineInterface) {
71 const d = await themesDir($)
72 const text = await shippedStatusline($)
73 await $.fs.write(`${d}/statusline.sh`, text)
74 await $.fs.write(`${d}/${MARKER}`, hash(text) + '\n')
75}
76
77// 'none': nothing to do; 'available': the plugin ships a script the person hasn't applied yet
78async function statuslineUpdate($: EngineInterface): Promise<'none' | 'available'> {
79 const d = await themesDir($)
80 const shipped = hash(await shippedStatusline($))
81 if (await $.fs.exists(`${d}/${MARKER}`)) {
82 return (await $.fs.read(`${d}/${MARKER}`)).trim() === shipped ? 'none' : 'available'
83 }
84 // no marker yet (installed by an older version): identical copy → record it silently
85 const current = (await $.fs.exists(`${d}/statusline.sh`)) ? hash(await $.fs.read(`${d}/statusline.sh`)) : ''
86 if (current === shipped) {
87 await $.fs.write(`${d}/${MARKER}`, shipped + '\n')
88 return 'none'
89 }
90 return 'available'
91}
92
93// Line counts of what an update changes, without a full diff
94function changeSummary(before: string, after: string) {
95 const a = before.split('\n'), b = after.split('\n')
96 const inA = new Set(a), inB = new Set(b)
97 return { added: b.filter(l => !inA.has(l)).length, removed: a.filter(l => !inB.has(l)).length }
98}
99
100async function load($: EngineInterface, name?: string) {
101 const d = await themesDir($)
102 const active = (name ?? (await $.fs.read(`${d}/active`))).trim()
103 const t = JSON.parse(await $.fs.read(`${d}/${active}.json`)) as Theme
104 Object.assign(theme, t)
105 Object.assign(MD, t.markdown)
106 return active
107}
108
109async function themeNames($: EngineInterface) {
110 return (await $.fs.list(await themesDir($)))
111 .map(f => f.name)
112 .filter(n => n.endsWith('.json'))
113 .map(n => n.slice(0, -5))
114 .sort()
115}
116
117async function applyTheme($: EngineInterface, name: string) {
118 await load($, name)
119 await $.fs.write(`${await themesDir($)}/active`, name + '\n')
120 $.ui.invalidate('ui.render')
121}
122
123const PICKER = 'ui-theme-picker'
124
125export const register: Register = on => {
126 on('session.start', async ($, e, next) => {
127 try {
128 await seed($)
129 await load($)
130 } catch {}
131 try {
132 if ((await statuslineUpdate($)) === 'available') {
133 $.ui.toast(`ui-themes ${await pluginVersion($)} has a new status line script. Run /ui-theme statusline update to review and apply it.`)
134 }
135 } catch {}
136 $.ui.invalidate('ui.render')
137 await $.command.register({
138 name: 'ui-theme',
139 description: 'Switch the UI theme (message boxes, reply colours, status line)',
140 argumentHint: '[name | next | list | statusline [update]]',
141 })
142 return next(e)
143 })
144
145 on('command.run', { command: 'ui-theme' }, async ($, e) => {
146 try {
147 const d = await themesDir($)
148 const names = await themeNames($)
149 const list = names.map(n => (n === theme.name ? `**${n}**` : `\`${n}\``)).join(' · ')
150 let want = e.args.trim()
151 if (!want) {
152 // no name: open a picker the person moves through with the arrow keys
153 await $.ui.open({ id: PICKER, title: 'UI theme', focus: true, closeOnEscape: true, rows: names.length + 4 })
154 return {}
155 }
156 if (want === 'list') {
157 return { text: `Theme: **${theme.name}**\n\n${list}\n\n\`/ui-theme\` to pick from a list, \`/ui-theme <name>\` to switch, \`/ui-theme next\` to cycle, \`/ui-theme statusline\` to set up the matching status line.\nAdd your own by copying a file in \`${d}\`.` }
158 }
159 if (want === 'next') want = names[(names.indexOf(theme.name) + 1) % names.length] ?? theme.name
160 if (want === 'statusline' || want === 'statusline update') {
161 const installed = `${d}/statusline.sh`
162 const state = await statuslineUpdate($)
163 if (want === 'statusline') {
164 const pending = state === 'available'
165 ? `\n\n**Update available:** ui-themes ${await pluginVersion($)} ships a different script. Run \`/ui-theme statusline update\` to review and apply it.`
166 : ''
167 return { text: `To use the themed status line, add this to \`~/.claude/settings.json\`:\n\n\`\`\`json\n"statusLine": { "type": "command", "command": "bash ${installed}" }\n\`\`\`\n\nIt needs \`jq\` and looks best with a Nerd Font. Plugin updates never change this file on their own.${pending}` }
168 }
169 if (state === 'none') return { text: `Your status line script is up to date (\`${installed}\`).` }
170 const before = (await $.fs.exists(installed)) ? await $.fs.read(installed) : ''
171 const after = await shippedStatusline($)
172 const { added, removed } = changeSummary(before, after)
173 if (before) await $.fs.write(`${installed}.bak`, before)
174 await installStatusline($)
175 return { text: `Status line script updated to the version in ui-themes ${await pluginVersion($)}: ${added} line(s) added, ${removed} removed.${before ? `\n\nYour previous copy is saved as \`${installed}.bak\`.` : ''}\nReview it any time: \`${installed}\`` }
176 }
177 if (!names.includes(want)) {
178 return { text: `No theme \`${want}\`. Available: ${list}` }
179 }
180 await applyTheme($, want)
181 return { text: `Theme switched to **${want}**. The status line follows on its next refresh.` }
182 } catch (err) {
183 return { text: `Theme error: ${String(err)}` }
184 }
185 })
186
187 // The picker: one row per theme with its description; Enter applies it, Esc closes
188 on('ui.render', { component: 'Pane', requestId: PICKER }, async ($, e) => {
189 if (e.surface === 'mobile') {
190 const { Text } = $.ui.resolve(e)
191 return <Text>Themes: {(await themeNames($)).join(', ')}. Use {'/ui-theme <name>'} to switch.</Text>
192 }
193 const { Box, Text, Select } = $.ui.resolve(e)
194 const d = await themesDir($)
195 const options = []
196 for (const n of await themeNames($)) {
197 let about = ''
198 try { about = (JSON.parse(await $.fs.read(`${d}/${n}.json`)) as { description?: string }).description ?? '' } catch {}
199 options.push({ value: n, label: about ? `${n} — ${about}` : n })
200 }
201 return (
202 <Box flexDirection="column" paddingX={1}>
203 <Select
204 key="theme"
205 autoFocus
206 options={options}
207 value={theme.name}
208 onSelect={async (value: string) => {
209 await applyTheme($, value)
210 await $.ui.close({ id: PICKER })
211 $.ui.toast(`Theme: ${value}`)
212 }}
213 />
214 <Text dimColor>↑/↓ to choose · Enter to apply · Esc to close</Text>
215 </Box>
216 )
217 })
218
219 // Your prompts: padded box in the theme's prompt colour
220 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
221 // skip task notifications and messages from other agents; style everything you sent (live or resumed)
222 if (e.props.task || e.props.from) return next(e)
223 const { Box, Text } = $.ui.resolve(e)
224 return (
225 <Box backgroundColor={theme.prompt.bg} paddingX={2} paddingY={1} marginTop={1}>
226 <Text>{e.props.text}</Text>
227 </Box>
228 )
229 })
230
231 // Claude's replies: padded box in the theme's reply colour, markdown in theme colours
232 on('ui.render', { component: 'AssistantMessage' }, ($, e) => {
233 const t = $.ui.resolve(e)
234 const { Box } = t
235 return (
236 <Box backgroundColor={theme.reply.bg} paddingX={2} paddingY={1} marginTop={1}>
237 {renderMarkdown(e.props.text, t)}
238 </Box>
239 )
240 })
241}
242hooks/markdown.tsx 156 lines1// A small markdown renderer with our own colours, for replies.
2// Tables fall back to the engine's Markdown element.
3
4// Filled from the active theme (~/.claude/ui-themes/<name>.json) by register.tsx
5export const MD: Record<'code' | 'bold' | 'heading' | 'link' | 'bullet' | 'quote' | 'rule', string> = {
6 code: '#e0a458',
7 bold: '#f5e6d3',
8 heading: '#d97757',
9 link: '#d97757',
10 bullet: '#d97757',
11 quote: '#a8998a',
12 rule: '#3a3029',
13}
14
15type El = any
16type Table = { Box: any; Text: any; Link: any; Code: any; Markdown: any }
17
18// `code`, **bold**, *italic* / _italic_, [text](url)
19const INLINE = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\[[^\]\n]+\]\([^)\s]+\))|(\*[^*\s][^*\n]*\*)|(\b_[^_\n]+_\b)/g
20
21export function inline(s: string, t: Table, keyBase: string): El[] {
22 const { Text, Link } = t
23 const out: El[] = []
24 let last = 0
25 let k = 0
26 for (const m of s.matchAll(INLINE)) {
27 const at = m.index ?? 0
28 if (at > last) out.push(s.slice(last, at))
29 const tok = m[0]
30 const key = `${keyBase}-${k++}`
31 if (m[1]) out.push(<Text key={key} color={MD.code}>{tok.slice(1, -1)}</Text>)
32 else if (m[2]) out.push(<Text key={key} bold color={MD.bold}>{tok.slice(2, -2)}</Text>)
33 else if (m[3]) {
34 const [, label, href] = tok.match(/^\[([^\]]+)\]\(([^)]+)\)$/) ?? []
35 out.push(/^(https?|file):/.test(href ?? '')
36 ? <Link key={key} href={href}><Text color={MD.link} underline>{label}</Text></Link>
37 : <Text key={key} color={MD.link} underline>{label}</Text>)
38 } else out.push(<Text key={key} italic>{tok.slice(1, -1)}</Text>)
39 last = at + tok.length
40 }
41 if (last < s.length) out.push(s.slice(last))
42 return out
43}
44
45// tone: colour for plain text (paragraphs, list items); quotes pass MD.quote
46export function renderMarkdown(src: string, t: Table, tone?: string): El {
47 const { Box, Text, Code, Markdown } = t
48 const lines = src.replace(/\s+$/, '').split('\n')
49 const blocks: El[] = []
50 let i = 0
51 const push = (el: El) => blocks.push(
52 <Box key={`b${blocks.length}`} marginTop={blocks.length ? 1 : 0} flexDirection="column">{el}</Box>,
53 )
54
55 while (i < lines.length) {
56 const line = lines[i] ?? ''
57
58 if (!line.trim()) { i++; continue }
59
60 // ```lang fenced code
61 const fence = line.match(/^\s*```(\S*)/)
62 if (fence) {
63 const body: string[] = []
64 i++
65 while (i < lines.length && !/^\s*```\s*$/.test(lines[i] ?? '')) body.push(lines[i++] ?? '')
66 i++
67 push(<Code source={body.join('\n')} language={fence[1] || undefined} />)
68 continue
69 }
70
71 // tables: hand to the engine
72 if (/^\s*\|/.test(line)) {
73 const rows: string[] = []
74 while (i < lines.length && /^\s*\|/.test(lines[i] ?? '')) rows.push(lines[i++] ?? '')
75 push(<Markdown text={rows.join('\n')} />)
76 continue
77 }
78
79 // # headings
80 const head = line.match(/^ {0,3}(#{1,6})\s+(.*)$/) // up to 3 spaces of indent, as in markdown
81 if (head) {
82 push(<Text bold color={MD.heading}>{inline(head[2] ?? '', t, `h${i}`)}</Text>)
83 i++
84 continue
85 }
86
87 // --- rule
88 if (/^\s*([-*_])\s*(\1\s*){2,}$/.test(line)) {
89 push(<Text color={MD.rule}>{'─'.repeat(40)}</Text>)
90 i++
91 continue
92 }
93
94 // > quote: plain prose keeps the │ bar; a quote with structure (list, heading,
95 // blank line, code) is markdown again, drawn in a frame in the quote colour
96 if (/^\s*>/.test(line)) {
97 const q: string[] = []
98 while (i < lines.length && /^\s*>/.test(lines[i] ?? '')) q.push((lines[i++] ?? '').replace(/^\s*>\s?/, ''))
99 const structured = q.length > 1 && q.some(l => !l.trim() || /^(\s*([-*+]|\d+[.)])\s| {0,3}#{1,6}\s|\s*```)/.test(l))
100 push(!structured
101 ? (
102 <Box flexDirection="row">
103 <Text color={MD.quote}>│ </Text>
104 <Text color={MD.quote}>{inline(q.map(l => l.trim()).join(' '), t, `q${i}`)}</Text>
105 </Box>
106 )
107 : (
108 <Box borderStyle="round" borderColor={MD.quote} paddingX={1} flexDirection="column">
109 {renderMarkdown(q.join('\n'), t, MD.quote)}
110 </Box>
111 ))
112 continue
113 }
114
115 // - / * / 1. lists (with nesting by indent)
116 if (/^\s*([-*+]|\d+[.)])\s+/.test(line)) {
117 const items: El[] = []
118 while (i < lines.length) {
119 const l = lines[i] ?? ''
120 const m = l.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/)
121 if (m) {
122 const depth = Math.floor((m[1] ?? '').length / 2)
123 const marker = /\d/.test(m[2] ?? '') ? (m[2] ?? '') : '•'
124 let text = m[3] ?? ''
125 i++
126 // continuation lines indented under the item
127 while (i < lines.length && /^\s{2,}\S/.test(lines[i] ?? '') && !/^\s*([-*+]|\d+[.)])\s+/.test(lines[i] ?? '')) {
128 text += ' ' + (lines[i++] ?? '').trim()
129 }
130 items.push(
131 <Box key={`li${i}`} flexDirection="row" paddingLeft={depth * 2}>
132 <Box width={marker.length + 1} flexShrink={0}><Text color={MD.bullet}>{marker}</Text></Box>
133 <Box flexGrow={1} flexShrink={1}><Text color={tone}>{inline(text, t, `li${i}`)}</Text></Box>
134 </Box>,
135 )
136 } else break
137 }
138 push(<Box flexDirection="column">{items}</Box>)
139 continue
140 }
141
142 // paragraph: join until a blank line or another block starts
143 // the first line is always taken, so a line no other rule claims can't stall the loop (#1)
144 const para: string[] = []
145 while (i < lines.length) {
146 const l = lines[i] ?? ''
147 if (para.length && (!l.trim() || /^(\s*(```|\||>|([-*+]|\d+[.)])\s)| {0,3}#{1,6}\s)/.test(l))) break
148 para.push(l.trim())
149 i++
150 }
151 push(<Text color={tone}>{inline(para.join(' '), t, `p${i}`)}</Text>)
152 }
153
154 return <Box flexDirection="column">{blocks}</Box>
155}
156