zut themes
zut themes are JSON files that override any subset of the built-in
light/dark TUI theme. Nothing is required: a theme can change one
color, only the spinner, only syntax highlighting, or all of them.
Missing values inherit from zut's built-in default for the detected
terminal background.
Where themes live
User themes are discovered from:
$ZUT_HOME/themes/*.json
Open /settings and choose color theme to switch. Changes are
saved in $ZUT_HOME/config.json and apply immediately. If the selected
file is deleted later, zut resets the setting to the built-in auto
(default) theme.
The built-in choices are:
- auto — detect the terminal background and use zut's built-in dark or
light palette. This remains the default.
- inherited (from terminal) — use the terminal's reported foreground,
background, and ANSI palette. When
COLORTERM=truecolor/24bit or
TERM ends in -direct, zut emits truecolor; otherwise it quantizes
derived colors to the xterm-256 palette as a best effort. Terminals that
do not answer OSC color queries fall back to zut's built-in palette while
still using the advertised color depth.
- dark / light — use zut's fixed built-in palettes.
The inherited terminal snapshot is captured at startup and reused when the
setting is changed live; restart zut to pick up terminal palette changes.
All theme color roles use the same terminal-color model. JSON themes may use
xterm-256 indexes, ANSI SGR colors, or RGB values for any role. On terminals
that advertise truecolor, zut emits RGB values directly and expands indexed
values through the xterm palette; otherwise RGB values fall back to the
nearest xterm-256 color while indexed values remain indexed. ANSI values keep
their SGR semantics, with standard foreground/background ranges normalized to
the role where necessary.
Theme proposals
- shades of gray (truecolor) — a neutral grayscale palette for the UI and
syntax highlighting, with an xterm-256 fallback when direct color is
unavailable.
Theme files bundled with extensions are discovered in-place from loaded
extension directories:
$ZUT_HOME/extensions/<extension>/theme.json
$ZUT_HOME/extensions/<extension>/themes/theme.json
<project>/.zut/extensions/<extension>/theme.json
<project>/.zut/extensions/<extension>/themes/theme.json
zut does not copy extension themes into $ZUT_HOME/themes; extension
owned themes stay in the extension directory. The settings picker shows
source info such as from extension my-theme-extension.
Minimal themes
All of these are valid.
Metadata only:
{
"name": "my-theme",
"description": "Metadata only; all visuals inherit zut defaults."
}
One shared color for both light and dark terminals:
{
"name": "pink-accent",
"colors": {
"accent": 204
}
}
One color per mode:
{
"name": "split-accent",
"colors": {
"dark": { "accent": 204 },
"light": { "accent": 161 }
}
}
Spinner-only:
{
"name": "custom-spinner",
"description": "Only changes the busy spinner.",
"spinner_frames": ["◢", "◣", "◤", "◥"],
"spinner_messages": ["working"],
"spinner_interval_ms": 120
}
Dark-only themes still work on light terminals. If colors.light is
missing, zut applies colors.dark overrides on top of the built-in
light default. The inverse also works.
{
"name": "custom-spinner",
"description": "An alternative spinner for zut that only displays a single spinner text.",
"colors": {
"dark": {
"spinner_frames": ["◢", "◣", "◤", "◥"],
"spinner_messages": ["working"],
"spinner_interval_ms": 120
}
}
}
Full shape
All fields are optional.
{
"name": "my-theme",
"description": "Shown in /settings → color theme.",
"color_descriptions": {
"accent": "Optional documentation for humans. zut ignores this object."
},
"colors": {
"dark": {
"fg": 253,
"muted": 244,
"accent": 111,
"background": "#0b1020",
"user": 180,
"user_bubble_bg": "#42454b",
"user_bubble_fg": 248,
"assistant": 117,
"tool": 114,
"tool_out": 245,
"error": 203,
"warning": 214,
"spinner": 183,
"selection_bg": 24,
"selection_fg": 231,
"spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
"spinner_messages": ["thinking", "working"],
"spinner_interval_ms": 80,
"syntax_base_style": "monokai",
"syntax": {
"keyword": "#81a1c1 bold",
"keyword_constant": "#81a1c1",
"keyword_declaration": "#81a1c1",
"keyword_namespace": "#81a1c1",
"keyword_reserved": "#81a1c1 bold",
"keyword_type": "#88c0d0",
"name_builtin": "#88c0d0",
"name_function": "#8fbcbb",
"name_class": "#a3be8c bold",
"name_decorator": "#b48ead",
"literal_string": "#a3be8c",
"literal_string_escape": "#bf616a",
"literal_number": "#d08770",
"comment": "#616e88 italic",
"comment_preproc": "#b48ead",
"operator": "#eceff4",
"punctuation": "#d8dee9",
"text": "#e5e9f0"
}
},
"light": {
"fg": 236,
"muted": 244,
"accent": 33
}
}
}
You may also put overrides directly at the top level or directly under
colors when they should apply to both modes:
{
"name": "tiny",
"accent": 204,
"colors": {
"spinner_messages": ["shipping"]
}
}
Color fields
Every color field accepts the following JSON forms:
254
"#42454b"
{ "mode": "256", "index": 254 }
{ "mode": "ansi", "index": 100 }
{ "mode": "rgb", "r": 66, "g": 69, "b": 75 }
Numeric values are xterm-256 indexes. ANSI values use SGR codes, with
standard foreground/background ranges normalized to the role where necessary.
RGB values are emitted directly when truecolor is advertised and quantized to
xterm-256 otherwise. The built-in inherited theme additionally resolves
semantic accents through the terminal's ANSI palette and derives muted and
selected surfaces from the terminal foreground/background.
fg — default foreground text.
muted — secondary text, dividers, gutters, inactive hints.
accent — prompt bar, bullets, links, headings, active markers.
background — optional full-row TUI background. If missing, zut uses the terminal's existing background. Experimental: terminal background colors can vary by emulator and scrollback behavior; for the most reliable result, change your terminal background color in your terminal settings instead.
user — user role label color; mostly compatibility.
user_bubble_bg — background behind user message rows.
user_bubble_fg — foreground inside user message rows.
assistant — assistant/zut accent and spinner text.
tool — tool names, success marks, diff additions.
tool_out — plain tool-output text.
error — errors, refused calls, diff deletions.
warning — warnings and high context-usage state.
spinner — reserved spinner color slot.
thinkingMax or thinking_max (names retained for compatibility) - status color used when the opt-in max reasoning level is active. Missing values inherit the built-in theme color.
selection_bg — highlighted row background.
selection_fg — highlighted row foreground.
The same forms apply to background, user_bubble_bg, and every other
semantic color field listed above.
Spinner fields
Spinner settings can appear at top level, under colors, or under
colors.dark / colors.light.
spinner_frames — list of frame strings. Single-cell glyphs keep
status-bar alignment clean.
spinner_messages — list of messages; zut picks one per turn.
spinner_interval_ms — frame interval in milliseconds. Missing or
invalid falls back to 80ms.
Syntax fields
Syntax highlighting uses Chroma style entries. Values may include
attributes after the color, such as bold, italic, or underline.
Supported syntax override keys:
keyword, keyword_constant, keyword_declaration, keyword_namespace,
keyword_reserved, keyword_type, name_builtin, name_function,
name_class, name_decorator, literal_string, literal_string_escape,
literal_number, comment, comment_preproc, operator, punctuation, text
Example:
{
"colors": {
"dark": {
"syntax_base_style": "monokai",
"syntax": {
"keyword": "#f05b8d",
"name_function": "#b675f1",
"literal_string": "#58c760",
"comment": "#a1a1a1 italic"
}
}
}
}
Theme-only extensions
An extension can exist only to ship a theme. No slash command,
subprocess, or executable is required when the extension contains a
valid theme file.
$ZUT_HOME/extensions/my-theme-extension/
├── extension.json
└── theme.json
extension.json:
{
"name": "my-theme-extension",
"version": "1.0.0",
"description": "Ships a zut color theme",
"enabled": true
}
No exec is needed when theme.json or themes/theme.json exists.
If exec is present, zut treats it as a normal extension too.
Validate
zut theme files are plain JSON, not JSONC. Validate before installing:
python3 -m json.tool theme.json >/dev/null
source
1# zut themes
2
3zut themes are JSON files that override any subset of the built-in
4light/dark TUI theme. Nothing is required: a theme can change one
5color, only the spinner, only syntax highlighting, or all of them.
6Missing values inherit from zut's built-in default for the detected
7terminal background.
8
9## Where themes live
10
11User themes are discovered from:
12
13```text
14$ZUT_HOME/themes/*.json
15```
16
17Open `/settings` and choose **color theme** to switch. Changes are
18saved in `$ZUT_HOME/config.json` and apply immediately. If the selected
19file is deleted later, zut resets the setting to the built-in auto
20(default) theme.
21
22The built-in choices are:
23
24- **auto** — detect the terminal background and use zut's built-in dark or
25 light palette. This remains the default.
26- **inherited (from terminal)** — use the terminal's reported foreground,
27 background, and ANSI palette. When `COLORTERM=truecolor`/`24bit` or
28 `TERM` ends in `-direct`, zut emits truecolor; otherwise it quantizes
29 derived colors to the xterm-256 palette as a best effort. Terminals that
30 do not answer OSC color queries fall back to zut's built-in palette while
31 still using the advertised color depth.
32- **dark** / **light** — use zut's fixed built-in palettes.
33
34The inherited terminal snapshot is captured at startup and reused when the
35setting is changed live; restart zut to pick up terminal palette changes.
36
37All theme color roles use the same terminal-color model. JSON themes may use
38xterm-256 indexes, ANSI SGR colors, or RGB values for any role. On terminals
39that advertise truecolor, zut emits RGB values directly and expands indexed
40values through the xterm palette; otherwise RGB values fall back to the
41nearest xterm-256 color while indexed values remain indexed. ANSI values keep
42their SGR semantics, with standard foreground/background ranges normalized to
43the role where necessary.
44
45## Theme proposals
46
47- **shades of gray (truecolor)** — a neutral grayscale palette for the UI and
48 syntax highlighting, with an xterm-256 fallback when direct color is
49 unavailable.
50
51Theme files bundled with extensions are discovered in-place from loaded
52extension directories:
53
54```text
55$ZUT_HOME/extensions/<extension>/theme.json
56$ZUT_HOME/extensions/<extension>/themes/theme.json
57<project>/.zut/extensions/<extension>/theme.json
58<project>/.zut/extensions/<extension>/themes/theme.json
59```
60
61zut does **not** copy extension themes into `$ZUT_HOME/themes`; extension
62owned themes stay in the extension directory. The settings picker shows
63source info such as `from extension my-theme-extension`.
64
65## Minimal themes
66
67All of these are valid.
68
69Metadata only:
70
71```json
72{
73 "name": "my-theme",
74 "description": "Metadata only; all visuals inherit zut defaults."
75}
76```
77
78One shared color for both light and dark terminals:
79
80```json
81{
82 "name": "pink-accent",
83 "colors": {
84 "accent": 204
85 }
86}
87```
88
89One color per mode:
90
91```json
92{
93 "name": "split-accent",
94 "colors": {
95 "dark": { "accent": 204 },
96 "light": { "accent": 161 }
97 }
98}
99```
100
101Spinner-only:
102
103```json
104{
105 "name": "custom-spinner",
106 "description": "Only changes the busy spinner.",
107 "spinner_frames": ["◢", "◣", "◤", "◥"],
108 "spinner_messages": ["working"],
109 "spinner_interval_ms": 120
110}
111```
112
113Dark-only themes still work on light terminals. If `colors.light` is
114missing, zut applies `colors.dark` overrides on top of the built-in
115light default. The inverse also works.
116
117```json
118{
119 "name": "custom-spinner",
120 "description": "An alternative spinner for zut that only displays a single spinner text.",
121 "colors": {
122 "dark": {
123 "spinner_frames": ["◢", "◣", "◤", "◥"],
124 "spinner_messages": ["working"],
125 "spinner_interval_ms": 120
126 }
127 }
128}
129```
130
131## Full shape
132
133All fields are optional.
134
135```json
136{
137 "name": "my-theme",
138 "description": "Shown in /settings → color theme.",
139 "color_descriptions": {
140 "accent": "Optional documentation for humans. zut ignores this object."
141 },
142 "colors": {
143 "dark": {
144 "fg": 253,
145 "muted": 244,
146 "accent": 111,
147 "background": "#0b1020",
148 "user": 180,
149 "user_bubble_bg": "#42454b",
150 "user_bubble_fg": 248,
151 "assistant": 117,
152 "tool": 114,
153 "tool_out": 245,
154 "error": 203,
155 "warning": 214,
156 "spinner": 183,
157 "selection_bg": 24,
158 "selection_fg": 231,
159 "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
160 "spinner_messages": ["thinking", "working"],
161 "spinner_interval_ms": 80,
162 "syntax_base_style": "monokai",
163 "syntax": {
164 "keyword": "#81a1c1 bold",
165 "keyword_constant": "#81a1c1",
166 "keyword_declaration": "#81a1c1",
167 "keyword_namespace": "#81a1c1",
168 "keyword_reserved": "#81a1c1 bold",
169 "keyword_type": "#88c0d0",
170 "name_builtin": "#88c0d0",
171 "name_function": "#8fbcbb",
172 "name_class": "#a3be8c bold",
173 "name_decorator": "#b48ead",
174 "literal_string": "#a3be8c",
175 "literal_string_escape": "#bf616a",
176 "literal_number": "#d08770",
177 "comment": "#616e88 italic",
178 "comment_preproc": "#b48ead",
179 "operator": "#eceff4",
180 "punctuation": "#d8dee9",
181 "text": "#e5e9f0"
182 }
183 },
184 "light": {
185 "fg": 236,
186 "muted": 244,
187 "accent": 33
188 }
189 }
190}
191```
192
193You may also put overrides directly at the top level or directly under
194`colors` when they should apply to both modes:
195
196```json
197{
198 "name": "tiny",
199 "accent": 204,
200 "colors": {
201 "spinner_messages": ["shipping"]
202 }
203}
204```
205
206## Color fields
207
208Every color field accepts the following JSON forms:
209
210```json
211254
212"#42454b"
213{ "mode": "256", "index": 254 }
214{ "mode": "ansi", "index": 100 }
215{ "mode": "rgb", "r": 66, "g": 69, "b": 75 }
216```
217
218Numeric values are xterm-256 indexes. ANSI values use SGR codes, with
219standard foreground/background ranges normalized to the role where necessary.
220RGB values are emitted directly when truecolor is advertised and quantized to
221xterm-256 otherwise. The built-in inherited theme additionally resolves
222semantic accents through the terminal's ANSI palette and derives muted and
223selected surfaces from the terminal foreground/background.
224
225- `fg` — default foreground text.
226- `muted` — secondary text, dividers, gutters, inactive hints.
227- `accent` — prompt bar, bullets, links, headings, active markers.
228- `background` — optional full-row TUI background. If missing, zut uses the terminal's existing background. Experimental: terminal background colors can vary by emulator and scrollback behavior; for the most reliable result, change your terminal background color in your terminal settings instead.
229- `user` — user role label color; mostly compatibility.
230- `user_bubble_bg` — background behind user message rows.
231- `user_bubble_fg` — foreground inside user message rows.
232- `assistant` — assistant/zut accent and spinner text.
233- `tool` — tool names, success marks, diff additions.
234- `tool_out` — plain tool-output text.
235- `error` — errors, refused calls, diff deletions.
236- `warning` — warnings and high context-usage state.
237- `spinner` — reserved spinner color slot.
238- `thinkingMax` or `thinking_max` (names retained for compatibility) - status color used when the opt-in `max` reasoning level is active. Missing values inherit the built-in theme color.
239- `selection_bg` — highlighted row background.
240- `selection_fg` — highlighted row foreground.
241
242The same forms apply to `background`, `user_bubble_bg`, and every other
243semantic color field listed above.
244
245## Spinner fields
246
247Spinner settings can appear at top level, under `colors`, or under
248`colors.dark` / `colors.light`.
249
250- `spinner_frames` — list of frame strings. Single-cell glyphs keep
251 status-bar alignment clean.
252- `spinner_messages` — list of messages; zut picks one per turn.
253- `spinner_interval_ms` — frame interval in milliseconds. Missing or
254 invalid falls back to 80ms.
255
256## Syntax fields
257
258Syntax highlighting uses Chroma style entries. Values may include
259attributes after the color, such as `bold`, `italic`, or `underline`.
260
261Supported syntax override keys:
262
263```text
264keyword, keyword_constant, keyword_declaration, keyword_namespace,
265keyword_reserved, keyword_type, name_builtin, name_function,
266name_class, name_decorator, literal_string, literal_string_escape,
267literal_number, comment, comment_preproc, operator, punctuation, text
268```
269
270Example:
271
272```json
273{
274 "colors": {
275 "dark": {
276 "syntax_base_style": "monokai",
277 "syntax": {
278 "keyword": "#f05b8d",
279 "name_function": "#b675f1",
280 "literal_string": "#58c760",
281 "comment": "#a1a1a1 italic"
282 }
283 }
284 }
285}
286```
287
288## Theme-only extensions
289
290An extension can exist only to ship a theme. No slash command,
291subprocess, or executable is required when the extension contains a
292valid theme file.
293
294```text
295$ZUT_HOME/extensions/my-theme-extension/
296├── extension.json
297└── theme.json
298```
299
300`extension.json`:
301
302```json
303{
304 "name": "my-theme-extension",
305 "version": "1.0.0",
306 "description": "Ships a zut color theme",
307 "enabled": true
308}
309```
310
311No `exec` is needed when `theme.json` or `themes/theme.json` exists.
312If `exec` is present, zut treats it as a normal extension too.
313
314## Validate
315
316zut theme files are plain JSON, not JSONC. Validate before installing:
317
318```bash
319python3 -m json.tool theme.json >/dev/null
320```