Palette button above the prompt: pick the session's prompt bar color (/color) with a click

Personal Claude Code mods, packaged as a plugin marketplace named local-mods.
Requires Claude Code 2.1.287 or later.
git clone https://github.com/tiger42/claude-mods.git ~/claude-mods
claude plugin marketplace add ~/claude-mods
The marketplace is a local folder, so Claude Code reads each mod straight from the clone. To update: git pull, then /reload-plugins in any open session.
claude plugin install <mod>@local-mods --scope user # all projects
claude plugin install <mod>@local-mods --scope project # this repo, via .claude/settings.json
claude plugin disable <mod>@local-mods # turn off again
Or use /plugin in a session. Run /reload-plugins to load a mod into a session that is already open.

A 🎨 button at the right end of the band above the prompt opens a picker with the session colors of /color as swatches, three to a row. A click on a swatch or its digit sets the color and closes the picker; Esc closes it unchanged. The current color carries a check, including one set with a typed /color. /colors opens the picker too, for when the band is collapsed.
While Claude is working, a color picked in the picker waits for the turn to end: Claude Code runs a mod's /color only once the session is idle, unlike a typed /color, which takes effect at once. Beside the 🎨 button the band says so (● red once Claude is done) until the color is set, a toast says it too, and in the picker its swatch carries ….
To skip the wait, check Via prompt in the picker (p; also under /config as the plugin's option). A color picked while Claude works then goes into the prompt as /color <color>, and Enter sets it at once; the band above the prompt (● Press Enter to set red) and the hint line under it say so. A draft you have typed is never replaced: with text in the prompt, the color waits for the turn as above. The last choice always stands: a /color typed while a pick waits is set again once the queue has run the pick.
The swatches are painted through the theme exactly as the prompt bar is, so they look the same in any color depth. With 256 colors or more the picker offers all eight colors and default. In 16 colors (TERM=xterm with no COLORTERM, typical over SSH) orange draws as red, blue as cyan and pink as purple, so it offers only red, green, yellow, purple, cyan and default; COLORTERM=truecolor brings the rest back if the terminal supports it. To switch it on, check that printf '\e[38;2;217;119;87m██ orange\e[0m\n' prints orange rather than red, then add export COLORTERM=truecolor to the shell profile (~/.bashrc) of the machine Claude Code runs on, the server when you connect over SSH, and start Claude Code anew. A session moved to the background (from the sessions overview) always runs in full color, whatever the terminal it was started from, so the picker there offers all eight.
Shares the band with other mods: the button sits beside whatever they draw there. Terminal only: the session color tints the terminal's prompt bar and nothing in the Desktop app, so the button and /colors stay out of other surfaces.
claude plugin install color-chooser@local-mods --scope user
<br> <sub>Terminal</sub>
<br> <sub>Desktop app</sub>
<br> <sub>VS Code extension</sub>
Band above the prompt showing the 5-hour and weekly rate limits of a Claude subscription. Each limit gets a gradient bar, a white tick for the time elapsed in the window, a countdown to the reset and a forecast at the current rate (→ ~75% at reset or ▲ full in 40m). In the terminal a light sweeps the bars while Claude works. A new session starts from the last reading of any other session on the same machine. What other mods draw in the band stays beside the bars, or moves under them when there is no room. Collapse the band with ctrl+x ctrl+a.
How to read it:
Draws in the terminal, in the Desktop app's Code tab and in the VS Code extension; outside the terminal the bars are drawn as SVG and grow with the window into the room beside the figures. Shows nothing for API-key accounts, which have no rate-limit windows.
claude plugin install usage-band@local-mods --scope user
claude --plugin-dir ./<mod> # load a working copy, hot-reloads on save
claude plugin validate ./<mod> # manifest and static analysis of the hooks module
claude plugin test ./<mod> # runs <mod>/tests/*.test.ts(x)
--plugin-dir writes type declarations to <mod>/.claude-plugin/types/ (git-ignored). Type-checking needs TypeScript 5.4 or later.
To add a mod, create its folder, list it under plugins in .claude-plugin/marketplace.json and add a section above.
hooks/register.tsx 481 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { SessionColor } from '../types'
5
6// The color last set through the picker or a typed /color; null while unknown
7const current = atom({ plugin: 'color-chooser', key: 'current' } as const, null)
8// The latest pick whose /color still waits in the session's queue: a plugin's runs once the session is idle
9const pending = atom({ plugin: 'color-chooser', key: 'pending' } as const, null)
10// Whether a turn of the main loop runs
11const working = atom({ plugin: 'color-chooser', key: 'working' } as const, false)
12// The viaPrompt option: a pick made while a turn runs goes into the prompt box, where Enter runs /color at once
13const viaPrompt = atom({ plugin: 'color-chooser', key: 'viaPrompt' } as const, false)
14// The color whose /color line the picker put into the prompt box; null when none is there
15const offered = atom({ plugin: 'color-chooser', key: 'offered' } as const, null)
16// A /color set elsewhere while a pick waited in the queue: the queue would overturn it, so it is set again after
17const restore = atom({ plugin: 'color-chooser', key: 'restore' } as const, null)
18
19const PLUGIN = 'color-chooser'
20const PANE = PLUGIN
21const OPTION = 'viaPrompt'
22const DEFAULT = 'default'
23// The mark on the swatch the session has, and on the one that waits for the turn to end
24const CHECK = '✓'
25const WAIT = '…'
26
27// What /color takes, in its own order
28const COLORS = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan']
29// The ones that still differ in 16 colors: orange draws as red, blue as cyan, pink as purple
30const BASIC = ['red', 'green', 'yellow', 'purple', 'cyan']
31// Arguments /color reads as the theme's own color
32const RESETS = [DEFAULT, 'reset', 'none', 'gray', 'grey']
33
34// Each color as the dark theme paints it: for surfaces without the theme's keys, and to pick the check's contrast
35const RGB: Record<string, number> = {
36 red: 0xdc2626,
37 blue: 0x6a9bcc,
38 green: 0x16a34a,
39 yellow: 0xca8a04,
40 purple: 0x827dbd,
41 orange: 0xd97757,
42 pink: 0xc46686,
43 cyan: 0x0891b2,
44}
45const GRAY = 0x888888
46
47// A swatch's size in cells, how many share a row, and the room between two
48const TILE_W = 5
49const TILE_H = 3
50const PER_ROW = 3
51const GAP = 2
52// The swatches' width, which the picker's notes wrap to
53const GRID_W = PER_ROW * TILE_W + (PER_ROW - 1) * GAP
54
55// The notes under the swatches
56const WAITS = `${WAIT} set after this turn`
57const HELP = 'Mid-turn, /color goes into the empty prompt; Enter applies it'
58
59// The colors on offer, settled at session start by the terminal's color depth
60let choices = [...COLORS, DEFAULT]
61
62export const register: Register = (on, options) => {
63 on('session.start', async ($, e, next) => {
64 choices = [...((await colorLevel($)) >= 2 ? COLORS : BASIC), DEFAULT]
65 // A change of the option in /config reloads the module, and each load starts here
66 await update($, viaPrompt, () => options[OPTION] === true)
67 // The session color shows in the terminal's prompt bar alone: elsewhere there is nothing to pick for
68 if (e.surface === 'terminal') {
69 await $.command.register({ name: 'colors', description: 'Pick the prompt bar color for this session' })
70 }
71
72 return next(e)
73 })
74
75 on('command.run', { command: 'colors' }, async $ => {
76 await openPicker($)
77
78 return {}
79 })
80
81 // A typed /color moves the mark in the picker too. The picker's own runs are `ran`'s to mark, whether or not they
82 // reach this hook: one started from a press does not, one from the click's ui.message hook does
83 on('command.run', { command: 'color' }, async ($, e, next) => {
84 const result = await next(e)
85 if (e.origin.kind === 'plugin' && e.origin.name === PLUGIN) {
86 return result
87 }
88
89 const color = chosen(e.args, result.text)
90 if (color !== undefined) {
91 await setElsewhere($, color)
92 }
93
94 return result
95 })
96
97 on('turn.start', async ($, e, next) => {
98 await update($, working, () => true)
99
100 return next(e)
101 })
102
103 // Subagents' turns end inside the main one: only the main loop's leaves the session idle
104 on('turn.complete', async ($, e, next) => {
105 if (e.agentId === undefined) {
106 await update($, working, () => false)
107 }
108
109 return next(e)
110 })
111
112 // The palette button sits at the right end of the terminal's band, beside whatever the plugins beneath draw
113 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
114 const below = await next(e)
115 if (e.props.hasSurvey || e.surface !== 'terminal') {
116 return below
117 }
118
119 const { Box, Button, Text } = $.ui.resolve(e)
120 const notice = await noticeOf($)
121
122 // Beside the button, a dot in the color and a line on it stay while the person has something to wait or press for
123 return (
124 <Box flexDirection="row" justifyContent="space-between" gap={1}>
125 <Box flexShrink={1}>{below}</Box>
126 <Box flexDirection="row" gap={1}>
127 {notice !== null && <Text color={shade(notice.color, e.surface)}>●</Text>}
128 {notice !== null && <Text>{notice.text}</Text>}
129 <Button key="open" plain label="🎨" onPress={() => void openPicker($)} />
130 </Box>
131 </Box>
132 )
133 })
134
135 // While the /color line the picker put there is in the prompt box, the hint line says what Enter does
136 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
137 const color = e.props.isDraft && e.surface === 'terminal' ? await inPrompt($) : null
138 if (color === null) {
139 return next(e)
140 }
141
142 const tail = color === DEFAULT ? 'enter to reset the prompt bar' : `enter to set ${color}`
143
144 return next({ ...e, props: { ...e.props, tail } })
145 })
146
147 // Swatches are painted through the theme as the prompt bar is, so the terminal's color depth treats both alike
148 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
149 const { Box, Button, Text } = $.ui.resolve(e)
150 const Client = e.surface === 'terminal' ? $.ui.resolve(e).Client : null
151 const active = await read($, current)
152 const waiting = await waitingColor($)
153 const isViaPrompt = await read($, viaPrompt)
154 const focused = active !== null && choices.includes(active) ? active : choices[0]
155 const rows = Array.from({ length: Math.ceil(choices.length / PER_ROW) }, (_, row) =>
156 choices.slice(row * PER_ROW, (row + 1) * PER_ROW),
157 )
158
159 return (
160 <Box flexDirection="column">
161 {rows.map((row, rowIndex) => (
162 <Box key={`row-${rowIndex}`} flexDirection="row" columnGap={GAP}>
163 {row.map((color, column) => {
164 const digit = String(rowIndex * PER_ROW + column + 1)
165 const isDefault = color === DEFAULT
166 const paint = shade(color, e.surface)
167 const check = color === active ? CHECK : color === waiting ? WAIT : isDefault ? '╱' : ' '
168 const side = (isDefault ? '╱' : ' ').repeat((TILE_W - 1) / 2)
169
170 return (
171 <Box key={`tile-${color}`} flexDirection="column" width={TILE_W}>
172 {Array.from({ length: TILE_H }, (_, line) => {
173 const glyphs = line === (TILE_H - 1) / 2 ? [side, check, side] : [side + (isDefault ? '╱' : ' ') + side]
174
175 return (
176 <Box key={`line-${line}`} flexDirection="row">
177 {glyphs.map(text =>
178 isDefault ? (
179 <Text color={text === CHECK || text === WAIT ? 'text' : paint}>{text}</Text>
180 ) : (
181 <Text backgroundColor={paint} color={hex(contrast(rgbOf(color)))} bold>
182 {text}
183 </Text>
184 ),
185 )}
186 </Box>
187 )
188 })}
189 {Client !== null && (
190 <Box position="absolute" top={0} left={0}>
191 <Client key={`hit-${color}`} module="./tile.tsx" props={{ color }} width={TILE_W} height={TILE_H} />
192 </Box>
193 )}
194 <Button
195 key={color}
196 hotkey={digit}
197 label={digit}
198 {...(color === focused ? { autoFocus: true as const } : {})}
199 onPress={() => void choose($, color)}
200 />
201 </Box>
202 )
203 })}
204 </Box>
205 ))}
206 {waiting !== null && <Text dimColor>{WAITS}</Text>}
207 <Text dimColor>{footer(active)}</Text>
208 <Box marginTop={1}>
209 <Button
210 key={OPTION}
211 plain
212 hotkey="p"
213 label={toggleLabel(isViaPrompt)}
214 onPress={() => void setViaPrompt($, !isViaPrompt)}
215 />
216 </Box>
217 <Text dimColor>{HELP}</Text>
218 </Box>
219 )
220 })
221
222 // A click on a swatch, from the hit area laid over it
223 on('ui.message', async ($, e, next) => {
224 const color = picked(e.data)
225 if (e.requestId === PANE && color !== null) {
226 await choose($, color)
227 }
228
229 return next(e)
230 })
231}
232
233async function openPicker($: EngineInterface) {
234 const waiting = await waitingColor($)
235 // The toggle's plain Button draws its hotkey before the label, a blank line above it
236 const notes = [...(waiting === null ? [] : [WAITS]), footer(await read($, current)), '', `p: ${toggleLabel(true)}`, HELP]
237 await $.ui.open({
238 id: PANE,
239 title: 'Session color',
240 focus: true,
241 closeOnEscape: true,
242 holdToasts: true,
243 rows: Math.ceil(choices.length / PER_ROW) * (TILE_H + 1) + notes.reduce((sum, note) => sum + lines(note), 0),
244 columns: GRID_W + 2,
245 })
246}
247
248// The color the queue holds for the end of the running turn; null when none waits or no turn runs
249async function waitingColor($: EngineInterface): Promise<SessionColor | null> {
250 return (await read($, working)) ? read($, pending) : null
251}
252
253// The color whose /color line the picker put into the prompt box, while the box still holds that line as it was put
254async function inPrompt($: EngineInterface): Promise<SessionColor | null> {
255 const color = await read($, offered)
256 if (color === null) {
257 return null
258 }
259
260 return (await $.prompt.read()).text.trim() === `/color ${color}` ? color : null
261}
262
263// What the band says beside the button: Enter for a line in the prompt box comes first, then a pick the queue holds
264async function noticeOf($: EngineInterface): Promise<{ color: SessionColor; text: string } | null> {
265 const offer = await inPrompt($)
266 if (offer !== null) {
267 return { color: offer, text: offer === DEFAULT ? 'Press Enter to reset the color' : `Press Enter to set ${offer}` }
268 }
269
270 const waiting = await waitingColor($)
271 if (waiting !== null) {
272 return { color: waiting, text: `${waiting === DEFAULT ? 'default color' : waiting} once Claude is done` }
273 }
274
275 return null
276}
277
278async function choose($: EngineInterface, color: SessionColor) {
279 await $.ui.close({ id: PANE })
280 // A plugin's /color runs once the session is idle, unlike a typed one: while a turn runs, offer it in the prompt
281 // box when the option asks for that, or say that it waits
282 if (await read($, working)) {
283 const offer = (await read($, viaPrompt)) ? await fillPrompt($, color) : 'off'
284 if (offer === 'filled') {
285 return
286 }
287
288 const later =
289 color === DEFAULT ? 'The prompt bar resets once Claude is done' : `The prompt bar turns ${color} once Claude is done`
290 $.ui.toast(offer === 'draft' ? `Prompt not empty. ${later}` : later, { timeoutMs: 6000 })
291 }
292
293 // The newest pick runs after all the queue holds, so nothing set before it needs setting again
294 await update($, pending, () => color)
295 await update($, restore, () => null)
296 run($, color)
297}
298
299// Not awaited, as a turn in flight holds it in the queue until the session is idle
300function run($: EngineInterface, color: SessionColor) {
301 void $.command.run({ command: 'color', args: color }).then(
302 () => ran($, color),
303 async (error: unknown) => {
304 await update($, pending, waiting => (waiting === color ? null : waiting))
305 $.ui.toast(`Could not set the color: ${error instanceof Error ? error.message : String(error)}`)
306 },
307 )
308}
309
310// The queue ran a pick: the check moves to it. A /color set elsewhere after the pick was queued is the later choice: once
311// no pick waits any more, it is set again over what the queue ran
312async function ran($: EngineInterface, color: SessionColor) {
313 await update($, current, () => color)
314 await update($, pending, waiting => (waiting === color ? null : waiting))
315 const later = await read($, restore)
316 if (later !== null && (await read($, pending)) === null) {
317 await update($, restore, () => null)
318 run($, later)
319 }
320}
321
322// A /color the picker did not run, typed or sent from the prompt box: the check moves to it, the line the picker put
323// there is spent, and a pick still waiting in the queue no longer stands
324async function setElsewhere($: EngineInterface, color: SessionColor | null) {
325 await update($, current, () => color)
326 await update($, offered, () => null)
327 if (color !== null && (await read($, pending)) !== null) {
328 await update($, pending, () => null)
329 await update($, restore, () => color)
330 }
331}
332
333// Puts the /color line into the prompt box, where Enter runs it at once. Never over a draft of the person's: only into
334// an empty box, or over the line it put there before
335async function fillPrompt($: EngineInterface, color: SessionColor): Promise<'filled' | 'draft' | 'refused'> {
336 const before = await read($, offered)
337 const draft = (await $.prompt.read()).text.trim()
338 if (draft !== '' && (before === null || draft !== `/color ${before}`)) {
339 return 'draft'
340 }
341
342 const { isFilled } = await $.prompt.fill({ text: `/color ${color}` })
343 if (!isFilled) {
344 return 'refused'
345 }
346
347 await update($, offered, () => color)
348 $.ui.toast(color === DEFAULT ? 'Press Enter to reset the prompt bar now' : `Press Enter to set ${color} now`, {
349 timeoutMs: 6000,
350 })
351
352 return 'filled'
353}
354
355// The option lives in the plugin's settings, where /config shows it too
356async function setViaPrompt($: EngineInterface, value: boolean) {
357 try {
358 const set = await $.config.set({ key: `${PLUGIN}.${OPTION}`, value })
359 if (set.deny !== undefined) {
360 $.ui.toast(`Could not change the setting: ${set.deny}`)
361
362 return
363 }
364
365 await update($, viaPrompt, () => value)
366 } catch (error: unknown) {
367 $.ui.toast(`Could not change the setting: ${error instanceof Error ? error.message : String(error)}`)
368 }
369}
370
371function footer(active: SessionColor | null): string {
372 return `${active === null ? '' : `Current: ${active} · `}Click or 1-${choices.length} · Esc closes`
373}
374
375function toggleLabel(isOn: boolean): string {
376 return `[${isOn ? 'x' : ' '}] Via prompt`
377}
378
379// The rows a note takes under the swatches, a blank one included
380function lines(note: string): number {
381 return Math.max(1, Math.ceil(note.length / GRID_W))
382}
383
384// The color depth Claude Code draws in, by the rules of its color support: 0 none, 1 16 colors, 2 256, 3 full
385async function colorLevel($: EngineInterface): Promise<number> {
386 const force = await $.env.get('FORCE_COLOR')
387 const forced =
388 force === undefined
389 ? undefined
390 : force === 'true' || force === ''
391 ? 1
392 : force === 'false'
393 ? 0
394 : Math.min(Number.parseInt(force, 10), 3)
395 if (forced === 0) {
396 return 0
397 }
398
399 const fallback = forced || 0
400 const term = (await $.env.get('TERM')) ?? ''
401 if (term === 'dumb' || (await $.env.get('CI')) !== undefined) {
402 return fallback
403 }
404
405 const colorterm = await $.env.get('COLORTERM')
406 if (colorterm === 'truecolor') {
407 return 3
408 }
409
410 const program = await $.env.get('TERM_PROGRAM')
411 if (program === 'iTerm.app') {
412 const major = Number.parseInt(((await $.env.get('TERM_PROGRAM_VERSION')) ?? '').split('.')[0] ?? '', 10)
413
414 return major >= 3 ? 3 : 2
415 }
416 if (program === 'Apple_Terminal' || /-256(color)?$/i.test(term)) {
417 return 2
418 }
419 if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(term) || colorterm !== undefined) {
420 return 1
421 }
422
423 return fallback
424}
425
426// What a /color run left the session with: a color, null for a random one, undefined when unchanged
427function chosen(args: string, text: string | undefined): SessionColor | null | undefined {
428 if (text) {
429 const set = /Session color set to: (\S+)/.exec(text)
430 if (set) {
431 return set[1]
432 }
433
434 return text.includes('reset to default') ? DEFAULT : undefined
435 }
436
437 const arg = args.trim().toLowerCase()
438 if (arg === '') {
439 return null
440 }
441 if (RESETS.includes(arg)) {
442 return DEFAULT
443 }
444
445 return COLORS.includes(arg) ? arg : undefined
446}
447
448function picked(data: unknown): SessionColor | null {
449 if (typeof data !== 'object' || data === null || !('pick' in data)) {
450 return null
451 }
452
453 const { pick } = data
454
455 return typeof pick === 'string' && choices.includes(pick) ? pick : null
456}
457
458// The terminal paints the theme's own keys, as the prompt bar does; other surfaces the dark theme's values
459function shade(color: SessionColor, surface: RenderSurface): string {
460 if (surface === 'terminal') {
461 return color === DEFAULT ? 'promptBorder' : `${color}_FOR_SUBAGENTS_ONLY`
462 }
463
464 return hex(rgbOf(color))
465}
466
467function rgbOf(color: SessionColor): number {
468 return RGB[color] ?? GRAY
469}
470
471// Black or white, whichever reads on the color
472function contrast(rgb: number): number {
473 const luma = 0.299 * ((rgb >> 16) & 0xff) + 0.587 * ((rgb >> 8) & 0xff) + 0.114 * (rgb & 0xff)
474
475 return luma > 150 ? 0x000000 : 0xffffff
476}
477
478function hex(rgb: number): string {
479 return `#${rgb.toString(16).padStart(6, '0')}`
480}
481hooks/tile.tsx 20 lines1import type { ClientModule } from 'claude-code'
2
3type Props = { color: string }
4
5// An invisible hit area laid over a swatch: a click released on it picks the swatch's color
6const Tile: ClientModule<Props> = (props, surface) => {
7 surface.onPointer(event => {
8 const isInside = event.x >= 0 && event.y >= 0 && event.x < surface.columns && event.y < surface.rows
9 if (event.type === 'up' && event.button === 'left' && isInside) {
10 surface.post({ pick: props.color })
11 }
12 })
13
14 const { Box } = surface.elements
15
16 return <Box />
17}
18
19export default Tile
20types/index.d.ts 22 lines1// A /color argument: one of the colors, or "default" for the theme's own
2export type SessionColor = string
3
4declare module 'claude-code' {
5 interface PluginState {
6 'color-chooser': {
7 // The color last set through the picker or a typed /color; null while unknown
8 current: SessionColor | null
9 // The latest pick whose /color waits in the session's queue until it is idle; null when none waits
10 pending: SessionColor | null
11 // Whether a turn of the main loop is running
12 working: boolean
13 // The viaPrompt option as last loaded or toggled in the picker
14 viaPrompt: boolean
15 // The color whose /color line the picker put into the prompt box; null when none is there
16 offered: SessionColor | null
17 // A /color set elsewhere while a pick waited in the queue, set again once the queue has run the pick
18 restore: SessionColor | null
19 }
20 }
21}
22