Recolors the terminal tab and background by Claude Code session state (working, waiting, done, error).

Color visualization for Claude Code, in the spirit of nvim-colorizer. Written in Go with no dependencies.

#rgb, #rrggbb(aa), rgb()/rgba(), hsl()/hsla() and oklch(). Turn them off with "statusline": {"swatches": false}.● working), on the line Claude Code already reserves under the prompt. It works in any terminal and isn't overwritten the way the title glyph can be.show / try. Highlight color literals in any file (claude-colorizer show styles.css), or preview a color as your terminal background (claude-colorizer try '#1e1e2e').This is the recommended setup: it turns on everything (state colors, statusline swatches and inline highlighting). You need Go 1.22+ and Claude Code.
git clone https://github.com/cjodo/claude-colorizer ~/repos/claude-colorizer
cd ~/repos/claude-colorizer
--no-hooks leaves the hooks to the plugin (step 4), so each event doesn't fire twice. make install ARGS="--no-hooks"
/plugin marketplace add ~/repos/claude-colorizer
name@marketplace, not the path: /plugin install ~/repos/claude-colorizer fails with Marketplace "..." not found. /plugin install claude-colorizer@claude-colorizer
Check it with /plugin list: claude-colorizer@claude-colorizer should be listed as enabled.
/reload-plugins picks up the plugin, but the statusline and inline highlighting start with the next session.claude-colorizer detect to see which terminal was detected.● working / ● done at the start of the statusline.If something is missing, check the table below:
| Missing | Likely cause |
|---|---|
| Inline highlighting | Plugin not installed or not restarted (steps 4–5). |
Swatches / ● working | Statusline not installed (step 2), or Claude Code not restarted. |
| Tab color | Terminal config (step 6), or the terminal has no tab color support. |
| Every event twice | Hooks registered by both make install and the plugin: run claude-colorizer uninstall && claude-colorizer install --no-hooks. |
To change colors, see Configuration. The sections below cover each part in detail, including installing without the plugin.
Ghostty is the most tested terminal: it's the one claude-colorizer is developed in. The other drivers follow each terminal's documented escape sequences and have unit tests, but see less real use. If you use one of them, see CONTRIBUTING.md for a short test checklist and how to report results or add a terminal.
| Terminal | Tab color | Background tint | Title fallback |
|---|---|---|---|
| Kitty | ✅ remote control¹ | ✅ OSC 11 | — |
| Ghostty | — (no escape exists) | ✅ OSC 11 | ✅ glyph |
| WezTerm | ✅ user var + Lua² | ✅ OSC 11 | — |
| iTerm2 | ✅ OSC 6 | ✅ OSC 1337 | — |
| Warp | — | — | ✅ glyph |
| Alacritty | — (no tabs) | ✅ OSC 11 | ✅ glyph |
| Windows Terminal | — | ✅ OSC 11 | ✅ glyph |
| other xterm-like | — | ✅ OSC 11 | ✅ glyph |
Statusline swatches use 24-bit SGR colors, which work in all of them.
Program status (OSC 7501). Every state change is also reported with the Program Status Protocol, which is not tied to any terminal. Terminals that implement it (Ghostty, through libghostty) can show the session state natively, with no glyph or config. The others ignore it. The report carries app=claude-code, the title Claude Code · <dir>, and for permission prompts state=blocked:kind=permission plus Claude's notification message. Inside tmux it needs passthrough enabled (see tmux). To turn it off, set "status": false.
Each terminal is a driver implementing terminal.Terminal (internal/terminal/drivers.go). Detection reads environment variables (internal/terminal/detect.go). Inside tmux it asks tmux which terminal each client tty attached to the session is running instead, because the environment describes the terminal that started the tmux server. If different terminals are attached, tab colors are sent to all of them. To override detection, set CLAUDE_COLORIZER_TERMINAL=kitty|ghostty|wezterm|iterm2|warp|alacritty|windows-terminal|generic. Run claude-colorizer detect to see what was picked.
¹ Kitty: add allow_remote_control yes to kitty.conf (or socket-only plus listen_on). Without it, kitty ignores the tab color and only the background changes.
² WezTerm: the color is published as the user var claude_colorizer_tab. To paint the tab, add this to wezterm.lua:
wezterm.on('format-tab-title', function(tab)
local c = tab.active_pane.user_vars.claude_colorizer_tab
if c and c ~= '' then
return { { Background = { Color = c } }, { Foreground = { Color = '#000000' } },
{ Text = ' ' .. tab.active_pane.title .. ' ' } }
end
end)
Title fallback: Claude Code sets the terminal title itself and may overwrite the glyph. If your version supports CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1, set it. If you don't want the glyph, set "title": false.
Background tint and title work without any setup, because tmux understands those sequences and applies them to the pane. Everything else has to reach the outer terminal through tmux passthrough:
claude-colorizer wraps these for passthrough automatically when $TMUX is set, but tmux drops them unless passthrough is on. Add this to ~/.tmux.conf (or ~/.config/tmux/tmux.conf):
set -g allow-passthrough all
Reload it in a running tmux with tmux source-file ~/.tmux.conf.
| Value | Effect |
|---|---|
off | Default. Tab colors and status reports are dropped. |
on | Passed through only from panes currently visible. |
all | Passed through from every pane, including windows you aren't viewing. |
Use all so a Claude session in a background window can still mark its tab as blocked or done while you are elsewhere. on drops those updates until you switch back to the window. The tradeoff is that any program in any pane can then send escape sequences straight to the outer terminal.
To try it in the current pane only, without editing your config:
tmux set -p allow-passthrough all
Check the global value with tmux show -gv allow-passthrough, and the detected terminal with claude-colorizer detect (it prints tmux: yes).
Background opacity. tmux paints a tinted pane with an explicit color in every cell, and terminals normally draw explicit cell colors fully opaque, so a translucent terminal turns solid while the tint is on. There are two fixes:
background-opacity-cells = true
Other explicitly colored cells (the tmux status bar, editor themes) then turn translucent as well.
{ "tmuxBackground": "terminal" }
This needs passthrough enabled, and the tint covers every pane in the window rather than just Claude's. The default is "pane".
For the full setup with the plugin, follow Quick start. This section covers installing without the plugin, which gives you state colors and swatches but no inline highlighting.
You need Go 1.22+ and Claude Code.
git clone <this repo> && cd claude-colorizer
make install
That's the whole setup. make install does two things:
go install, which builds the binary into $(go env GOPATH)/bin.claude-colorizer install, which adds the statusline and all hooks to ~/.claude/settings.json using the binary's absolute path, so it doesn't matter whether that directory is on your $PATH.Restart Claude Code, then try it: ask Claude for a palette and the swatches appear under the prompt. The tab or background changes while it works.
Tab colors in Kitty and WezTerm need one extra piece of terminal config. See Terminal support.
install changessettings.json.bak. If settings.json isn't valid JSON (for example, it has comments), install refuses to touch it.uninstall puts it back exactly."refreshInterval": 2. Permission prompts and idle notifications don't make Claude Code re-run the statusline, so without it the state indicator would lag until the next message.install again doesn't add duplicates.settings.json are rewritten in alphabetical order.Preview the changes first with claude-colorizer install --dry-run.
| Flag | Effect |
|---|---|
--dry-run | Print the changes without writing |
--no-hooks | Statusline only (or use this if you installed the plugin) |
--no-statusline | State colors only |
--settings PATH | Edit another file, e.g. .claude/settings.json in a project |
Pass flags through make with make install ARGS="--no-statusline".
make uninstall # or: claude-colorizer uninstall
This removes only the entries it added, restores a chained statusline, and deletes the binary.
The repo is also a plugin marketplace. The plugin provides the hooks and inline highlighting (below), but not the statusline, because plugins can't set one:
/plugin marketplace add /path/to/claude-colorizer
/plugin install claude-colorizer@claude-colorizer
Then run claude-colorizer install --no-hooks to add the statusline without registering the hooks twice. To try it for a single session without installing, run claude --plugin-dir /path/to/claude-colorizer.
Inline highlighting draws each color literal in Claude's replies on its own color, like nvim-colorizer does in a buffer. Replies without colors are drawn as usual. In a reply with colors, lines holding a color are drawn as plain text, so their bold and inline code markers are dropped.
It only works when the repo is loaded as a plugin. The highlighter is a function-hooks module (hooks/colorize.tsx, with hooks/colors.ts porting internal/colors), and Claude Code loads it only from the plugin's hooks/hooks.json. make install writes plain command hooks to settings.json, which can't load modules, so with make install alone you get state colors and swatches but no inline highlighting.
To turn it on, load the plugin in either of these ways:
claude --plugin-dir /path/to/claude-colorizer.hooks/.If you already ran make install, switch its hooks over to the plugin so each event doesn't fire twice:
claude-colorizer uninstall && claude-colorizer install --no-hooks
Highlighting starts in the next session. To check that it loaded, ask Claude to print a hex color such as #ff5733; it should appear on an orange background. Run its tests with claude plugin test ..
The tab, background tint and title glyph show what the session is doing, so you can tell from another tab or window whether Claude needs you.
| Color | Glyph | State | Meaning |
|---|---|---|---|
| Slate | ⚪ | idle | The session just started (or was cleared) and is waiting for your first prompt. |
| Blue | 🔵 | working | Claude is working: thinking, writing, running tools. Nothing for you to do yet. |
| Amber | 🟡 | attention | Claude is waiting on you: a permission prompt, or it has sat idle waiting for input. |
| Green | 🟢 | done | Claude finished its turn. Read the reply and send the next prompt. |
| Red | 🔴 | error | Something failed: the API request errored, or a tool call failed. |
| Your defaults | none | reset | The session ended. |
Red after a failed tool call doesn't always mean the turn is over. Claude often recovers, and the color goes back to blue on its next successful tool call. Red after an API error stays until you send another prompt.
The glyph is a fallback for terminals that can't color their tabs. It appears at the start of the window title. See the Title fallback column in Terminal support.
| Hook event | State | Default tab / tint | OSC 7501 state |
|---|---|---|---|
UserPromptSubmit, PostToolUse | working | #3b82f6 / #151b2b | working |
Notification (permission, idle) | attention | #f59e0b / #2a2112 | blocked |
Stop | done | #22c55e / #13231a | done |
StopFailure, PostToolUseFailure | error | #ef4444 / #2b1515 | error |
SessionStart | idle | #94a3b8 / #181b21 | idle |
SessionEnd | reset | terminal defaults | clear |
To change any color or glyph, see Configuration.
~/.config/claude-colorizer/config.json (or $CLAUDE_COLORIZER_CONFIG). Every key is optional and is layered over the defaults. Run claude-colorizer config to print the effective config.
{
"tab": true,
"background": true,
"title": true,
"status": true,
"tmuxBackground": "pane",
"states": {
"working": { "tab": "#7c3aed", "background": "#1a1426" },
"done": { "background": "#eef9f0" }
},
"statusline": {
"swatches": true,
"max": 12,
"sources": ["assistant", "tools", "user"],
"label": "hex",
"indicator": "label",
"prefix": "🎨 ",
"empty": ""
}
}
tmuxBackground only matters inside tmux: "pane" (default) tints just Claude's pane, "terminal" tints the outer terminal and keeps its background opacity (see tmux).
statusline.swatches turns the color swatches on (default) or off. With false, the transcript isn't read and the statusline shows only the state indicator. The other swatch keys (max, sources, label, prefix, empty) then have no effect.
statusline.indicator controls the state indicator: "label" (default, ● working), "dot" (just ●), or "none". The dot uses the state's tab color. Hooks record each session's state in a small file under your cache directory (~/.cache/claude-colorizer/sessions on Linux, or $CLAUDE_COLORIZER_STATE_DIR), which is deleted when the session ends.
The default background tints assume a dark theme. On a light theme, set light background values, or set "background": false.
make test # go vet + unit tests (parser, drivers, detection, transcript)
make cross # linux/darwin/windows binaries in dist/
See CONTRIBUTING.md for testing on a terminal and adding a new one.
hooks/colorize.tsx 59 lines1// Inline color highlighting in Claude's replies, in the spirit of
2// nvim-colorizer: each color literal is drawn on its own color.
3
4import type { Register, RenderChildren } from 'claude-code'
5
6import { contrast, hex } from './colors'
7import { segment, type Segment } from './segment'
8
9export const register: Register = on => {
10 on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
11 if (e.props.isSummary) return next(e)
12
13 const segments = segment(e.props.text)
14 if (!segments.some(s => s.kind === 'line')) return next(e)
15
16 const { Box, Text, Markdown } = $.ui.resolve(e)
17
18 const draw = (s: Segment, i: number) => {
19 switch (s.kind) {
20 case 'markdown':
21 return <Markdown key={`md${i}`} text={s.text} />
22 case 'blank':
23 return <Text key={`b${i}`}> </Text>
24 case 'line': {
25 const parts: RenderChildren[] = []
26 let at = 0
27 for (const m of s.matches) {
28 if (m.start > at) parts.push(s.text.slice(at, m.start))
29 parts.push(
30 <Text backgroundColor={hex(m.color)} color={contrast(m.color)}>
31 {m.text}
32 </Text>,
33 )
34 at = m.end
35 }
36 if (at < s.text.length) parts.push(s.text.slice(at))
37 return (
38 <Box key={`l${i}`} paddingLeft={s.code ? 2 : 0}>
39 <Text>{parts.length ? parts : ' '}</Text>
40 </Box>
41 )
42 }
43 }
44 }
45
46 // A tree of our own replaces the whole row, bullet included.
47 return (
48 <Box flexDirection="row">
49 <Box width={2} flexShrink={0}>
50 <Text color="text">{e.props.isFirstOfReply ? '●' : ' '}</Text>
51 </Box>
52 <Box flexDirection="column" flexGrow={1}>
53 {segments.map(draw)}
54 </Box>
55 </Box>
56 )
57 })
58}
59hooks/colors.ts 155 lines1// Port of internal/colors (find.go, color.go): finds color literals in text
2// and converts them to 24-bit sRGB. Keep the two in step.
3
4export type RGB = { r: number; g: number; b: number }
5
6// A color literal found in text. start and end are string offsets.
7export type Match = { start: number; end: number; text: string; color: RGB }
8
9const hexRe = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})/g
10// rgb()/rgba()/hsl()/hsla()/oklch() with comma or space syntax and an
11// optional alpha after "," or "/".
12const fnRe =
13 /\b(rgba?|hsla?|oklch)\(\s*([-+.\d]+(?:%|deg)?)\s*[,\s]\s*([-+.\d]+%?)\s*[,\s]\s*([-+.\d]+(?:%|deg)?)\s*(?:[,/]\s*[-+.\d]+%?\s*)?\)/gi
14
15// find returns every color literal in s, in order of appearance.
16export function find(s: string): Match[] {
17 const out: Match[] = []
18 for (const m of s.matchAll(hexRe)) {
19 const start = m.index!
20 const end = start + m[0].length
21 if (!hexBoundary(s, start, end)) continue
22 // "#123" is far more often an issue/PR number than a color, so short
23 // forms must contain at least one hex letter.
24 if (m[0].length <= 5 && !/[a-fA-F]/.test(m[0].slice(1))) continue
25 const color = parseHex(m[0])
26 if (color) out.push({ start, end, text: m[0], color })
27 }
28 for (const m of s.matchAll(fnRe)) {
29 const [, name = '', x = '', y = '', z = ''] = m
30 const color = parseFunc(name.toLowerCase(), [x, y, z])
31 if (!color) continue
32 out.push({ start: m.index!, end: m.index! + m[0].length, text: m[0], color })
33 }
34 return out.sort((a, b) => a.start - b.start)
35}
36
37// hexBoundary rejects hex runs embedded in longer tokens such as URL
38// fragments (#section), HTML entities ({) or longer hex strings.
39function hexBoundary(s: string, start: number, end: number): boolean {
40 const before = s.charAt(start - 1)
41 const after = s.charAt(end)
42 if (before === '&' || isWord(before)) return false
43 if (isWord(after) || after === '-') return false
44 return true
45}
46
47const isWord = (c: string) => /[A-Za-z0-9_]/.test(c)
48
49export function parseHex(s: string): RGB | null {
50 s = s.replace(/^#/, '')
51 if (s.length === 3 || s.length === 4) s = [0, 1, 2].map(i => s.charAt(i).repeat(2)).join('')
52 else if (s.length === 6 || s.length === 8) s = s.slice(0, 6)
53 else return null
54 if (!/^[0-9a-fA-F]{6}$/.test(s)) return null
55 const v = parseInt(s, 16)
56 return { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 }
57}
58
59function parseFunc(name: string, a: [string, string, string]): RGB | null {
60 const nx = num(a[0])
61 const ny = num(a[1])
62 const nz = num(a[2])
63 if (!nx || !ny || !nz) return null
64 const [[x, xPct], [y, yPct], [z, zPct]] = [nx, ny, nz]
65 switch (name) {
66 case 'rgb':
67 case 'rgba': {
68 const ch = (f: number, pct: boolean) => clamp8(pct ? (f * 255) / 100 : f)
69 return { r: ch(x, xPct), g: ch(y, yPct), b: ch(z, zPct) }
70 }
71 case 'hsl':
72 case 'hsla':
73 return hslToRGB(x, y / 100, z / 100)
74 case 'oklch': {
75 const l = xPct || x > 1 ? x / 100 : x
76 const c = yPct ? (y * 0.4) / 100 : y
77 return oklchToRGB(l, c, z)
78 }
79 }
80 return null
81}
82
83// num parses "12", "12.5%", "120deg" into [value, has trailing %].
84function num(s: string): [number, boolean] | null {
85 s = s.replace(/deg$/, '')
86 const pct = s.endsWith('%')
87 if (pct) s = s.slice(0, -1)
88 const f = Number(s)
89 return s === '' || Number.isNaN(f) ? null : [f, pct]
90}
91
92function clamp8(f: number): number {
93 if (Number.isNaN(f) || f < 0) return 0
94 if (f > 255) return 255
95 return Math.round(f)
96}
97
98function hslToRGB(h: number, s: number, l: number): RGB {
99 h = ((((h % 360) + 360) % 360) / 360)
100 if (s === 0) {
101 const v = clamp8(l * 255)
102 return { r: v, g: v, b: v }
103 }
104 const q = l < 0.5 ? l * (1 + s) : l + s - l * s
105 const p = 2 * l - q
106 const hue = (t: number) => {
107 if (t < 0) t++
108 if (t > 1) t--
109 if (t < 1 / 6) return p + (q - p) * 6 * t
110 if (t < 1 / 2) return q
111 if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
112 return p
113 }
114 return {
115 r: clamp8(hue(h + 1 / 3) * 255),
116 g: clamp8(hue(h) * 255),
117 b: clamp8(hue(h - 1 / 3) * 255),
118 }
119}
120
121// oklchToRGB converts OKLCH (L in [0,1], C, H in degrees) to gamut-clipped sRGB.
122function oklchToRGB(l: number, c: number, h: number): RGB {
123 const hr = (h * Math.PI) / 180
124 const a = c * Math.cos(hr)
125 const b = c * Math.sin(hr)
126
127 const l_ = l + 0.3963377774 * a + 0.2158037573 * b
128 const m_ = l - 0.1055613458 * a - 0.0638541728 * b
129 const s_ = l - 0.0894841775 * a - 1.291485548 * b
130 const l3 = l_ ** 3
131 const m3 = m_ ** 3
132 const s3 = s_ ** 3
133
134 const r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3
135 const g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3
136 const bl = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3
137
138 const gamma = (x: number) =>
139 x <= 0.0031308 ? clamp8(12.92 * x * 255) : clamp8((1.055 * x ** (1 / 2.4) - 0.055) * 255)
140 return { r: gamma(r), g: gamma(g), b: gamma(bl) }
141}
142
143export const hex = ({ r, g, b }: RGB) =>
144 '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')
145
146// contrast returns black or white, whichever reads better on c (WCAG).
147export function contrast(c: RGB): string {
148 const lin = (v: number) => {
149 const f = v / 255
150 return f <= 0.04045 ? f / 12.92 : ((f + 0.055) / 1.055) ** 2.4
151 }
152 const lum = 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b)
153 return lum > 0.179 ? '#000000' : '#ffffff'
154}
155hooks/segment.ts 71 lines1// Splits a reply's markdown so lines holding color literals can be drawn as
2// styled text while everything else keeps the engine's markdown renderer.
3
4import { find, type Match } from './colors'
5
6export type Segment =
7 | { kind: 'markdown'; text: string }
8 | { kind: 'blank' }
9 // A line drawn as plain text with each literal on its own color; code is
10 // true for a line from a fenced block.
11 | { kind: 'line'; text: string; matches: Match[]; code: boolean }
12
13const fenceRe = /^\s*(```|~~~)/
14
15export function segment(text: string): Segment[] {
16 const out: Segment[] = []
17 let md: string[] = []
18
19 const flush = () => {
20 // Blank lines at a chunk's edges become spacers, so the gap between a
21 // markdown chunk and a styled line matches the paragraph gap.
22 let lead = 0
23 while (lead < md.length && md[lead]?.trim() === '') lead++
24 let tail = md.length
25 while (tail > lead && md[tail - 1]?.trim() === '') tail--
26 for (let i = 0; i < lead; i++) out.push({ kind: 'blank' })
27 if (tail > lead) out.push({ kind: 'markdown', text: md.slice(lead, tail).join('\n') })
28 for (let i = tail; i < md.length; i++) out.push({ kind: 'blank' })
29 md = []
30 }
31
32 const lines = text.split('\n')
33 for (let i = 0; i < lines.length; i++) {
34 const line = lines[i] ?? ''
35 const fence = line.match(fenceRe)?.[1]
36 if (fence) {
37 let j = i + 1
38 while (j < lines.length && !lines[j]?.trimStart().startsWith(fence)) j++
39 const body = lines.slice(i + 1, j)
40 const block = lines.slice(i, j + 1)
41 i = j
42 if (!body.some(l => find(l).length)) {
43 md.push(...block)
44 continue
45 }
46 flush()
47 for (const l of body) out.push({ kind: 'line', text: l, matches: find(l), code: true })
48 continue
49 }
50
51 if (!find(line).length) {
52 md.push(line)
53 continue
54 }
55 flush()
56 const plain = stripInline(line)
57 out.push({ kind: 'line', text: plain, matches: find(plain), code: false })
58 }
59 flush()
60 return out
61}
62
63// stripInline drops the markdown syntax a styled line would otherwise show
64// raw: emphasis and code markers, heading hashes, list bullets.
65function stripInline(line: string): string {
66 return line
67 .replace(/^(\s*)#{1,6}\s+/, '$1')
68 .replace(/^(\s*)[-*+]\s+/, '$1• ')
69 .replace(/\*\*|__|`/g, '')
70}
71