A colour per project: a full-width stripe in the repo's colour above the prompt (stacked over other bands), a colour-emoji status line for mobile, and /color…

A colour per project, so you can tell at a glance which repo a Claude Code window belongs to. A full-width stripe in the project's colour sits above the prompt with the project name on the left and the current branch, dimmed, on the right.
▌ modemon feature/x
╭──────────────────────────────────────────────────────────────────────────╮
│ > │
╰──────────────────────────────────────────────────────────────────────────╯
🟣 modemon
The stripe row is a solid colour band (purple here) and the name is bold in black or white, whichever has more contrast on it.
.git/HEAD (worktree .git files are followed) at session start and after each turn. A detached HEAD shows a short commit. It is dropped when the band is too narrow, or skipped if it can't be read./plugin install project-color --marketplace mrjk05/modemon
Answer y to add the marketplace, then pick a scope.
| Command | What it does |
|---|---|
/color | Shows the current colour and the palette as swatches. Also works as /color list. |
/color <name> | Pins a palette colour for this repo, e.g. /color teal. |
/color <#hex> | Pins any colour: #RGB, #RRGGBB or a bare RRGGBB. Anything else is refused and the palette is listed. |
/color auto | Removes the pin and goes back to the automatic colour. Also works as /color reset. |
/project-color ... | The same command under the mod's own name. It is always registered, in case a Claude Code build reserves /color for itself. |
A pin is saved in the mod's $.store, keyed by the repo root, so it lasts across sessions and only applies to that repo.
The /color output is drawn as a small tree: the project in its colour, then the 12 swatches with the current one marked ●. Its plain-text form, which the model reads and which is shown wherever the tree can't be drawn, names the colour, its hex and whether it is auto or pinned.
| Surface | Stripe above the prompt | /color swatches | Status line |
|---|---|---|---|
| Terminal | Yes | Yes | 🟣 modemon |
| Desktop (Code tab) | Yes | Yes | 🟣 modemon |
| Claude mobile app | No, the app does not raise the band | Yes, inline in the transcript | 🟣 modemon: this is how the colour shows on the phone |
Set these in /config (or under pluginConfigs.project-color in settings):
| Option | Default | Meaning |
|---|---|---|
stripe | true | Draw the stripe above the prompt. |
thickness | "1" | "2" draws a two-row stripe. It falls back to one row when the band has fewer than 3 rows of room. |
branch | true | Show the branch at the right end of the stripe. |
statusLine | true | Pin <emoji> <project> as the mod's status entry on every surface. |
/color output use the project colour./color to tell them apart.hooks/register.tsx 239 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, RenderElement, RenderInputOf, RenderSurface } from 'claude-code'
3
4import type { ProjectColorProject } from '../types'
5import {
6 basename,
7 branchFromHead,
8 commandText,
9 gitdirFromFile,
10 packPalette,
11 parseArg,
12 parseSummary,
13 resolveSwatch,
14 statusText,
15 stripeParts,
16 textOn,
17} from './lib'
18
19const project = atom({ plugin: 'project-color', key: 'project' } as const, null)
20
21/** A `CommandOutput` row's render input, on any surface. */
22type OutputInput = { [S in RenderSurface]: RenderInputOf<'CommandOutput', S> }[RenderSurface]
23
24type Config = { stripe: boolean; statusLine: boolean; branch: boolean; thickness: number }
25
26/** Where an override is kept: one per repository root. */
27const storeKey = (root: string) => `override:${root}`
28
29/** Runs `work`, swallowing a refusal: a missing piece never stops the mod. */
30async function quietly<T>(work: () => Promise<T>): Promise<T | undefined> {
31 try {
32 return await work()
33 } catch {
34 return undefined
35 }
36}
37
38/** The checked-out branch, read from `.git/HEAD` (a worktree's `.git` file followed); null when not cheap to know. */
39async function readBranch($: EngineInterface, root: string): Promise<string | null> {
40 const dir = root.replace(/[\\/]+$/, '')
41 const head = await quietly(() => $.fs.read(`${dir}/.git/HEAD`))
42 if (head !== undefined) return branchFromHead(head)
43 const file = await quietly(() => $.fs.read(`${dir}/.git`))
44 const gitdir = file === undefined ? null : gitdirFromFile(file, dir)
45 if (gitdir === null) return null
46 const linked = await quietly(() => $.fs.read(`${gitdir}/HEAD`))
47 return linked === undefined ? null : branchFromHead(linked)
48}
49
50/** Who this session's project is: the repo root's basename, else the session root's, else the cwd's. */
51async function identify($: EngineInterface, cwd: string, cfg: Config): Promise<ProjectColorProject> {
52 const repo = await quietly(() => $.session.repo())
53 const sessionRoot = await quietly(() => $.session.root())
54 const root = repo?.root ?? sessionRoot ?? cwd
55 const stored = await quietly(() => $.store.get(storeKey(root)))
56 const override = typeof stored === 'string' ? stored : null
57 // The session root is the working tree a worktree session is in; the repo root is the main one.
58 const branch = cfg.branch ? await readBranch($, sessionRoot ?? root) : null
59 return { name: basename(root), root, branch, override }
60}
61
62/** Pins `🟣 modemon` under the prompt, on every surface (it is the phone's only sign of the colour). */
63function syncStatus($: EngineInterface, p: ProjectColorProject | null, cfg: Config): void {
64 if (!cfg.statusLine || p === null) return
65 $.ui.status(statusText(p.name, resolveSwatch(p.name, p.override)))
66}
67
68/** `/color [name|#hex|auto]`: shows, pins or resets the colour, answering the row `drawOutput` draws. */
69async function runColor($: EngineInterface, args: string, cfg: Config): Promise<{ text: string }> {
70 let p = await read($, project)
71 if (p === null) {
72 p = await identify($, await $.session.cwd(), cfg)
73 await update($, project, () => p)
74 }
75 const arg = parseArg(args)
76 if (arg.kind === 'error') return { text: arg.message }
77
78 let note: string | undefined
79 if (arg.kind === 'auto') {
80 await $.store.delete(storeKey(p.root))
81 p = { ...p, override: null }
82 note = 'Back to the automatic colour.'
83 } else if (arg.kind === 'set') {
84 await $.store.set(storeKey(p.root), arg.override)
85 p = { ...p, override: arg.override }
86 note = `Pinned for ${p.root}.`
87 }
88 if (arg.kind !== 'show') {
89 const next = p
90 await update($, project, () => next)
91 syncStatus($, next, cfg)
92 }
93 return { text: commandText(p.name, resolveSwatch(p.name, p.override), p.override, note) }
94}
95
96// The command's row drawn as a tree: the current colour, then the palette as swatches.
97async function drawOutput($: EngineInterface, e: OutputInput): Promise<RenderElement | null> {
98 const summary = parseSummary(e.props.text)
99 if (e.props.isErrored || summary === null) return null
100
101 const { Box, Text } = $.ui.resolve(e)
102 const width = Math.max(16, e.viewport?.columns ?? 60)
103 const fg = textOn(summary.hex)
104 const rows = packPalette(width)
105
106 return (
107 <Box flexDirection="column">
108 <Box flexDirection="row">
109 <Text backgroundColor={summary.hex} color={fg} bold wrap="truncate-end">
110 {` ▌ ${summary.project} `}
111 </Text>
112 <Text bold> {summary.name}</Text>
113 <Text dimColor>
114 {' '}
115 {summary.hex} · {summary.source}
116 </Text>
117 </Box>
118 {rows.map(row => (
119 <Box flexDirection="row" columnGap={2}>
120 {row.map(s => {
121 const isCurrent = s.name === summary.name
122 return (
123 <Text>
124 <Text backgroundColor={s.hex} color={s.fg}>
125 {isCurrent ? ' ● ' : ' '}
126 </Text>
127 <Text bold={isCurrent} dimColor={!isCurrent}>
128 {' '}
129 {s.name}
130 </Text>
131 </Text>
132 )
133 })}
134 </Box>
135 ))}
136 <Text dimColor wrap="truncate-end">
137 /color <name|#hex> pins · /color auto resets
138 </Text>
139 </Box>
140 )
141}
142
143export const register: Register = (on, options: PluginOptions) => {
144 const cfg: Config = {
145 stripe: options['stripe'] !== false,
146 statusLine: options['statusLine'] !== false,
147 branch: options['branch'] !== false,
148 thickness: options['thickness'] === '2' ? 2 : 1,
149 }
150
151 on('session.start', async ($, e, next) => {
152 // `/color` is the short name; `/project-color` always registers, in case a build owns `/color`.
153 await quietly(() =>
154 $.command.register({
155 name: 'color',
156 description: "Show or pin this project's colour (stripe above the prompt)",
157 argumentHint: '[name|#hex|auto]',
158 immediate: true,
159 }),
160 )
161 await quietly(() =>
162 $.command.register({
163 name: 'project-color',
164 description: "Show or pin this project's colour (same as /color)",
165 argumentHint: '[name|#hex|auto]',
166 immediate: true,
167 }),
168 )
169 const p = await quietly(() => identify($, e.cwd, cfg))
170 if (p !== undefined) {
171 await update($, project, () => p)
172 syncStatus($, p, cfg)
173 }
174 return next(e)
175 })
176
177 // The branch can move during a session; one small read per turn keeps the hint honest.
178 on('turn.complete', async ($, e, next) => {
179 if (cfg.branch && cfg.stripe) {
180 await quietly(async () => {
181 const p = await read($, project)
182 if (p === null) return
183 const branch = await readBranch($, (await quietly(() => $.session.root())) ?? p.root)
184 if (branch !== p.branch) await update($, project, was => (was === null ? was : { ...was, branch }))
185 })
186 }
187 return next(e)
188 })
189
190 on('command.run', { command: 'color' }, ($, e) => runColor($, e.args, cfg))
191 on('command.run', { command: 'project-color' }, ($, e) => runColor($, e.args, cfg))
192 on('ui.render', { component: 'CommandOutput', props: { command: 'color' } }, async ($, e, next) => {
193 return (await drawOutput($, e)) ?? next(e)
194 })
195 on('ui.render', { component: 'CommandOutput', props: { command: 'project-color' } }, async ($, e, next) => {
196 return (await drawOutput($, e)) ?? next(e)
197 })
198
199 // The stripe: drawn on top, then whatever the other band mods draw beneath it.
200 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
201 if (e.props.hasSurvey || !cfg.stripe) return next(e)
202 const p = await read($, project)
203 if (p === null) return next(e)
204
205 const { Box, Text } = $.ui.resolve(e)
206 const swatch = resolveSwatch(p.name, p.override)
207 const width = Math.max(1, e.props.bodyColumns)
208 const parts = stripeParts(p.name, p.branch, width)
209 const rows = cfg.thickness === 2 && e.props.maxRows >= 3 ? 2 : 1
210 const paint = { backgroundColor: swatch.hex, color: swatch.fg }
211
212 const stripe = (
213 <Box flexDirection="column" width={width}>
214 <Box flexDirection="row" width={width}>
215 <Text {...paint} bold wrap="truncate-end">
216 {parts.left}
217 </Text>
218 <Text {...paint}>{parts.fill}</Text>
219 {parts.right !== '' && (
220 <Text {...paint} dimColor>
221 {parts.right}
222 </Text>
223 )}
224 </Box>
225 {rows === 2 && <Text {...paint}>{' '.repeat(width)}</Text>}
226 </Box>
227 )
228
229 const below: RenderElement | null | undefined = await next(e)
230 if (below === null || below === undefined || below.type === 'engine') return stripe
231 return (
232 <Box flexDirection="column">
233 {stripe}
234 {below}
235 </Box>
236 )
237 })
238}
239hooks/lib.ts 234 lines1import type { ProjectColorSwatch } from '../types'
2
3/** One palette entry before its text colour is worked out. */
4type Entry = { name: string; hex: string; emoji: string }
5
6/**
7 * Twelve mid-tone colours: each is light enough to stand out on a dark
8 * terminal and saturated enough to stand out on a light one.
9 */
10const ENTRIES: readonly Entry[] = [
11 { name: 'purple', hex: '#8B5CF6', emoji: '🟣' },
12 { name: 'blue', hex: '#3B82F6', emoji: '🔵' },
13 { name: 'teal', hex: '#0D9488', emoji: '🟢' },
14 { name: 'green', hex: '#22C55E', emoji: '🟢' },
15 { name: 'lime', hex: '#84CC16', emoji: '🟢' },
16 { name: 'yellow', hex: '#EAB308', emoji: '🟡' },
17 { name: 'amber', hex: '#B45309', emoji: '🟤' },
18 { name: 'orange', hex: '#F97316', emoji: '🟠' },
19 { name: 'red', hex: '#DC2626', emoji: '🔴' },
20 { name: 'pink', hex: '#EC4899', emoji: '🔴' },
21 { name: 'magenta', hex: '#C026D3', emoji: '🟣' },
22 { name: 'slate', hex: '#64748B', emoji: '⚫' },
23]
24
25export const BLACK = '#000000'
26export const WHITE = '#FFFFFF'
27
28/** `#RRGGBB` to its three channels, 0 to 255. */
29export function rgb(hex: string): [number, number, number] {
30 const n = parseInt(hex.slice(1), 16)
31 return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
32}
33
34/** WCAG relative luminance, 0 (black) to 1 (white). */
35export function luminance(hex: string): number {
36 const [r, g, b] = rgb(hex).map(c => {
37 const s = c / 255
38 return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
39 }) as [number, number, number]
40 return 0.2126 * r + 0.7152 * g + 0.0722 * b
41}
42
43/** WCAG contrast ratio between two colours, 1 to 21. */
44export function contrast(a: string, b: string): number {
45 const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]
46 return (hi + 0.05) / (lo + 0.05)
47}
48
49/** Black or white, whichever has the better contrast on `hex`. */
50export function textOn(hex: string): string {
51 return contrast(hex, BLACK) >= contrast(hex, WHITE) ? BLACK : WHITE
52}
53
54export const PALETTE: readonly ProjectColorSwatch[] = ENTRIES.map(e => ({ ...e, fg: textOn(e.hex) }))
55
56/** 32-bit FNV-1a over the string's UTF-16 code units: stable across runs and machines. */
57export function fnv1a(text: string): number {
58 let hash = 0x811c9dc5
59 for (let i = 0; i < text.length; i++) {
60 hash ^= text.charCodeAt(i)
61 hash = Math.imul(hash, 0x01000193)
62 }
63 return hash >>> 0
64}
65
66/** The palette colour a project gets when nothing is pinned. */
67export function autoSwatch(project: string): ProjectColorSwatch {
68 return PALETTE[fnv1a(project) % PALETTE.length] as ProjectColorSwatch
69}
70
71/** `#abc`, `#aabbcc` or `aabbcc` as `#AABBCC`; null when it is not a hex colour. */
72export function parseHex(input: string): string | null {
73 const text = input.trim()
74 const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(text) ?? /^([0-9a-f]{6})$/i.exec(text)
75 const digits = m?.[1]
76 if (digits === undefined) return null
77 const full = digits.length === 3 ? [...digits].map(d => d + d).join('') : digits
78 return `#${full.toUpperCase()}`
79}
80
81/** The palette entry named `name` (any case), if any. */
82export function byName(name: string): ProjectColorSwatch | undefined {
83 const key = name.trim().toLowerCase()
84 return PALETTE.find(s => s.name === key)
85}
86
87/** The palette entry closest to `hex` by RGB distance. */
88export function nearest(hex: string): ProjectColorSwatch {
89 const [r, g, b] = rgb(hex)
90 let best = PALETTE[0] as ProjectColorSwatch
91 let bestDistance = Infinity
92 for (const s of PALETTE) {
93 const [sr, sg, sb] = rgb(s.hex)
94 const d = (r - sr) ** 2 + (g - sg) ** 2 + (b - sb) ** 2
95 if (d < bestDistance) [best, bestDistance] = [s, d]
96 }
97 return best
98}
99
100/** A custom hex as a swatch: its own text colour, the nearest palette emoji. */
101export function customSwatch(hex: string): ProjectColorSwatch {
102 return { name: 'custom', hex, fg: textOn(hex), emoji: nearest(hex).emoji }
103}
104
105/** The colour a project draws in: its pinned override when valid, else its auto colour. */
106export function resolveSwatch(project: string, override: string | null): ProjectColorSwatch {
107 if (override !== null) {
108 const named = byName(override)
109 if (named !== undefined) return named
110 const hex = parseHex(override)
111 if (hex !== null) return customSwatch(hex)
112 }
113 return autoSwatch(project)
114}
115
116export type ColorArg =
117 | { kind: 'show' }
118 | { kind: 'auto' }
119 | { kind: 'set'; override: string }
120 | { kind: 'error'; message: string }
121
122/** What `/color <args>` asks for. */
123export function parseArg(args: string): ColorArg {
124 const text = args.trim()
125 if (text === '' || /^(list|show|palette)$/i.test(text)) return { kind: 'show' }
126 if (/^(auto|reset|clear)$/i.test(text)) return { kind: 'auto' }
127 const named = byName(text)
128 if (named !== undefined) return { kind: 'set', override: named.name }
129 const hex = parseHex(text)
130 if (hex !== null) return { kind: 'set', override: hex }
131 return {
132 kind: 'error',
133 message: `"${text}" is not a palette colour or a hex colour (#RGB or #RRGGBB). Colours: ${PALETTE.map(s => s.name).join(', ')}.`,
134 }
135}
136
137/** The last path segment, trailing slashes ignored; `/` for the root. */
138export function basename(path: string): string {
139 const parts = path.split(/[\\/]+/).filter(p => p !== '')
140 return parts.at(-1) ?? '/'
141}
142
143/** The branch a `.git/HEAD` names, or a short commit when detached; null when unreadable. */
144export function branchFromHead(head: string): string | null {
145 const text = head.trim()
146 const ref = /^ref:\s*refs\/heads\/(.+)$/.exec(text)
147 if (ref?.[1] !== undefined) return ref[1]
148 if (/^[0-9a-f]{7,64}$/i.test(text)) return text.slice(0, 7)
149 return null
150}
151
152/** The git directory a worktree's `.git` file points at (`gitdir: <path>`), resolved against `root`. */
153export function gitdirFromFile(text: string, root: string): string | null {
154 const m = /^gitdir:\s*(.+)$/m.exec(text.trim())
155 const dir = m?.[1]?.trim()
156 if (dir === undefined || dir === '') return null
157 return dir.startsWith('/') || /^[a-z]:[\\/]/i.test(dir) ? dir : `${root.replace(/[\\/]+$/, '')}/${dir}`
158}
159
160/** The status line: `🟣 modemon`. */
161export function statusText(project: string, swatch: ProjectColorSwatch): string {
162 return `${swatch.emoji} ${project}`
163}
164
165export type StripeParts = { left: string; fill: string; right: string }
166
167/**
168 * The stripe's one row as three runs exactly `width` cells wide: the project
169 * on the left (cut to fit), the hint on the right when it fits, spaces between.
170 */
171export function stripeParts(project: string, hint: string | null, width: number): StripeParts {
172 const room = Math.max(0, width)
173 let left = ` ▌ ${project} `
174 if (left.length > room) left = room <= 4 ? left.slice(0, room) : `${left.slice(0, room - 2)}… `
175 const right = hint !== null && hint !== '' ? ` ${hint} ` : ''
176 const showRight = right !== '' && left.length + right.length + 2 <= room
177 const used = left.length + (showRight ? right.length : 0)
178 return { left, fill: ' '.repeat(Math.max(0, room - used)), right: showRight ? right : '' }
179}
180
181/** Where the colour came from, as the command says it. */
182export function sourceOf(override: string | null): 'auto' | 'pinned' {
183 return override === null ? 'auto' : 'pinned'
184}
185
186/**
187 * The command's text: what the model reads and what a surface without the
188 * tree shows. Its first line is the one `parseSummary` reads back.
189 */
190export function commandText(
191 project: string,
192 swatch: ProjectColorSwatch,
193 override: string | null,
194 note?: string,
195): string {
196 const lines = [
197 `${project}: ${swatch.name} ${swatch.hex} (${sourceOf(override)})`,
198 ...(note !== undefined ? [note] : []),
199 `Palette: ${PALETTE.map(s => s.name).join(', ')}.`,
200 'Use /color <name|#hex> to pin a colour for this repo, /color auto to go back to the automatic one.',
201 ]
202 return lines.join('\n')
203}
204
205export type Summary = { project: string; name: string; hex: string; source: 'auto' | 'pinned' }
206
207/** Reads back `commandText`'s first line; null for any other text (an error, say). */
208export function parseSummary(text: string): Summary | null {
209 const first = text.split('\n')[0] ?? ''
210 const m = /^(.+): ([a-z]+) (#[0-9A-F]{6}) \((auto|pinned)\)$/.exec(first)
211 if (m === null) return null
212 const [, project, name, hex, source] = m as unknown as [string, string, string, string, 'auto' | 'pinned']
213 return { project, name, hex, source }
214}
215
216/** Palette swatches packed into rows no wider than `width` (each item `swatchWidth` cells plus its name). */
217export function packPalette(width: number, swatchWidth = 3): ProjectColorSwatch[][] {
218 const rows: ProjectColorSwatch[][] = []
219 let row: ProjectColorSwatch[] = []
220 let used = 0
221 for (const s of PALETTE) {
222 const size = swatchWidth + 1 + s.name.length + 2
223 if (row.length > 0 && used + size > width) {
224 rows.push(row)
225 row = []
226 used = 0
227 }
228 row.push(s)
229 used += size
230 }
231 if (row.length > 0) rows.push(row)
232 return rows
233}
234types/index.d.ts 32 lines1/** One colour of the palette, or a pinned custom hex. */
2export type ProjectColorSwatch = {
3 /** A palette name (`purple`), or `custom` for a pinned hex. */
4 name: string
5 /** `#RRGGBB`, upper case. */
6 hex: string
7 /** Black or white: whichever reads better on `hex`. */
8 fg: string
9 /** The coloured circle the status line shows (mobile has no stripe). */
10 emoji: string
11}
12
13/** The session's project as the stripe and status line draw it. */
14export type ProjectColorProject = {
15 /** The repository root's basename (the working directory's outside a repo). */
16 name: string
17 /** The absolute root the override is stored under. */
18 root: string
19 /** The checked-out branch, or a short commit when detached; null when unknown. */
20 branch: string | null
21 /** The pinned colour (`red`, `#12AB34`); null when picked automatically. */
22 override: string | null
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'project-color': {
28 project: ProjectColorProject | null
29 }
30 }
31}
32