SLOPSHOPPER

color-chooser

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

newpanebandspinnercommandtoast
★ 2v0.3.0MITupdated 2026-10-07tiger42/claude-mods/color-chooser
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · color-chooser
│ ┃ Session color ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ▣ client module ./tile.tsx ▣ client module ⏺ Update(src/auth.ts) │ ┃ [ 1 ] [ 2 ] ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ▣ client module ./tile.tsx ▣ client module ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ [ 4 ] [ 5 ] │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /colors │ ┃ ▣ client module ./tile.tsx ▣ client module │ ┃ [ 7 ] [ 8 ] │ ┃ Click or 1-9 · Esc closes │ ┃ │ ┃ p: [ ] Via prompt │ ┃ Mid-turn, /color goes into the empty prompt; │ ┃ Enter applies it │ ⟨Claude Code's own drawing⟩ 🎨 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 🎨
Pane · Session color
▣ client module ./tile.tsx ▣ client module ./tile.tsx ▣ cl [ 1 ] [ 2 ] [ 3 ▣ client module ./tile.tsx ▣ client module ./tile.tsx ▣ cl [ 4 ] [ 5 ] [ 6 ╱╱╱╱ ╱╱╱╱ ╱╱╱╱ ▣ client module ./tile.tsx ▣ client module ./tile.tsx ▣ cl [ 7 ] [ 8 ] [ 9 Click or 1-9 · Esc closes p: [ ] Via prompt Mid-turn, /color goes into the empty prompt; Enter applies it
README

claude-mods

Personal Claude Code mods, packaged as a plugin marketplace named local-mods.

Requires Claude Code 2.1.287 or later.

Setup

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.

Enable a mod

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.

Mods

color-chooser

color-chooser: the 🎨 button in the band above the prompt and the picker with its swatches

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

usage-band

usage-band: 5-hour and weekly limit bars above the prompt (CLI)<br> <sub>Terminal</sub>

usage-band: 5-hour and weekly limit bars above the prompt (app)<br> <sub>Desktop app</sub>

usage-band: 5-hour and weekly limit bars above the prompt (VS Code)<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:

  • White tick: how much of the window has already passed. If the bar ends left of it, you are using the limit more slowly than it allows; right of it, faster.
  • Forecast: the pace so far, carried forward: used ÷ share of the window elapsed.

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

Development

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.

License

MIT

Source 3 files
hooks/register.tsx 481 lines
1import { 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}
481
hooks/tile.tsx 20 lines
1import 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
20
types/index.d.ts 22 lines
1// 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