SLOPSHOPPER

ui-themes

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

newpanerowscommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ui-themes
│ ┃ UI theme ✕ │ ┃ ▾ │ ┃ ↑/↓ to choose · Enter to apply · Esc to fix the failing auth test and add an audit log call │ ┃ close │ │ ⏺ 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. I made refresh reject expired claims and added an audit │ call. │ │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ui-theme │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · UI theme
▾ ↑/↓ to choose · Enter to apply · Esc to close
Your message
fix the failing auth test and add an audit log call
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
README

ui-themes

Switchable themes for the Claude Code terminal UI:

  • Your prompts and Claude's replies drawn in padded, coloured boxes (Claude Code's defaults have no reply box and little prompt padding; the plugin draws both itself)
  • Reply markdown in theme colours (headings, inline code, bold, links, bullets, quotes)
  • A matching one-line status line: model (own icon and colour for Opus, Sonnet, Haiku and Fable), folder, git branch, context, 5h/7d limits
  • /ui-theme to pick a theme from a list and switch everything at once

Before and after

<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%">

Why I made this

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:

ThemeLook
claude-warm (default)Terracotta prompts, charcoal replies, Claude orange and honey text
night-blueNavy prompts, near-black replies, bright blue and cyan text
titaniumOh My Pi inspired: steel blue prompts, brushed titanium replies

Install

/plugin marketplace add yugal1107/ui-themes
/plugin install ui-themes@ui-themes

Restart Claude Code once after installing.

Use

CommandWhat it does
/ui-themeOpen a picker: arrow keys to choose, Enter to apply, Esc to close
/ui-theme listShow the current theme and the available ones as text
/ui-theme <name>Switch theme
/ui-theme nextCycle to the next theme
/ui-theme statuslineShow how to enable the matching status line
/ui-theme statusline updateApply a new status line script from a plugin update (your old copy is kept as .bak)

Status line

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.

Make your own theme

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"
  }
}

Notes

  • Built on Claude Code's function-hook plugin API, which is in early access and may change between releases.
  • Code blocks and tables keep Claude Code's own rendering.
  • While a reply is still streaming, Claude Code draws it with its own view; it moves into the coloured box once the block finishes.
  • /theme is Claude Code's own command and is left untouched; this plugin uses /ui-theme.

License

MIT © 2026 Yugal Burde

Source 2 files
hooks/register.tsx 242 lines
1import 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}
242
hooks/markdown.tsx 156 lines
1// 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