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_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 busy-spinner animation for zut.",
"colors": {
"dark": {
"spinner_frames": ["◢", "◣", "◤", "◥"],
"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_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_frames": ["◐", "◓", "◑", "◒"]
}
}
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_interval_ms — frame interval in milliseconds. Missing or
invalid falls back to 80ms.
Working-status text is owned by zut and reflects the current operation. The
legacy spinner_messages field is ignored when present in existing theme
files. In this v0 API, the former tui.Theme.SpinnerMessages and
tui.ThemeOverrides.SpinnerMessages Go fields are removed; no compatibility
alias is provided.
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_interval_ms": 120
109}
110```
111
112Dark-only themes still work on light terminals. If `colors.light` is
113missing, zut applies `colors.dark` overrides on top of the built-in
114light default. The inverse also works.
115
116```json
117{
118 "name": "custom-spinner",
119 "description": "An alternative busy-spinner animation for zut.",
120 "colors": {
121 "dark": {
122 "spinner_frames": ["◢", "◣", "◤", "◥"],
123 "spinner_interval_ms": 120
124 }
125 }
126}
127```
128
129## Full shape
130
131All fields are optional.
132
133```json
134{
135 "name": "my-theme",
136 "description": "Shown in /settings → color theme.",
137 "color_descriptions": {
138 "accent": "Optional documentation for humans. zut ignores this object."
139 },
140 "colors": {
141 "dark": {
142 "fg": 253,
143 "muted": 244,
144 "accent": 111,
145 "background": "#0b1020",
146 "user": 180,
147 "user_bubble_bg": "#42454b",
148 "user_bubble_fg": 248,
149 "assistant": 117,
150 "tool": 114,
151 "tool_out": 245,
152 "error": 203,
153 "warning": 214,
154 "spinner": 183,
155 "selection_bg": 24,
156 "selection_fg": 231,
157 "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
158 "spinner_interval_ms": 80,
159 "syntax_base_style": "monokai",
160 "syntax": {
161 "keyword": "#81a1c1 bold",
162 "keyword_constant": "#81a1c1",
163 "keyword_declaration": "#81a1c1",
164 "keyword_namespace": "#81a1c1",
165 "keyword_reserved": "#81a1c1 bold",
166 "keyword_type": "#88c0d0",
167 "name_builtin": "#88c0d0",
168 "name_function": "#8fbcbb",
169 "name_class": "#a3be8c bold",
170 "name_decorator": "#b48ead",
171 "literal_string": "#a3be8c",
172 "literal_string_escape": "#bf616a",
173 "literal_number": "#d08770",
174 "comment": "#616e88 italic",
175 "comment_preproc": "#b48ead",
176 "operator": "#eceff4",
177 "punctuation": "#d8dee9",
178 "text": "#e5e9f0"
179 }
180 },
181 "light": {
182 "fg": 236,
183 "muted": 244,
184 "accent": 33
185 }
186 }
187}
188```
189
190You may also put overrides directly at the top level or directly under
191`colors` when they should apply to both modes:
192
193```json
194{
195 "name": "tiny",
196 "accent": 204,
197 "colors": {
198 "spinner_frames": ["◐", "◓", "◑", "◒"]
199 }
200}
201```
202
203## Color fields
204
205Every color field accepts the following JSON forms:
206
207```json
208254
209"#42454b"
210{ "mode": "256", "index": 254 }
211{ "mode": "ansi", "index": 100 }
212{ "mode": "rgb", "r": 66, "g": 69, "b": 75 }
213```
214
215Numeric values are xterm-256 indexes. ANSI values use SGR codes, with
216standard foreground/background ranges normalized to the role where necessary.
217RGB values are emitted directly when truecolor is advertised and quantized to
218xterm-256 otherwise. The built-in inherited theme additionally resolves
219semantic accents through the terminal's ANSI palette and derives muted and
220selected surfaces from the terminal foreground/background.
221
222- `fg` — default foreground text.
223- `muted` — secondary text, dividers, gutters, inactive hints.
224- `accent` — prompt bar, bullets, links, headings, active markers.
225- `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.
226- `user` — user role label color; mostly compatibility.
227- `user_bubble_bg` — background behind user message rows.
228- `user_bubble_fg` — foreground inside user message rows.
229- `assistant` — assistant/zut accent and spinner text.
230- `tool` — tool names, success marks, diff additions.
231- `tool_out` — plain tool-output text.
232- `error` — errors, refused calls, diff deletions.
233- `warning` — warnings and high context-usage state.
234- `spinner` — reserved spinner color slot.
235- `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.
236- `selection_bg` — highlighted row background.
237- `selection_fg` — highlighted row foreground.
238
239The same forms apply to `background`, `user_bubble_bg`, and every other
240semantic color field listed above.
241
242## Spinner fields
243
244Spinner settings can appear at top level, under `colors`, or under
245`colors.dark` / `colors.light`.
246
247- `spinner_frames` — list of frame strings. Single-cell glyphs keep
248 status-bar alignment clean.
249- `spinner_interval_ms` — frame interval in milliseconds. Missing or
250 invalid falls back to 80ms.
251
252Working-status text is owned by zut and reflects the current operation. The
253legacy `spinner_messages` field is ignored when present in existing theme
254files. In this v0 API, the former `tui.Theme.SpinnerMessages` and
255`tui.ThemeOverrides.SpinnerMessages` Go fields are removed; no compatibility
256alias is provided.
257
258## Syntax fields
259
260Syntax highlighting uses Chroma style entries. Values may include
261attributes after the color, such as `bold`, `italic`, or `underline`.
262
263Supported syntax override keys:
264
265```text
266keyword, keyword_constant, keyword_declaration, keyword_namespace,
267keyword_reserved, keyword_type, name_builtin, name_function,
268name_class, name_decorator, literal_string, literal_string_escape,
269literal_number, comment, comment_preproc, operator, punctuation, text
270```
271
272Example:
273
274```json
275{
276 "colors": {
277 "dark": {
278 "syntax_base_style": "monokai",
279 "syntax": {
280 "keyword": "#f05b8d",
281 "name_function": "#b675f1",
282 "literal_string": "#58c760",
283 "comment": "#a1a1a1 italic"
284 }
285 }
286 }
287}
288```
289
290## Theme-only extensions
291
292An extension can exist only to ship a theme. No slash command,
293subprocess, or executable is required when the extension contains a
294valid theme file.
295
296```text
297$ZUT_HOME/extensions/my-theme-extension/
298├── extension.json
299└── theme.json
300```
301
302`extension.json`:
303
304```json
305{
306 "name": "my-theme-extension",
307 "version": "1.0.0",
308 "description": "Ships a zut color theme",
309 "enabled": true
310}
311```
312
313No `exec` is needed when `theme.json` or `themes/theme.json` exists.
314If `exec` is present, zut treats it as a normal extension too.
315
316## Validate
317
318zut theme files are plain JSON, not JSONC. Validate before installing:
319
320```bash
321python3 -m json.tool theme.json >/dev/null
322```