detached 3dbf466

Merge pull request #156 from bnema/feat/terminal-owned-live-themes

feat(themes): follow live terminal appearance
commit
3dbf466b33bbc8a4f121198729a1366b621f8326
author
bnema · · 2026-08-23 15:51 UTC
committer
GitHub · · 2026-08-23 15:51 UTC
merge of
07cbc98 + 6b98b2a · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.24.0
25 files · +1802 -967
M README.md +1 -1
@@ -450,7 +450,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
450450 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
451451 - **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position`.
452452 - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.
453-- **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZUT_HOME/themes` or a loaded extension. Built-in and JSON themes follow terminal color capability: truecolor terminals receive RGB output, while RGB values fall back to xterm-256 elsewhere. Theme files can use xterm-256, ANSI, or RGB values for semantic UI colors and can also override syntax colors and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zut resets to auto. See [docs/themes.md](docs/themes.md).
453+- **color theme** — `auto` follows the terminal foreground, background, ANSI palette, color depth, and supported live appearance changes. Choose fixed dark/light palettes or JSON overlays discovered under `$ZUT_HOME/themes` and loaded extensions. Active custom files reload safely; invalid edits retain the last valid appearance and persistent deletion resets to auto. See [docs/themes.md](docs/themes.md).
454454 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
455455
456456 ### `/skills`
@@ -450,7 +450,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
450 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.450 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
451 - **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position`.451 - **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position`.
452 - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.452 - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.
453-- **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZUT_HOME/themes` or a loaded extension. Built-in and JSON themes follow terminal color capability: truecolor terminals receive RGB output, while RGB values fall back to xterm-256 elsewhere. Theme files can use xterm-256, ANSI, or RGB values for semantic UI colors and can also override syntax colors and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zut resets to auto. See [docs/themes.md](docs/themes.md).453+- **color theme** — `auto` follows the terminal foreground, background, ANSI palette, color depth, and supported live appearance changes. Choose fixed dark/light palettes or JSON overlays discovered under `$ZUT_HOME/themes` and loaded extensions. Active custom files reload safely; invalid edits retain the last valid appearance and persistent deletion resets to auto. See [docs/themes.md](docs/themes.md).
454 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.454 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
455 455
456 ### `/skills`456 ### `/skills`
M docs/themes.md +68 -247
@@ -1,55 +1,51 @@
11 # zut themes
22
3-zut themes are JSON files that override any subset of the built-in
4-light/dark TUI theme. Nothing is required: a theme can change one
5-color, only the spinner, only syntax highlighting, or all of them.
6-Missing values inherit from zut's built-in default for the detected
7-terminal background.
3+zut uses the controlling terminal as the source of truth for its default
4+appearance. Theme files are JSON overlays: every field is optional.
85
9-## Where themes live
6+## Built-in choices
107
11-User themes are discovered from:
8+Open `/settings` and select **color theme**.
129
13-```text
14-$ZUT_HOME/themes/*.json
15-```
10+- **auto** (default) follows the terminal default foreground and background,
11+ ANSI palette, color depth, and reported light/dark scheme. It does not paint
12+ a full-row background or set a cursor color.
13+- **dark** and **light** use zut's fixed palettes. They remain fixed when the
14+ terminal appearance changes.
15+- A **custom theme** overlays `auto`. Unspecified roles remain terminal-owned;
16+ explicit JSON values remain literal overrides.
1617
17-Open `/settings` and choose **color theme** to switch. Changes are
18-saved in `$ZUT_HOME/config.json` and apply immediately. If the selected
19-file is deleted later, zut resets the setting to the built-in auto
20-(default) theme.
18+`inherited` is no longer a theme choice. A persisted `inherited` value resets
19+to `auto`.
2120
22-The built-in choices are:
21+`ZUT_THEME=dark` or `ZUT_THEME=light` forces that choice for one process. The
22+settings dialog still saves the next-launch preference, but cannot override the
23+environment for the running process. An unset `ZUT_THEME` or `ZUT_THEME=auto`
24+uses the saved preference.
2325
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.
26+## Live terminal changes
3327
34-The inherited terminal snapshot is captured at startup and reused when the
35-setting is changed live; restart zut to pick up terminal palette changes.
28+Interactive zut queries terminal defaults and ANSI slots at startup. When the
29+terminal supports DEC mode 2031 color-scheme notifications, zut follows
30+reported scheme changes and refreshes its terminal profile. It probes the mode
31+before changing it and disables it on exit only when zut enabled a known-reset
32+mode itself. Terminals without notification support receive a low-rate OSC 11
33+and scheme query fallback (about two seconds).
3634
37-All theme color roles use the same terminal-color model. JSON themes may use
38-xterm-256 indexes, ANSI SGR colors, or RGB values for any role. On terminals
39-that advertise truecolor, zut emits RGB values directly and expands indexed
40-values through the xterm palette; otherwise RGB values fall back to the
41-nearest xterm-256 color while indexed values remain indexed. ANSI values keep
42-their SGR semantics, with standard foreground/background ranges normalized to
43-the role where necessary.
35+Terminal support varies through multiplexers and remote sessions. If a reply
36+does not arrive, zut remains usable with terminal default SGR colors and ANSI
37+slots. Existing scrollback is never recolored or cleared by a theme change;
38+only the visible live frame is repainted.
4439
45-## Theme proposals
40+## Theme files and reload
4641
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.
42+User themes live under:
43+
44+```text
45+$ZUT_HOME/themes/*.json
46+```
5047
51-Theme files bundled with extensions are discovered in-place from loaded
52-extension directories:
48+Extension-owned themes are loaded in place from:
5349
5450 ```text
5551 $ZUT_HOME/extensions/<extension>/theme.json
@@ -58,35 +54,25 @@ $ZUT_HOME/extensions/<extension>/themes/theme.json
5854 <project>/.zut/extensions/<extension>/themes/theme.json
5955 ```
6056
61-zut does **not** copy extension themes into `$ZUT_HOME/themes`; extension
62-owned themes stay in the extension directory. The settings picker shows
63-source info such as `from extension my-theme-extension`.
64-
65-## Minimal themes
57+An active custom file is polled every 500 ms. A valid complete edit applies
58+without restarting zut. Invalid JSON, invalid colors, unsupported syntax
59+styles, and oversized files leave the last valid revision active. Brief atomic
60+rename gaps are tolerated; a file that stays missing resets the saved choice to
61+`auto` and shows a status message.
6662
67-All of these are valid.
68-
69-Metadata only:
70-
71-```json
72-{
73- "name": "my-theme",
74- "description": "Metadata only; all visuals inherit zut defaults."
75-}
76-```
77-
78-One shared color for both light and dark terminals:
63+## Minimal files
7964
8065 ```json
8166 {
8267 "name": "pink-accent",
83- "colors": {
84- "accent": 204
85- }
68+ "colors": { "accent": 204 }
8669 }
8770 ```
8871
89-One color per mode:
72+Shared overrides can appear at the top level or directly in `colors`.
73+Mode-specific overrides go under `colors.dark` and `colors.light`; zut uses a
74+reported color scheme when available, and a background-luminance fallback
75+otherwise.
9076
9177 ```json
9278 {
@@ -98,111 +84,17 @@ One color per mode:
9884 }
9985 ```
10086
101-Spinner-only:
102-
10387 ```json
10488 {
105- "name": "custom-spinner",
106- "description": "Only changes the busy spinner.",
89+ "name": "spinner-only",
10790 "spinner_frames": ["◢", "◣", "◤", "◥"],
10891 "spinner_interval_ms": 120
10992 }
11093 ```
11194
112-Dark-only themes still work on light terminals. If `colors.light` is
113-missing, zut applies `colors.dark` overrides on top of the built-in
114-light default. The inverse also works.
95+## Color values
11596
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-
131-All 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-
190-You 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-
205-Every color field accepts the following JSON forms:
97+Every semantic color accepts one of:
20698
20799 ```json
208100 254
@@ -212,73 +104,31 @@ Every color field accepts the following JSON forms:
212104 { "mode": "rgb", "r": 66, "g": 69, "b": 75 }
213105 ```
214106
215-Numeric values are xterm-256 indexes. ANSI values use SGR codes, with
216-standard foreground/background ranges normalized to the role where necessary.
217-RGB values are emitted directly when truecolor is advertised and quantized to
218-xterm-256 otherwise. The built-in inherited theme additionally resolves
219-semantic accents through the terminal's ANSI palette and derives muted and
220-selected 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-
239-The same forms apply to `background`, `user_bubble_bg`, and every other
240-semantic color field listed above.
241-
242-## Spinner fields
243-
244-Spinner 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-
252-Working-status text is owned by zut and reflects the current operation. The
253-legacy `spinner_messages` field is ignored when present in existing theme
254-files. In this v0 API, the former `tui.Theme.SpinnerMessages` and
255-`tui.ThemeOverrides.SpinnerMessages` Go fields are removed; no compatibility
256-alias is provided.
107+Numeric values are literal xterm-256 indexes. ANSI values are literal ANSI SGR
108+colors. RGB values use truecolor when available, otherwise zut quantizes them
109+to the active terminal capability. `auto`-generated colors are distinct:
110+they retain terminal-default or terminal-palette-slot provenance and therefore
111+change with the terminal palette.
257112
258-## Syntax fields
113+Color roles are `fg`, `muted`, `accent`, `background`, `user`,
114+`user_bubble_bg`, `user_bubble_fg`, `assistant`, `tool`, `tool_out`, `error`,
115+`warning`, `spinner`, `thinking_max` (or `thinkingMax`), `selection_bg`, and
116+`selection_fg`. `background` intentionally paints the full zut row; omit it
117+to retain terminal background ownership.
259118
260-Syntax highlighting uses Chroma style entries. Values may include
261-attributes after the color, such as `bold`, `italic`, or `underline`.
119+`spinner_frames` contains 1–64 non-empty frames. `spinner_interval_ms` must be
120+between 10 and 10000. Theme files are limited to 1 MiB.
262121
263-Supported syntax override keys:
122+## Syntax
264123
265-```text
266-keyword, keyword_constant, keyword_declaration, keyword_namespace,
267-keyword_reserved, keyword_type, name_builtin, name_function,
268-name_class, name_decorator, literal_string, literal_string_escape,
269-literal_number, comment, comment_preproc, operator, punctuation, text
270-```
271-
272-Example:
124+Custom `syntax` entries use Chroma style entries, for example:
273125
274126 ```json
275127 {
276128 "colors": {
277129 "dark": {
278- "syntax_base_style": "monokai",
279130 "syntax": {
280- "keyword": "#f05b8d",
281- "name_function": "#b675f1",
131+ "keyword": "#f05b8d bold",
282132 "literal_string": "#58c760",
283133 "comment": "#a1a1a1 italic"
284134 }
@@ -287,36 +137,7 @@ Example:
287137 }
288138 ```
289139
290-## Theme-only extensions
291-
292-An extension can exist only to ship a theme. No slash command,
293-subprocess, or executable is required when the extension contains a
294-valid 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-
313-No `exec` is needed when `theme.json` or `themes/theme.json` exists.
314-If `exec` is present, zut treats it as a normal extension too.
315-
316-## Validate
317-
318-zut theme files are plain JSON, not JSONC. Validate before installing:
319-
320-```bash
321-python3 -m json.tool theme.json >/dev/null
322-```
140+In `auto`, zut keeps Chroma for lexing but emits its own exact terminal SGR
141+sequences. Keywords, names, strings, errors, numbers, comments, and plain text
142+therefore retain terminal ANSI-slot identity instead of being remapped through
143+the xterm color cube.
@@ -1,55 +1,51 @@
1 # zut themes1 # zut themes
2 2
3-zut themes are JSON files that override any subset of the built-in3+zut uses the controlling terminal as the source of truth for its default
4-light/dark TUI theme. Nothing is required: a theme can change one4+appearance. Theme files are JSON overlays: every field is optional.
5-color, only the spinner, only syntax highlighting, or all of them.
6-Missing values inherit from zut's built-in default for the detected
7-terminal background.
8 5
9-## Where themes live6+## Built-in choices
10 7
11-User themes are discovered from:8+Open `/settings` and select **color theme**.
12 9
13-```text10+- **auto** (default) follows the terminal default foreground and background,
14-$ZUT_HOME/themes/*.json11+ ANSI palette, color depth, and reported light/dark scheme. It does not paint
15-```12+ a full-row background or set a cursor color.
13+- **dark** and **light** use zut's fixed palettes. They remain fixed when the
14+ terminal appearance changes.
15+- A **custom theme** overlays `auto`. Unspecified roles remain terminal-owned;
16+ explicit JSON values remain literal overrides.
16 17
17-Open `/settings` and choose **color theme** to switch. Changes are18+`inherited` is no longer a theme choice. A persisted `inherited` value resets
18-saved in `$ZUT_HOME/config.json` and apply immediately. If the selected19+to `auto`.
19-file is deleted later, zut resets the setting to the built-in auto
20-(default) theme.
21 20
22-The built-in choices are:21+`ZUT_THEME=dark` or `ZUT_THEME=light` forces that choice for one process. The
22+settings dialog still saves the next-launch preference, but cannot override the
23+environment for the running process. An unset `ZUT_THEME` or `ZUT_THEME=auto`
24+uses the saved preference.
23 25
24-- **auto** — detect the terminal background and use zut's built-in dark or26+## Live terminal changes
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 27
34-The inherited terminal snapshot is captured at startup and reused when the28+Interactive zut queries terminal defaults and ANSI slots at startup. When the
35-setting is changed live; restart zut to pick up terminal palette changes.29+terminal supports DEC mode 2031 color-scheme notifications, zut follows
30+reported scheme changes and refreshes its terminal profile. It probes the mode
31+before changing it and disables it on exit only when zut enabled a known-reset
32+mode itself. Terminals without notification support receive a low-rate OSC 11
33+and scheme query fallback (about two seconds).
36 34
37-All theme color roles use the same terminal-color model. JSON themes may use35+Terminal support varies through multiplexers and remote sessions. If a reply
38-xterm-256 indexes, ANSI SGR colors, or RGB values for any role. On terminals36+does not arrive, zut remains usable with terminal default SGR colors and ANSI
39-that advertise truecolor, zut emits RGB values directly and expands indexed37+slots. Existing scrollback is never recolored or cleared by a theme change;
40-values through the xterm palette; otherwise RGB values fall back to the38+only the visible live frame is repainted.
41-nearest xterm-256 color while indexed values remain indexed. ANSI values keep
42-their SGR semantics, with standard foreground/background ranges normalized to
43-the role where necessary.
44 39
45-## Theme proposals40+## Theme files and reload
46 41
47-- **shades of gray (truecolor)** — a neutral grayscale palette for the UI and42+User themes live under:
48- syntax highlighting, with an xterm-256 fallback when direct color is43+
49- unavailable.44+```text
45+$ZUT_HOME/themes/*.json
46+```
50 47
51-Theme files bundled with extensions are discovered in-place from loaded48+Extension-owned themes are loaded in place from:
52-extension directories:
53 49
54 ```text50 ```text
55 $ZUT_HOME/extensions/<extension>/theme.json51 $ZUT_HOME/extensions/<extension>/theme.json
@@ -58,35 +54,25 @@ $ZUT_HOME/extensions/<extension>/themes/theme.json
58 <project>/.zut/extensions/<extension>/themes/theme.json54 <project>/.zut/extensions/<extension>/themes/theme.json
59 ```55 ```
60 56
61-zut does **not** copy extension themes into `$ZUT_HOME/themes`; extension57+An active custom file is polled every 500 ms. A valid complete edit applies
62-owned themes stay in the extension directory. The settings picker shows58+without restarting zut. Invalid JSON, invalid colors, unsupported syntax
63-source info such as `from extension my-theme-extension`.59+styles, and oversized files leave the last valid revision active. Brief atomic
64-60+rename gaps are tolerated; a file that stays missing resets the saved choice to
65-## Minimal themes61+`auto` and shows a status message.
66 62
67-All of these are valid.63+## Minimal files
68-
69-Metadata only:
70-
71-```json
72-{
73- "name": "my-theme",
74- "description": "Metadata only; all visuals inherit zut defaults."
75-}
76-```
77-
78-One shared color for both light and dark terminals:
79 64
80 ```json65 ```json
81 {66 {
82 "name": "pink-accent",67 "name": "pink-accent",
83- "colors": {68+ "colors": { "accent": 204 }
84- "accent": 204
85- }
86 }69 }
87 ```70 ```
88 71
89-One color per mode:72+Shared overrides can appear at the top level or directly in `colors`.
73+Mode-specific overrides go under `colors.dark` and `colors.light`; zut uses a
74+reported color scheme when available, and a background-luminance fallback
75+otherwise.
90 76
91 ```json77 ```json
92 {78 {
@@ -98,111 +84,17 @@ One color per mode:
98 }84 }
99 ```85 ```
100 86
101-Spinner-only:
102-
103 ```json87 ```json
104 {88 {
105- "name": "custom-spinner",89+ "name": "spinner-only",
106- "description": "Only changes the busy spinner.",
107 "spinner_frames": ["◢", "◣", "◤", "◥"],90 "spinner_frames": ["◢", "◣", "◤", "◥"],
108 "spinner_interval_ms": 12091 "spinner_interval_ms": 120
109 }92 }
110 ```93 ```
111 94
112-Dark-only themes still work on light terminals. If `colors.light` is95+## Color values
113-missing, zut applies `colors.dark` overrides on top of the built-in
114-light default. The inverse also works.
115 96
116-```json97+Every semantic color accepts one of:
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-
131-All 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-
190-You 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-
205-Every color field accepts the following JSON forms:
206 98
207 ```json99 ```json
208 254100 254
@@ -212,73 +104,31 @@ Every color field accepts the following JSON forms:
212 { "mode": "rgb", "r": 66, "g": 69, "b": 75 }104 { "mode": "rgb", "r": 66, "g": 69, "b": 75 }
213 ```105 ```
214 106
215-Numeric values are xterm-256 indexes. ANSI values use SGR codes, with107+Numeric values are literal xterm-256 indexes. ANSI values are literal ANSI SGR
216-standard foreground/background ranges normalized to the role where necessary.108+colors. RGB values use truecolor when available, otherwise zut quantizes them
217-RGB values are emitted directly when truecolor is advertised and quantized to109+to the active terminal capability. `auto`-generated colors are distinct:
218-xterm-256 otherwise. The built-in inherited theme additionally resolves110+they retain terminal-default or terminal-palette-slot provenance and therefore
219-semantic accents through the terminal's ANSI palette and derives muted and111+change with the terminal palette.
220-selected 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-
239-The same forms apply to `background`, `user_bubble_bg`, and every other
240-semantic color field listed above.
241-
242-## Spinner fields
243-
244-Spinner 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-
252-Working-status text is owned by zut and reflects the current operation. The
253-legacy `spinner_messages` field is ignored when present in existing theme
254-files. In this v0 API, the former `tui.Theme.SpinnerMessages` and
255-`tui.ThemeOverrides.SpinnerMessages` Go fields are removed; no compatibility
256-alias is provided.
257 112
258-## Syntax fields113+Color roles are `fg`, `muted`, `accent`, `background`, `user`,
114+`user_bubble_bg`, `user_bubble_fg`, `assistant`, `tool`, `tool_out`, `error`,
115+`warning`, `spinner`, `thinking_max` (or `thinkingMax`), `selection_bg`, and
116+`selection_fg`. `background` intentionally paints the full zut row; omit it
117+to retain terminal background ownership.
259 118
260-Syntax highlighting uses Chroma style entries. Values may include119+`spinner_frames` contains 1–64 non-empty frames. `spinner_interval_ms` must be
261-attributes after the color, such as `bold`, `italic`, or `underline`.120+between 10 and 10000. Theme files are limited to 1 MiB.
262 121
263-Supported syntax override keys:122+## Syntax
264 123
265-```text124+Custom `syntax` entries use Chroma style entries, for example:
266-keyword, keyword_constant, keyword_declaration, keyword_namespace,
267-keyword_reserved, keyword_type, name_builtin, name_function,
268-name_class, name_decorator, literal_string, literal_string_escape,
269-literal_number, comment, comment_preproc, operator, punctuation, text
270-```
271-
272-Example:
273 125
274 ```json126 ```json
275 {127 {
276 "colors": {128 "colors": {
277 "dark": {129 "dark": {
278- "syntax_base_style": "monokai",
279 "syntax": {130 "syntax": {
280- "keyword": "#f05b8d",131+ "keyword": "#f05b8d bold",
281- "name_function": "#b675f1",
282 "literal_string": "#58c760",132 "literal_string": "#58c760",
283 "comment": "#a1a1a1 italic"133 "comment": "#a1a1a1 italic"
284 }134 }
@@ -287,36 +137,7 @@ Example:
287 }137 }
288 ```138 ```
289 139
290-## Theme-only extensions140+In `auto`, zut keeps Chroma for lexing but emits its own exact terminal SGR
291-141+sequences. Keywords, names, strings, errors, numbers, comments, and plain text
292-An extension can exist only to ship a theme. No slash command,142+therefore retain terminal ANSI-slot identity instead of being remapped through
293-subprocess, or executable is required when the extension contains a143+the xterm color cube.
294-valid 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-
313-No `exec` is needed when `theme.json` or `themes/theme.json` exists.
314-If `exec` is present, zut treats it as a normal extension too.
315-
316-## Validate
317-
318-zut theme files are plain JSON, not JSONC. Validate before installing:
319-
320-```bash
321-python3 -m json.tool theme.json >/dev/null
322-```
M packages/agent/cli.go +18 -4
@@ -2488,12 +2488,23 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
24882488 for idx, s := range initialCfg.QuickModelShortcuts {
24892489 quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model}
24902490 }
2491- theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), initialCfg.Theme, 80*time.Millisecond)
2491+ themeEnv := strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME")))
2492+ if themeEnv != "" && themeEnv != "auto" && themeEnv != "dark" && themeEnv != "light" {
2493+ fmt.Fprintf(os.Stderr, "theme override: unsupported ZUT_THEME=%q; ignoring it\n", themeEnv)
2494+ themeEnv = ""
2495+ }
2496+ themePreference := tui.ResolveThemePreference(initialCfg.Theme, themeEnv)
2497+ theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), themePreference.Effective, 80*time.Millisecond)
24922498 if themeErr != nil {
24932499 fmt.Fprintln(os.Stderr, "theme load:", themeErr)
2494- if initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) {
2495- initialCfg.Theme = ""
2496- _ = SaveConfig(initialCfg)
2500+ if !themePreference.Forced {
2501+ themePreference = tui.ResolveThemePreference("", themeEnv)
2502+ if initialCfg.Theme != "" {
2503+ initialCfg.Theme = ""
2504+ if err := SaveConfig(initialCfg); err != nil {
2505+ fmt.Fprintln(os.Stderr, "theme reset:", err)
2506+ }
2507+ }
24972508 }
24982509 }
24992510
@@ -2554,6 +2565,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
25542565 TUIStatusPosition: initialCfg.TUIStatusPosition,
25552566 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
25562567 ThemeName: initialCfg.Theme,
2568+ EffectiveThemeName: themePreference.Effective,
2569+ ThemeForced: themePreference.Forced,
2570+ TerminalProfile: theme.Terminal,
25572571 FlatTools: initialCfg.FlatToolRender(),
25582572 CompactUser: initialCfg.CompactUserInput(),
25592573 ExtensionThemes: extMgr.ThemeOptions,
@@ -2488,12 +2488,23 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2488 for idx, s := range initialCfg.QuickModelShortcuts {2488 for idx, s := range initialCfg.QuickModelShortcuts {
2489 quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model}2489 quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model}
2490 }2490 }
2491- theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), initialCfg.Theme, 80*time.Millisecond)2491+ themeEnv := strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME")))
2492+ if themeEnv != "" && themeEnv != "auto" && themeEnv != "dark" && themeEnv != "light" {
2493+ fmt.Fprintf(os.Stderr, "theme override: unsupported ZUT_THEME=%q; ignoring it\n", themeEnv)
2494+ themeEnv = ""
2495+ }
2496+ themePreference := tui.ResolveThemePreference(initialCfg.Theme, themeEnv)
2497+ theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), themePreference.Effective, 80*time.Millisecond)
2492 if themeErr != nil {2498 if themeErr != nil {
2493 fmt.Fprintln(os.Stderr, "theme load:", themeErr)2499 fmt.Fprintln(os.Stderr, "theme load:", themeErr)
2494- if initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) {2500+ if !themePreference.Forced {
2495- initialCfg.Theme = ""2501+ themePreference = tui.ResolveThemePreference("", themeEnv)
2496- _ = SaveConfig(initialCfg)2502+ if initialCfg.Theme != "" {
2503+ initialCfg.Theme = ""
2504+ if err := SaveConfig(initialCfg); err != nil {
2505+ fmt.Fprintln(os.Stderr, "theme reset:", err)
2506+ }
2507+ }
2497 }2508 }
2498 }2509 }
2499 2510
@@ -2554,6 +2565,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2554 TUIStatusPosition: initialCfg.TUIStatusPosition,2565 TUIStatusPosition: initialCfg.TUIStatusPosition,
2555 TUIWorkingPosition: initialCfg.TUIWorkingPosition,2566 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2556 ThemeName: initialCfg.Theme,2567 ThemeName: initialCfg.Theme,
2568+ EffectiveThemeName: themePreference.Effective,
2569+ ThemeForced: themePreference.Forced,
2570+ TerminalProfile: theme.Terminal,
2557 FlatTools: initialCfg.FlatToolRender(),2571 FlatTools: initialCfg.FlatToolRender(),
2558 CompactUser: initialCfg.CompactUserInput(),2572 CompactUser: initialCfg.CompactUserInput(),
2559 ExtensionThemes: extMgr.ThemeOptions,2573 ExtensionThemes: extMgr.ThemeOptions,
M packages/agent/modes/btw_dialog.go +19 -0
@@ -147,6 +147,25 @@ func (d *btwDialog) ToggleToolExpansion() {
147147 }
148148 }
149149
150+func (d *btwDialog) setTheme(theme tui.Theme) {
151+ if d == nil {
152+ return
153+ }
154+ d.mu.Lock()
155+ defer d.mu.Unlock()
156+ d.theme = theme
157+ if d.toolView != nil {
158+ d.toolView.Theme = theme
159+ d.toolView.InvalidateRenderCache()
160+ }
161+ if d.editor != nil {
162+ d.editor.Prompt = theme.AccentBar(theme.Accent)
163+ }
164+ if d.spin != nil {
165+ d.spin.Configure(theme)
166+ }
167+}
168+
150169 // Open enters the side chat. agent supplies the live transcript and
151170 // system prompt, plus the underlying provider client to use for the
152171 // one-off completion. seed is an optional first question that gets
@@ -147,6 +147,25 @@ func (d *btwDialog) ToggleToolExpansion() {
147 }147 }
148 }148 }
149 149
150+func (d *btwDialog) setTheme(theme tui.Theme) {
151+ if d == nil {
152+ return
153+ }
154+ d.mu.Lock()
155+ defer d.mu.Unlock()
156+ d.theme = theme
157+ if d.toolView != nil {
158+ d.toolView.Theme = theme
159+ d.toolView.InvalidateRenderCache()
160+ }
161+ if d.editor != nil {
162+ d.editor.Prompt = theme.AccentBar(theme.Accent)
163+ }
164+ if d.spin != nil {
165+ d.spin.Configure(theme)
166+ }
167+}
168+
150 // Open enters the side chat. agent supplies the live transcript and169 // Open enters the side chat. agent supplies the live transcript and
151 // system prompt, plus the underlying provider client to use for the170 // system prompt, plus the underlying provider client to use for the
152 // one-off completion. seed is an optional first question that gets171 // one-off completion. seed is an optional first question that gets
M packages/agent/modes/interactive.go +191 -10
@@ -135,6 +135,16 @@ type InteractiveConfig struct {
135135
136136 // ThemeName mirrors the persisted config theme value. Empty means auto.
137137 ThemeName string
138+ // EffectiveThemeName is the process-local selection after ZUT_THEME
139+ // precedence. ThemeForced keeps settings persistent without changing a
140+ // dark/light environment override during this run.
141+ EffectiveThemeName string
142+ ThemeForced bool
143+ // TerminalProfile is distinct from the applied Theme because an explicit
144+ // or custom theme is not evidence about the controlling terminal.
145+ TerminalProfile tui.TerminalProfile
146+ // ThemeWatchInterval is test-only when non-zero; production uses 500ms.
147+ ThemeWatchInterval time.Duration
138148
139149 // FlatTools renders tool calls without the bordered panel (a quiet
140150 // header line plus indented, frameless output). Mirrors the
@@ -627,8 +637,14 @@ type scheduledFollowUp struct {
627637 type Interactive struct {
628638 cfg InteractiveConfig
629639 view *tui.View
630- ed *tui.Editor
631- rend *tui.Renderer
640+
641+ terminalProfile tui.TerminalProfile
642+ activeThemeSource *tui.ThemeSource
643+ themeWatchCancel context.CancelFunc
644+ themeWatchEvents chan themeWatchEvent
645+ themeWatchWarning string
646+ ed *tui.Editor
647+ rend *tui.Renderer
632648
633649 mu sync.Mutex
634650 agent *core.Agent
@@ -966,8 +982,34 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
966982 startupExtensionNames = append(startupExtensionNames, cfg.StartupExtensionNames...)
967983 startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...)
968984 }
985+ profile := cfg.TerminalProfile
986+ if profile == (tui.TerminalProfile{}) {
987+ profile = cfg.Theme.Terminal
988+ }
989+ preference := cfg.EffectiveThemeName
990+ if preference == "" {
991+ preference = cfg.ThemeName
992+ }
993+ if preference == "" {
994+ preference = "auto"
995+ }
996+ source, sourceErr := tui.LoadThemeSource(cfg.ZutHome, preference)
997+ // Embedders historically supplied a ready-made Theme without a selection.
998+ // Preserve that explicit construction; CLI startup always supplies an
999+ // explicit effective preference and therefore uses the pure resolver.
1000+ implicitReadyTheme := cfg.ThemeName == "" && cfg.EffectiveThemeName == "" && len(cfg.Theme.SpinnerFrames) != 0
1001+ // Source loading is only a startup optimization. Invalid selected files are
1002+ // handled by the existing invalid-selection path in the main loop.
1003+ if sourceErr == nil && !implicitReadyTheme {
1004+ cfg.Theme = tui.ResolveTheme(preference, source, profile).Theme
1005+ }
1006+ cfg.TerminalProfile = profile
1007+ cfg.EffectiveThemeName = preference
9691008 i := &Interactive{
970- cfg: cfg,
1009+ cfg: cfg,
1010+ terminalProfile: profile,
1011+ activeThemeSource: source,
1012+ themeWatchEvents: make(chan themeWatchEvent, 8),
9711013 view: &tui.View{
9721014 Theme: cfg.Theme,
9731015 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
@@ -1112,6 +1154,8 @@ func (i *Interactive) Run(ctx context.Context) error {
11121154 i.mu.Lock()
11131155 i.runCtx = ctx
11141156 i.mu.Unlock()
1157+ i.restartThemeWatch()
1158+ defer i.stopThemeWatch()
11151159 term := i.cfg.Terminal
11161160 restore, err := term.EnterRaw()
11171161 if err != nil {
@@ -1147,7 +1191,14 @@ func (i *Interactive) Run(ctx context.Context) error {
11471191 // Erase the live frame and place the cursor deterministically before any
11481192 // exit summary or shell prompt is written. Do not erase scrollback: users
11491193 // should still be able to review the session after closing zut.
1150- defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqMouseOff + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor))
1194+ mode2031Owned := false
1195+ defer func() {
1196+ cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqMouseOff + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor
1197+ if mode2031Owned {
1198+ cleanup = tui.SeqDisableMode2031 + cleanup
1199+ }
1200+ _, _ = term.Write([]byte(cleanup))
1201+ }()
11511202 i.applyInputCursorColor()
11521203
11531204 // Streaming pacer: drains buffered text deltas at a steady rate
@@ -1222,19 +1273,46 @@ func (i *Interactive) Run(ctx context.Context) error {
12221273 i.dialog.Open(i.cfg.ZutHome)
12231274 }
12241275
1225- // Input goroutine. Buffered generously so a drag-drop that the
1226- // terminal delivers as a burst of single-character key events
1227- // (no bracketed paste) can be drained in one main-loop pass
1228- // instead of triggering a redraw per character.
1276+ // The appearance-aware source is the only stdin reader. It consumes only
1277+ // replies for queries currently in flight, keeps bracketed-paste payload
1278+ // opaque, and sends every other byte through the established key parser.
12291279 keys := make(chan tui.Key, 256)
1280+ appearanceEvents := make(chan tui.InputEvent, 256)
1281+ appearanceParser := &tui.AppearanceParser{}
1282+ appearanceParser.SetPendingColors(true)
1283+ appearanceParser.SetPendingScheme(true)
1284+ inputDone := make(chan struct{})
12301285 go func() {
1231- reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
1286+ defer close(inputDone)
1287+ source := tui.NewAppearanceSource(term.ReadByte, term.PeekByteTimeout, appearanceParser, func(event tui.InputEvent) {
1288+ select {
1289+ case appearanceEvents <- event:
1290+ case <-ctx.Done():
1291+ }
1292+ })
1293+ reader := tui.NewReader(source.ReadByte)
12321294 for {
12331295 k, err := reader.Read()
12341296 if err != nil {
12351297 return
12361298 }
1237- keys <- k
1299+ select {
1300+ case keys <- k:
1301+ case <-ctx.Done():
1302+ return
1303+ }
1304+ }
1305+ }()
1306+ // Probe before mutating mode 2031. Replies are handled in the main loop so
1307+ // ownership and profile publication stay serialized with rendering.
1308+ _, _ = term.Write([]byte(tui.SeqQueryMode2031 + tui.AppearanceQuery()))
1309+ defer func() {
1310+ // Test terminals and normal EOF join immediately. A physical tty read
1311+ // may be uncancellable on some platforms, so shutdown never waits
1312+ // indefinitely for a kernel read that cannot be interrupted.
1313+ select {
1314+ case <-inputDone:
1315+ case <-time.After(50 * time.Millisecond):
12381316 }
12391317 }()
12401318
@@ -1310,11 +1388,97 @@ func (i *Interactive) Run(ctx context.Context) error {
13101388
13111389 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
13121390 changelog := i.cfg.ChangelogChan // single-shot, see case below
1391+ profileCandidate := i.terminalProfile
1392+ profileDirty := false
1393+ appearanceDeadline := time.Now().Add(500 * time.Millisecond)
1394+ lastAppearancePoll := time.Now()
1395+ modeEnableRequested := false
1396+ notificationsSupported := false
13131397
13141398 for {
13151399 select {
13161400 case <-ctx.Done():
13171401 return ctx.Err()
1402+ case event := <-i.themeWatchEvents:
1403+ if i.activeThemeSource == nil || event.path != i.activeThemeSource.Path {
1404+ break
1405+ }
1406+ switch {
1407+ case event.source != nil:
1408+ i.activeThemeSource = event.source
1409+ i.themeWatchWarning = ""
1410+ i.installResolvedTheme(tui.ResolveTheme(i.cfg.EffectiveThemeName, event.source, i.terminalProfile))
1411+ i.mu.Lock()
1412+ i.statusOK = "theme reloaded"
1413+ i.statusErr = ""
1414+ i.mu.Unlock()
1415+ case event.deleted:
1416+ i.activeThemeSource = nil
1417+ i.stopThemeWatch()
1418+ i.cfg.ThemeName = ""
1419+ i.cfg.EffectiveThemeName = "auto"
1420+ if i.cfg.SettingsStore != nil {
1421+ _ = i.cfg.SettingsStore.SetTheme("auto")
1422+ }
1423+ i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile))
1424+ i.mu.Lock()
1425+ i.statusOK = "theme removed; using auto"
1426+ i.statusErr = ""
1427+ i.mu.Unlock()
1428+ case event.err != nil && event.err.Error() != i.themeWatchWarning:
1429+ i.themeWatchWarning = event.err.Error()
1430+ i.mu.Lock()
1431+ i.statusErr = "theme reload: " + event.err.Error()
1432+ i.mu.Unlock()
1433+ }
1434+ i.invalidate()
1435+ case event := <-appearanceEvents:
1436+ switch {
1437+ case event.Color != nil:
1438+ switch event.Color.Kind {
1439+ case 10:
1440+ profileCandidate.Foreground = event.Color.Color
1441+ profileCandidate.HasForeground = true
1442+ case 11:
1443+ profileCandidate.Background = event.Color.Color
1444+ profileCandidate.HasBackground = true
1445+ case 4:
1446+ profileCandidate.Palette[event.Color.Slot] = event.Color.Color
1447+ profileCandidate.PaletteKnown |= uint16(1) << event.Color.Slot
1448+ }
1449+ profileDirty = true
1450+ case event.Scheme != nil:
1451+ profileCandidate.Light = event.Scheme.Light
1452+ profileCandidate.SchemeKnown = true
1453+ profileDirty = true
1454+ if notificationsSupported && !event.Scheme.Solicited {
1455+ // A scheme notification means the terminal defaults and palette
1456+ // may have changed too. Refresh the complete profile atomically.
1457+ appearanceParser.SetPendingColors(true)
1458+ appearanceParser.SetPendingScheme(true)
1459+ appearanceDeadline = time.Now().Add(500 * time.Millisecond)
1460+ _, _ = term.Write([]byte(tui.AppearanceQuery()))
1461+ }
1462+ case event.Mode != nil && event.Mode.Mode == 2031:
1463+ switch event.Mode.Status {
1464+ case 1, 3:
1465+ notificationsSupported = true
1466+ appearanceParser.SetNotifications(true)
1467+ if modeEnableRequested {
1468+ mode2031Owned = true
1469+ }
1470+ case 2:
1471+ modeEnableRequested = true
1472+ _, _ = term.Write([]byte(tui.SeqEnableMode2031 + tui.SeqQueryMode2031))
1473+ default:
1474+ appearanceParser.SetNotifications(false)
1475+ }
1476+ }
1477+ // The parser gate closes after a bounded collection window; accepted
1478+ // notifications remain enabled separately once mode 2031 is known.
1479+ if profileDirty {
1480+ i.invalidate()
1481+ }
13181482 case k := <-keys:
13191483 if done := i.handleKey(ctx, k); done {
13201484 return nil
@@ -1403,6 +1567,23 @@ func (i *Interactive) Run(ctx context.Context) error {
14031567 case <-i.dirty:
14041568 requestRedraw()
14051569 case <-tick.C:
1570+ now := time.Now()
1571+ if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) {
1572+ appearanceParser.SetPendingColors(false)
1573+ appearanceParser.SetPendingScheme(false)
1574+ appearanceDeadline = time.Time{}
1575+ }
1576+ if profileDirty {
1577+ i.applyTerminalProfile(profileCandidate)
1578+ profileDirty = false
1579+ }
1580+ if !notificationsSupported && now.Sub(lastAppearancePoll) >= 2*time.Second {
1581+ appearanceParser.SetPendingColors(true)
1582+ appearanceParser.SetPendingScheme(true)
1583+ appearanceDeadline = now.Add(500 * time.Millisecond)
1584+ lastAppearancePoll = now
1585+ _, _ = term.Write([]byte("\x1b]11;?\a" + tui.SeqQueryColorScheme))
1586+ }
14061587 // Always drain a pending redraw on the tick. This is the
14071588 // safety net that catches the case where the dirty channel
14081589 // was saturated when the final "turn finished" invalidate
@@ -135,6 +135,16 @@ type InteractiveConfig struct {
135 135
136 // ThemeName mirrors the persisted config theme value. Empty means auto.136 // ThemeName mirrors the persisted config theme value. Empty means auto.
137 ThemeName string137 ThemeName string
138+ // EffectiveThemeName is the process-local selection after ZUT_THEME
139+ // precedence. ThemeForced keeps settings persistent without changing a
140+ // dark/light environment override during this run.
141+ EffectiveThemeName string
142+ ThemeForced bool
143+ // TerminalProfile is distinct from the applied Theme because an explicit
144+ // or custom theme is not evidence about the controlling terminal.
145+ TerminalProfile tui.TerminalProfile
146+ // ThemeWatchInterval is test-only when non-zero; production uses 500ms.
147+ ThemeWatchInterval time.Duration
138 148
139 // FlatTools renders tool calls without the bordered panel (a quiet149 // FlatTools renders tool calls without the bordered panel (a quiet
140 // header line plus indented, frameless output). Mirrors the150 // header line plus indented, frameless output). Mirrors the
@@ -627,8 +637,14 @@ type scheduledFollowUp struct {
627 type Interactive struct {637 type Interactive struct {
628 cfg InteractiveConfig638 cfg InteractiveConfig
629 view *tui.View639 view *tui.View
630- ed *tui.Editor640+
631- rend *tui.Renderer641+ terminalProfile tui.TerminalProfile
642+ activeThemeSource *tui.ThemeSource
643+ themeWatchCancel context.CancelFunc
644+ themeWatchEvents chan themeWatchEvent
645+ themeWatchWarning string
646+ ed *tui.Editor
647+ rend *tui.Renderer
632 648
633 mu sync.Mutex649 mu sync.Mutex
634 agent *core.Agent650 agent *core.Agent
@@ -966,8 +982,34 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
966 startupExtensionNames = append(startupExtensionNames, cfg.StartupExtensionNames...)982 startupExtensionNames = append(startupExtensionNames, cfg.StartupExtensionNames...)
967 startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...)983 startupSkillNames = append(startupSkillNames, cfg.StartupSkillNames...)
968 }984 }
985+ profile := cfg.TerminalProfile
986+ if profile == (tui.TerminalProfile{}) {
987+ profile = cfg.Theme.Terminal
988+ }
989+ preference := cfg.EffectiveThemeName
990+ if preference == "" {
991+ preference = cfg.ThemeName
992+ }
993+ if preference == "" {
994+ preference = "auto"
995+ }
996+ source, sourceErr := tui.LoadThemeSource(cfg.ZutHome, preference)
997+ // Embedders historically supplied a ready-made Theme without a selection.
998+ // Preserve that explicit construction; CLI startup always supplies an
999+ // explicit effective preference and therefore uses the pure resolver.
1000+ implicitReadyTheme := cfg.ThemeName == "" && cfg.EffectiveThemeName == "" && len(cfg.Theme.SpinnerFrames) != 0
1001+ // Source loading is only a startup optimization. Invalid selected files are
1002+ // handled by the existing invalid-selection path in the main loop.
1003+ if sourceErr == nil && !implicitReadyTheme {
1004+ cfg.Theme = tui.ResolveTheme(preference, source, profile).Theme
1005+ }
1006+ cfg.TerminalProfile = profile
1007+ cfg.EffectiveThemeName = preference
969 i := &Interactive{1008 i := &Interactive{
970- cfg: cfg,1009+ cfg: cfg,
1010+ terminalProfile: profile,
1011+ activeThemeSource: source,
1012+ themeWatchEvents: make(chan themeWatchEvent, 8),
971 view: &tui.View{1013 view: &tui.View{
972 Theme: cfg.Theme,1014 Theme: cfg.Theme,
973 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),1015 ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
@@ -1112,6 +1154,8 @@ func (i *Interactive) Run(ctx context.Context) error {
1112 i.mu.Lock()1154 i.mu.Lock()
1113 i.runCtx = ctx1155 i.runCtx = ctx
1114 i.mu.Unlock()1156 i.mu.Unlock()
1157+ i.restartThemeWatch()
1158+ defer i.stopThemeWatch()
1115 term := i.cfg.Terminal1159 term := i.cfg.Terminal
1116 restore, err := term.EnterRaw()1160 restore, err := term.EnterRaw()
1117 if err != nil {1161 if err != nil {
@@ -1147,7 +1191,14 @@ func (i *Interactive) Run(ctx context.Context) error {
1147 // Erase the live frame and place the cursor deterministically before any1191 // Erase the live frame and place the cursor deterministically before any
1148 // exit summary or shell prompt is written. Do not erase scrollback: users1192 // exit summary or shell prompt is written. Do not erase scrollback: users
1149 // should still be able to review the session after closing zut.1193 // should still be able to review the session after closing zut.
1150- defer term.Write([]byte(tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqMouseOff + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor))1194+ mode2031Owned := false
1195+ defer func() {
1196+ cleanup := tui.SeqResetScrollRegion + tui.SeqDeleteKittyImages + tui.SeqMouseOff + tui.SeqEnhancedKeyboardOff + tui.SeqBracketedPasteOff + tui.ResetCursorColor() + tui.ResetCursorShape() + tui.SeqClearScreenNoHome + tui.MoveTo(1, 1) + tui.SeqShowCursor
1197+ if mode2031Owned {
1198+ cleanup = tui.SeqDisableMode2031 + cleanup
1199+ }
1200+ _, _ = term.Write([]byte(cleanup))
1201+ }()
1151 i.applyInputCursorColor()1202 i.applyInputCursorColor()
1152 1203
1153 // Streaming pacer: drains buffered text deltas at a steady rate1204 // Streaming pacer: drains buffered text deltas at a steady rate
@@ -1222,19 +1273,46 @@ func (i *Interactive) Run(ctx context.Context) error {
1222 i.dialog.Open(i.cfg.ZutHome)1273 i.dialog.Open(i.cfg.ZutHome)
1223 }1274 }
1224 1275
1225- // Input goroutine. Buffered generously so a drag-drop that the1276+ // The appearance-aware source is the only stdin reader. It consumes only
1226- // terminal delivers as a burst of single-character key events1277+ // replies for queries currently in flight, keeps bracketed-paste payload
1227- // (no bracketed paste) can be drained in one main-loop pass1278+ // opaque, and sends every other byte through the established key parser.
1228- // instead of triggering a redraw per character.
1229 keys := make(chan tui.Key, 256)1279 keys := make(chan tui.Key, 256)
1280+ appearanceEvents := make(chan tui.InputEvent, 256)
1281+ appearanceParser := &tui.AppearanceParser{}
1282+ appearanceParser.SetPendingColors(true)
1283+ appearanceParser.SetPendingScheme(true)
1284+ inputDone := make(chan struct{})
1230 go func() {1285 go func() {
1231- reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)1286+ defer close(inputDone)
1287+ source := tui.NewAppearanceSource(term.ReadByte, term.PeekByteTimeout, appearanceParser, func(event tui.InputEvent) {
1288+ select {
1289+ case appearanceEvents <- event:
1290+ case <-ctx.Done():
1291+ }
1292+ })
1293+ reader := tui.NewReader(source.ReadByte)
1232 for {1294 for {
1233 k, err := reader.Read()1295 k, err := reader.Read()
1234 if err != nil {1296 if err != nil {
1235 return1297 return
1236 }1298 }
1237- keys <- k1299+ select {
1300+ case keys <- k:
1301+ case <-ctx.Done():
1302+ return
1303+ }
1304+ }
1305+ }()
1306+ // Probe before mutating mode 2031. Replies are handled in the main loop so
1307+ // ownership and profile publication stay serialized with rendering.
1308+ _, _ = term.Write([]byte(tui.SeqQueryMode2031 + tui.AppearanceQuery()))
1309+ defer func() {
1310+ // Test terminals and normal EOF join immediately. A physical tty read
1311+ // may be uncancellable on some platforms, so shutdown never waits
1312+ // indefinitely for a kernel read that cannot be interrupted.
1313+ select {
1314+ case <-inputDone:
1315+ case <-time.After(50 * time.Millisecond):
1238 }1316 }
1239 }()1317 }()
1240 1318
@@ -1310,11 +1388,97 @@ func (i *Interactive) Run(ctx context.Context) error {
1310 1388
1311 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select1389 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
1312 changelog := i.cfg.ChangelogChan // single-shot, see case below1390 changelog := i.cfg.ChangelogChan // single-shot, see case below
1391+ profileCandidate := i.terminalProfile
1392+ profileDirty := false
1393+ appearanceDeadline := time.Now().Add(500 * time.Millisecond)
1394+ lastAppearancePoll := time.Now()
1395+ modeEnableRequested := false
1396+ notificationsSupported := false
1313 1397
1314 for {1398 for {
1315 select {1399 select {
1316 case <-ctx.Done():1400 case <-ctx.Done():
1317 return ctx.Err()1401 return ctx.Err()
1402+ case event := <-i.themeWatchEvents:
1403+ if i.activeThemeSource == nil || event.path != i.activeThemeSource.Path {
1404+ break
1405+ }
1406+ switch {
1407+ case event.source != nil:
1408+ i.activeThemeSource = event.source
1409+ i.themeWatchWarning = ""
1410+ i.installResolvedTheme(tui.ResolveTheme(i.cfg.EffectiveThemeName, event.source, i.terminalProfile))
1411+ i.mu.Lock()
1412+ i.statusOK = "theme reloaded"
1413+ i.statusErr = ""
1414+ i.mu.Unlock()
1415+ case event.deleted:
1416+ i.activeThemeSource = nil
1417+ i.stopThemeWatch()
1418+ i.cfg.ThemeName = ""
1419+ i.cfg.EffectiveThemeName = "auto"
1420+ if i.cfg.SettingsStore != nil {
1421+ _ = i.cfg.SettingsStore.SetTheme("auto")
1422+ }
1423+ i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile))
1424+ i.mu.Lock()
1425+ i.statusOK = "theme removed; using auto"
1426+ i.statusErr = ""
1427+ i.mu.Unlock()
1428+ case event.err != nil && event.err.Error() != i.themeWatchWarning:
1429+ i.themeWatchWarning = event.err.Error()
1430+ i.mu.Lock()
1431+ i.statusErr = "theme reload: " + event.err.Error()
1432+ i.mu.Unlock()
1433+ }
1434+ i.invalidate()
1435+ case event := <-appearanceEvents:
1436+ switch {
1437+ case event.Color != nil:
1438+ switch event.Color.Kind {
1439+ case 10:
1440+ profileCandidate.Foreground = event.Color.Color
1441+ profileCandidate.HasForeground = true
1442+ case 11:
1443+ profileCandidate.Background = event.Color.Color
1444+ profileCandidate.HasBackground = true
1445+ case 4:
1446+ profileCandidate.Palette[event.Color.Slot] = event.Color.Color
1447+ profileCandidate.PaletteKnown |= uint16(1) << event.Color.Slot
1448+ }
1449+ profileDirty = true
1450+ case event.Scheme != nil:
1451+ profileCandidate.Light = event.Scheme.Light
1452+ profileCandidate.SchemeKnown = true
1453+ profileDirty = true
1454+ if notificationsSupported && !event.Scheme.Solicited {
1455+ // A scheme notification means the terminal defaults and palette
1456+ // may have changed too. Refresh the complete profile atomically.
1457+ appearanceParser.SetPendingColors(true)
1458+ appearanceParser.SetPendingScheme(true)
1459+ appearanceDeadline = time.Now().Add(500 * time.Millisecond)
1460+ _, _ = term.Write([]byte(tui.AppearanceQuery()))
1461+ }
1462+ case event.Mode != nil && event.Mode.Mode == 2031:
1463+ switch event.Mode.Status {
1464+ case 1, 3:
1465+ notificationsSupported = true
1466+ appearanceParser.SetNotifications(true)
1467+ if modeEnableRequested {
1468+ mode2031Owned = true
1469+ }
1470+ case 2:
1471+ modeEnableRequested = true
1472+ _, _ = term.Write([]byte(tui.SeqEnableMode2031 + tui.SeqQueryMode2031))
1473+ default:
1474+ appearanceParser.SetNotifications(false)
1475+ }
1476+ }
1477+ // The parser gate closes after a bounded collection window; accepted
1478+ // notifications remain enabled separately once mode 2031 is known.
1479+ if profileDirty {
1480+ i.invalidate()
1481+ }
1318 case k := <-keys:1482 case k := <-keys:
1319 if done := i.handleKey(ctx, k); done {1483 if done := i.handleKey(ctx, k); done {
1320 return nil1484 return nil
@@ -1403,6 +1567,23 @@ func (i *Interactive) Run(ctx context.Context) error {
1403 case <-i.dirty:1567 case <-i.dirty:
1404 requestRedraw()1568 requestRedraw()
1405 case <-tick.C:1569 case <-tick.C:
1570+ now := time.Now()
1571+ if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) {
1572+ appearanceParser.SetPendingColors(false)
1573+ appearanceParser.SetPendingScheme(false)
1574+ appearanceDeadline = time.Time{}
1575+ }
1576+ if profileDirty {
1577+ i.applyTerminalProfile(profileCandidate)
1578+ profileDirty = false
1579+ }
1580+ if !notificationsSupported && now.Sub(lastAppearancePoll) >= 2*time.Second {
1581+ appearanceParser.SetPendingColors(true)
1582+ appearanceParser.SetPendingScheme(true)
1583+ appearanceDeadline = now.Add(500 * time.Millisecond)
1584+ lastAppearancePoll = now
1585+ _, _ = term.Write([]byte("\x1b]11;?\a" + tui.SeqQueryColorScheme))
1586+ }
1406 // Always drain a pending redraw on the tick. This is the1587 // Always drain a pending redraw on the tick. This is the
1407 // safety net that catches the case where the dirty channel1588 // safety net that catches the case where the dirty channel
1408 // was saturated when the final "turn finished" invalidate1589 // was saturated when the final "turn finished" invalidate
M packages/agent/modes/interactive_render.go +4 -1
@@ -54,7 +54,10 @@ func (s *latestFrameScheduler) requestTheme(theme tui.Theme) bool {
5454 if s.stopped {
5555 return false
5656 }
57- s.clear = true
57+ // Theme changes repaint only the visible frame. Clear() emits 3J and
58+ // destroys scrollback, which is never an acceptable side effect of a live
59+ // terminal appearance update.
60+ s.invalidate = true
5861 s.theme = &theme
5962 select {
6063 case s.wake <- struct{}{}:
@@ -54,7 +54,10 @@ func (s *latestFrameScheduler) requestTheme(theme tui.Theme) bool {
54 if s.stopped {54 if s.stopped {
55 return false55 return false
56 }56 }
57- s.clear = true57+ // Theme changes repaint only the visible frame. Clear() emits 3J and
58+ // destroys scrollback, which is never an acceptable side effect of a live
59+ // terminal appearance update.
60+ s.invalidate = true
58 s.theme = &theme61 s.theme = &theme
59 select {62 select {
60 case s.wake <- struct{}{}:63 case s.wake <- struct{}{}:
M packages/agent/modes/interactive_settings.go +50 -24
@@ -42,6 +42,10 @@ func (i *Interactive) applyInputCursorColor() {
4242 if i == nil || i.cfg.Terminal == nil {
4343 return
4444 }
45+ if i.cfg.EffectiveThemeName == "auto" && i.cfg.Theme.FG == tui.TerminalDefault() {
46+ _, _ = i.cfg.Terminal.Write([]byte(tui.ResetCursorColor() + tui.CursorShapeBlock()))
47+ return
48+ }
4549 color := tui.Color256(15)
4650 if i.cursorDimmed {
4751 color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent)
@@ -383,7 +387,7 @@ func (i *Interactive) openSettingsDialog() {
383387 {
384388 key: "theme",
385389 label: "color theme",
386- desc: "choose auto, inherited terminal colors, a built-in dark/light theme, or a loaded theme file",
390+ desc: "choose terminal-owned auto, a built-in dark/light theme, or a loaded theme file",
387391 options: themeOptions,
388392 choice: themeChoice,
389393 },
@@ -1167,50 +1171,72 @@ func (i *Interactive) applyThemeSetting(name string) {
11671171 if name == "auto" {
11681172 i.cfg.ThemeName = ""
11691173 }
1170- i.applyThemeNow(name)
1174+ if !i.cfg.ThemeForced {
1175+ i.cfg.EffectiveThemeName = name
1176+ }
1177+ i.applyThemeNow(i.cfg.EffectiveThemeName)
11711178 }
1179+
11721180 func (i *Interactive) applyThemeNow(name string) {
11731181 if name == "" {
11741182 name = "auto"
11751183 }
1176- detected := tui.Dark
1177- if tui.IsLightTheme(i.cfg.Theme) {
1178- detected = tui.Light
1179- }
1180- // Keep the startup terminal snapshot available when a user switches
1181- // themes live; inherited mode must not issue OSC queries while the TUI
1182- // already owns raw stdin.
1183- detected.Terminal = i.cfg.Theme.Terminal
1184- th, applied, err := tui.LoadThemeFromHome(i.cfg.ZutHome, name, detected)
1184+ source, err := tui.LoadThemeSource(i.cfg.ZutHome, name)
11851185 if err != nil {
11861186 if i.cfg.SettingsStore != nil {
11871187 _ = i.cfg.SettingsStore.SetTheme("auto")
11881188 }
11891189 i.cfg.ThemeName = ""
1190- th, _, _ = tui.LoadThemeFromHome(i.cfg.ZutHome, "auto", detected)
1191- i.mu.Lock()
1192- i.statusErr = "theme missing; reset to default"
1193- i.mu.Unlock()
1194- } else {
1190+ i.cfg.EffectiveThemeName = "auto"
1191+ i.activeThemeSource = nil
1192+ i.stopThemeWatch()
1193+ i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile))
11951194 i.mu.Lock()
1196- label := applied
1197- if label == "" {
1198- label = "auto"
1199- }
1200- i.statusOK = "theme " + label
1201- i.statusErr = ""
1195+ i.statusErr = "theme missing; reset to auto"
12021196 i.mu.Unlock()
1197+ return
12031198 }
1199+ i.activeThemeSource = source
1200+ i.restartThemeWatch()
1201+ i.installResolvedTheme(tui.ResolveTheme(name, source, i.terminalProfile))
1202+ i.mu.Lock()
1203+ i.statusOK = "theme " + i.cfg.EffectiveThemeName
1204+ if i.cfg.ThemeForced {
1205+ i.statusOK += " (forced by ZUT_THEME)"
1206+ }
1207+ i.statusErr = ""
1208+ i.mu.Unlock()
1209+}
1210+
1211+// installResolvedTheme is the one main-loop-owned installation boundary for
1212+// settings, terminal profile, and accepted theme-file revisions.
1213+// applyTerminalProfile records detected appearance independently from the
1214+// currently applied theme. Forced and custom selections are re-resolved from
1215+// their immutable accepted source; no terminal event performs filesystem I/O.
1216+func (i *Interactive) applyTerminalProfile(profile tui.TerminalProfile) {
1217+ i.terminalProfile = profile
1218+ i.cfg.TerminalProfile = profile
1219+ resolution := tui.ResolveTheme(i.cfg.EffectiveThemeName, i.activeThemeSource, profile)
1220+ i.installResolvedTheme(resolution)
1221+}
1222+
1223+func (i *Interactive) installResolvedTheme(resolution tui.ThemeResolution) {
1224+ th := resolution.Theme
12041225 i.mu.Lock()
12051226 i.cfg.Theme = th
12061227 i.view.Theme = th
12071228 i.view.InvalidateRenderCache()
1208- i.mu.Unlock()
1229+ i.chatCacheValid = false
1230+ i.stableChatCacheValid = false
12091231 i.ed.Prompt = th.AccentBar(th.Accent)
1232+ i.mu.Unlock()
1233+ i.btwDialog.setTheme(th)
1234+ if i.residentChildSession != nil {
1235+ i.residentChildSession.setTheme(th)
1236+ }
12101237 i.applyInputCursorColor()
12111238 i.spin.Configure(th)
12121239 i.requestRendererTheme(th)
1213- i.requestRendererClear()
12141240 i.invalidate()
12151241 }
12161242 func (i *Interactive) applyReasoningSetting(level string) {
@@ -42,6 +42,10 @@ func (i *Interactive) applyInputCursorColor() {
42 if i == nil || i.cfg.Terminal == nil {42 if i == nil || i.cfg.Terminal == nil {
43 return43 return
44 }44 }
45+ if i.cfg.EffectiveThemeName == "auto" && i.cfg.Theme.FG == tui.TerminalDefault() {
46+ _, _ = i.cfg.Terminal.Write([]byte(tui.ResetCursorColor() + tui.CursorShapeBlock()))
47+ return
48+ }
45 color := tui.Color256(15)49 color := tui.Color256(15)
46 if i.cursorDimmed {50 if i.cursorDimmed {
47 color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent)51 color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent)
@@ -383,7 +387,7 @@ func (i *Interactive) openSettingsDialog() {
383 {387 {
384 key: "theme",388 key: "theme",
385 label: "color theme",389 label: "color theme",
386- desc: "choose auto, inherited terminal colors, a built-in dark/light theme, or a loaded theme file",390+ desc: "choose terminal-owned auto, a built-in dark/light theme, or a loaded theme file",
387 options: themeOptions,391 options: themeOptions,
388 choice: themeChoice,392 choice: themeChoice,
389 },393 },
@@ -1167,50 +1171,72 @@ func (i *Interactive) applyThemeSetting(name string) {
1167 if name == "auto" {1171 if name == "auto" {
1168 i.cfg.ThemeName = ""1172 i.cfg.ThemeName = ""
1169 }1173 }
1170- i.applyThemeNow(name)1174+ if !i.cfg.ThemeForced {
1175+ i.cfg.EffectiveThemeName = name
1176+ }
1177+ i.applyThemeNow(i.cfg.EffectiveThemeName)
1171 }1178 }
1179+
1172 func (i *Interactive) applyThemeNow(name string) {1180 func (i *Interactive) applyThemeNow(name string) {
1173 if name == "" {1181 if name == "" {
1174 name = "auto"1182 name = "auto"
1175 }1183 }
1176- detected := tui.Dark1184+ source, err := tui.LoadThemeSource(i.cfg.ZutHome, name)
1177- if tui.IsLightTheme(i.cfg.Theme) {
1178- detected = tui.Light
1179- }
1180- // Keep the startup terminal snapshot available when a user switches
1181- // themes live; inherited mode must not issue OSC queries while the TUI
1182- // already owns raw stdin.
1183- detected.Terminal = i.cfg.Theme.Terminal
1184- th, applied, err := tui.LoadThemeFromHome(i.cfg.ZutHome, name, detected)
1185 if err != nil {1185 if err != nil {
1186 if i.cfg.SettingsStore != nil {1186 if i.cfg.SettingsStore != nil {
1187 _ = i.cfg.SettingsStore.SetTheme("auto")1187 _ = i.cfg.SettingsStore.SetTheme("auto")
1188 }1188 }
1189 i.cfg.ThemeName = ""1189 i.cfg.ThemeName = ""
1190- th, _, _ = tui.LoadThemeFromHome(i.cfg.ZutHome, "auto", detected)1190+ i.cfg.EffectiveThemeName = "auto"
1191- i.mu.Lock()1191+ i.activeThemeSource = nil
1192- i.statusErr = "theme missing; reset to default"1192+ i.stopThemeWatch()
1193- i.mu.Unlock()1193+ i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile))
1194- } else {
1195 i.mu.Lock()1194 i.mu.Lock()
1196- label := applied1195+ i.statusErr = "theme missing; reset to auto"
1197- if label == "" {
1198- label = "auto"
1199- }
1200- i.statusOK = "theme " + label
1201- i.statusErr = ""
1202 i.mu.Unlock()1196 i.mu.Unlock()
1197+ return
1203 }1198 }
1199+ i.activeThemeSource = source
1200+ i.restartThemeWatch()
1201+ i.installResolvedTheme(tui.ResolveTheme(name, source, i.terminalProfile))
1202+ i.mu.Lock()
1203+ i.statusOK = "theme " + i.cfg.EffectiveThemeName
1204+ if i.cfg.ThemeForced {
1205+ i.statusOK += " (forced by ZUT_THEME)"
1206+ }
1207+ i.statusErr = ""
1208+ i.mu.Unlock()
1209+}
1210+
1211+// installResolvedTheme is the one main-loop-owned installation boundary for
1212+// settings, terminal profile, and accepted theme-file revisions.
1213+// applyTerminalProfile records detected appearance independently from the
1214+// currently applied theme. Forced and custom selections are re-resolved from
1215+// their immutable accepted source; no terminal event performs filesystem I/O.
1216+func (i *Interactive) applyTerminalProfile(profile tui.TerminalProfile) {
1217+ i.terminalProfile = profile
1218+ i.cfg.TerminalProfile = profile
1219+ resolution := tui.ResolveTheme(i.cfg.EffectiveThemeName, i.activeThemeSource, profile)
1220+ i.installResolvedTheme(resolution)
1221+}
1222+
1223+func (i *Interactive) installResolvedTheme(resolution tui.ThemeResolution) {
1224+ th := resolution.Theme
1204 i.mu.Lock()1225 i.mu.Lock()
1205 i.cfg.Theme = th1226 i.cfg.Theme = th
1206 i.view.Theme = th1227 i.view.Theme = th
1207 i.view.InvalidateRenderCache()1228 i.view.InvalidateRenderCache()
1208- i.mu.Unlock()1229+ i.chatCacheValid = false
1230+ i.stableChatCacheValid = false
1209 i.ed.Prompt = th.AccentBar(th.Accent)1231 i.ed.Prompt = th.AccentBar(th.Accent)
1232+ i.mu.Unlock()
1233+ i.btwDialog.setTheme(th)
1234+ if i.residentChildSession != nil {
1235+ i.residentChildSession.setTheme(th)
1236+ }
1210 i.applyInputCursorColor()1237 i.applyInputCursorColor()
1211 i.spin.Configure(th)1238 i.spin.Configure(th)
1212 i.requestRendererTheme(th)1239 i.requestRendererTheme(th)
1213- i.requestRendererClear()
1214 i.invalidate()1240 i.invalidate()
1215 }1241 }
1216 func (i *Interactive) applyReasoningSetting(level string) {1242 func (i *Interactive) applyReasoningSetting(level string) {
M packages/agent/modes/subagent_session.go +10 -0
@@ -55,6 +55,16 @@ func (s *residentChildSession) Close() {
5555 }
5656 }
5757
58+func (s *residentChildSession) setTheme(theme tui.Theme) {
59+ if s == nil {
60+ return
61+ }
62+ s.mu.Lock()
63+ defer s.mu.Unlock()
64+ s.view.Theme = theme
65+ s.view.InvalidateRenderCache()
66+}
67+
5868 func (s *residentChildSession) LoadRecent(limit int) error {
5969 if s == nil || s.manager == nil {
6070 return fmt.Errorf("resident child session: unavailable")
@@ -55,6 +55,16 @@ func (s *residentChildSession) Close() {
55 }55 }
56 }56 }
57 57
58+func (s *residentChildSession) setTheme(theme tui.Theme) {
59+ if s == nil {
60+ return
61+ }
62+ s.mu.Lock()
63+ defer s.mu.Unlock()
64+ s.view.Theme = theme
65+ s.view.InvalidateRenderCache()
66+}
67+
58 func (s *residentChildSession) LoadRecent(limit int) error {68 func (s *residentChildSession) LoadRecent(limit int) error {
59 if s == nil || s.manager == nil {69 if s == nil || s.manager == nil {
60 return fmt.Errorf("resident child session: unavailable")70 return fmt.Errorf("resident child session: unavailable")
M packages/agent/modes/theme_setting_test.go +23 -18
@@ -6,7 +6,7 @@ import (
66 "github.com/bnema/zut/packages/tui"
77 )
88
9-func TestSettingsDialogOffersInheritedTheme(t *testing.T) {
9+func TestSettingsDialogDoesNotOfferInheritedTheme(t *testing.T) {
1010 interactive := NewInteractive(InteractiveConfig{})
1111 interactive.openSettingsDialog()
1212
@@ -16,37 +16,42 @@ func TestSettingsDialogOffersInheritedTheme(t *testing.T) {
1616 }
1717 for _, option := range item.options {
1818 if option.value == "inherited" {
19- if option.label != "inherited (from terminal)" {
20- t.Fatalf("inherited label = %q, want inherited (from terminal)", option.label)
21- }
22- return
19+ t.Fatal("theme picker still offers inherited")
2320 }
2421 }
25- t.Fatal("theme picker did not include inherited")
22+ return
2623 }
2724 t.Fatal("settings dialog did not include theme picker")
2825 }
2926
30-func TestApplyingInheritedThemeUsesCapturedTerminalProfile(t *testing.T) {
31- detected := tui.Dark
32- detected.Terminal = tui.TerminalProfile{
27+func TestApplyingAutoThemeUsesCapturedTerminalProfile(t *testing.T) {
28+ profile := tui.TerminalProfile{
3329 Foreground: tui.ColorRGB(220, 220, 220),
3430 Background: tui.ColorRGB(10, 10, 10),
3531 HasForeground: true,
3632 HasBackground: true,
37- TrueColor: true,
33+ Depth: tui.ColorDepthTrueColor,
3834 }
39- interactive := NewInteractive(InteractiveConfig{Theme: detected, ThemeName: "auto"})
35+ interactive := NewInteractive(InteractiveConfig{Theme: tui.TerminalTheme(profile), TerminalProfile: profile, ThemeName: "dark"})
4036 interactive.rend = nil
41- interactive.applyThemeNow("inherited")
37+ interactive.applyThemeNow("auto")
4238
43- if !interactive.cfg.Theme.Inherited {
44- t.Fatal("applying inherited theme did not switch the live theme")
39+ if interactive.cfg.Theme.FG != tui.TerminalDefault() {
40+ t.Fatal("applying auto did not switch to terminal defaults")
4541 }
46- if !interactive.cfg.Theme.Terminal.TrueColor {
47- t.Fatal("live inherited color mode = 256, want truecolor")
42+ if interactive.cfg.Theme.Terminal != profile {
43+ t.Fatal("live auto theme lost the captured terminal profile")
4844 }
49- if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground {
50- t.Fatal("live inherited theme lost the captured terminal profile")
45+}
46+
47+func TestEnvironmentForcedThemeDoesNotChangeOnSettingsSelection(t *testing.T) {
48+ interactive := NewInteractive(InteractiveConfig{ThemeForced: true, EffectiveThemeName: "dark", ThemeName: "auto"})
49+ interactive.rend = nil
50+ interactive.applyThemeSetting("light")
51+ if interactive.cfg.ThemeName != "light" {
52+ t.Fatalf("persisted preference = %q, want light", interactive.cfg.ThemeName)
53+ }
54+ if interactive.cfg.EffectiveThemeName != "dark" {
55+ t.Fatalf("effective preference = %q, want dark", interactive.cfg.EffectiveThemeName)
5156 }
5257 }
@@ -6,7 +6,7 @@ import (
6 "github.com/bnema/zut/packages/tui"6 "github.com/bnema/zut/packages/tui"
7 )7 )
8 8
9-func TestSettingsDialogOffersInheritedTheme(t *testing.T) {9+func TestSettingsDialogDoesNotOfferInheritedTheme(t *testing.T) {
10 interactive := NewInteractive(InteractiveConfig{})10 interactive := NewInteractive(InteractiveConfig{})
11 interactive.openSettingsDialog()11 interactive.openSettingsDialog()
12 12
@@ -16,37 +16,42 @@ func TestSettingsDialogOffersInheritedTheme(t *testing.T) {
16 }16 }
17 for _, option := range item.options {17 for _, option := range item.options {
18 if option.value == "inherited" {18 if option.value == "inherited" {
19- if option.label != "inherited (from terminal)" {19+ t.Fatal("theme picker still offers inherited")
20- t.Fatalf("inherited label = %q, want inherited (from terminal)", option.label)
21- }
22- return
23 }20 }
24 }21 }
25- t.Fatal("theme picker did not include inherited")22+ return
26 }23 }
27 t.Fatal("settings dialog did not include theme picker")24 t.Fatal("settings dialog did not include theme picker")
28 }25 }
29 26
30-func TestApplyingInheritedThemeUsesCapturedTerminalProfile(t *testing.T) {27+func TestApplyingAutoThemeUsesCapturedTerminalProfile(t *testing.T) {
31- detected := tui.Dark28+ profile := tui.TerminalProfile{
32- detected.Terminal = tui.TerminalProfile{
33 Foreground: tui.ColorRGB(220, 220, 220),29 Foreground: tui.ColorRGB(220, 220, 220),
34 Background: tui.ColorRGB(10, 10, 10),30 Background: tui.ColorRGB(10, 10, 10),
35 HasForeground: true,31 HasForeground: true,
36 HasBackground: true,32 HasBackground: true,
37- TrueColor: true,33+ Depth: tui.ColorDepthTrueColor,
38 }34 }
39- interactive := NewInteractive(InteractiveConfig{Theme: detected, ThemeName: "auto"})35+ interactive := NewInteractive(InteractiveConfig{Theme: tui.TerminalTheme(profile), TerminalProfile: profile, ThemeName: "dark"})
40 interactive.rend = nil36 interactive.rend = nil
41- interactive.applyThemeNow("inherited")37+ interactive.applyThemeNow("auto")
42 38
43- if !interactive.cfg.Theme.Inherited {39+ if interactive.cfg.Theme.FG != tui.TerminalDefault() {
44- t.Fatal("applying inherited theme did not switch the live theme")40+ t.Fatal("applying auto did not switch to terminal defaults")
45 }41 }
46- if !interactive.cfg.Theme.Terminal.TrueColor {42+ if interactive.cfg.Theme.Terminal != profile {
47- t.Fatal("live inherited color mode = 256, want truecolor")43+ t.Fatal("live auto theme lost the captured terminal profile")
48 }44 }
49- if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground {45+}
50- t.Fatal("live inherited theme lost the captured terminal profile")46+
47+func TestEnvironmentForcedThemeDoesNotChangeOnSettingsSelection(t *testing.T) {
48+ interactive := NewInteractive(InteractiveConfig{ThemeForced: true, EffectiveThemeName: "dark", ThemeName: "auto"})
49+ interactive.rend = nil
50+ interactive.applyThemeSetting("light")
51+ if interactive.cfg.ThemeName != "light" {
52+ t.Fatalf("persisted preference = %q, want light", interactive.cfg.ThemeName)
53+ }
54+ if interactive.cfg.EffectiveThemeName != "dark" {
55+ t.Fatalf("effective preference = %q, want dark", interactive.cfg.EffectiveThemeName)
51 }56 }
52 }57 }
A packages/agent/modes/theme_watch.go +107 -0
@@ -0,0 +1,107 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "time"
8+
9+ "github.com/bnema/zut/packages/tui"
10+)
11+
12+const (
13+ themeWatchInterval = 500 * time.Millisecond
14+ themeMissingGrace = 2 * time.Second
15+)
16+
17+type themeWatchEvent struct {
18+ path string
19+ source *tui.ThemeSource
20+ err error
21+ deleted bool
22+}
23+
24+func (i *Interactive) restartThemeWatch() {
25+ if i.themeWatchCancel != nil {
26+ i.themeWatchCancel()
27+ i.themeWatchCancel = nil
28+ }
29+ if i.runCtx == nil || i.activeThemeSource == nil {
30+ return
31+ }
32+ ctx, cancel := context.WithCancel(i.runCtx)
33+ i.themeWatchCancel = cancel
34+ interval := i.cfg.ThemeWatchInterval
35+ if interval <= 0 {
36+ interval = themeWatchInterval
37+ }
38+ go watchThemeSource(ctx, i.cfg.ZutHome, i.cfg.EffectiveThemeName, i.activeThemeSource, interval, i.themeWatchEvents)
39+}
40+
41+func (i *Interactive) stopThemeWatch() {
42+ if i.themeWatchCancel != nil {
43+ i.themeWatchCancel()
44+ i.themeWatchCancel = nil
45+ }
46+}
47+
48+func watchThemeSource(ctx context.Context, home, preference string, accepted *tui.ThemeSource, interval time.Duration, events chan<- themeWatchEvent) {
49+ if accepted == nil || accepted.Path == "" {
50+ return
51+ }
52+ ticker := time.NewTicker(interval)
53+ defer ticker.Stop()
54+ watchThemeSourceTicks(ctx, home, preference, accepted, ticker.C, events)
55+}
56+
57+// watchThemeSourceTicks is the test seam for polling: tests advance explicit
58+// timestamps rather than synchronizing with sleeps.
59+func watchThemeSourceTicks(ctx context.Context, home, preference string, accepted *tui.ThemeSource, ticks <-chan time.Time, events chan<- themeWatchEvent) {
60+ if accepted == nil || accepted.Path == "" {
61+ return
62+ }
63+ lastDigest := accepted.Digest
64+ var missingSince time.Time
65+ lastError := ""
66+ for {
67+ select {
68+ case <-ctx.Done():
69+ return
70+ case now := <-ticks:
71+ if _, statErr := os.Stat(accepted.Path); errors.Is(statErr, os.ErrNotExist) {
72+ if missingSince.IsZero() {
73+ missingSince = now
74+ }
75+ if now.Sub(missingSince) >= themeMissingGrace {
76+ sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, deleted: true})
77+ return
78+ }
79+ continue
80+ }
81+ // A present-but-invalid revision remains watchable and must restart
82+ // the deletion grace period just like a valid replacement does.
83+ missingSince = time.Time{}
84+ source, err := tui.LoadThemeSource(home, preference)
85+ if err == nil && source != nil {
86+ lastError = ""
87+ if source.Path != accepted.Path || source.Digest == lastDigest {
88+ continue
89+ }
90+ lastDigest = source.Digest
91+ sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, source: source})
92+ continue
93+ }
94+ if err != nil && err.Error() != lastError {
95+ lastError = err.Error()
96+ sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, err: err})
97+ }
98+ }
99+ }
100+}
101+
102+func sendThemeWatchEvent(ctx context.Context, events chan<- themeWatchEvent, event themeWatchEvent) {
103+ select {
104+ case events <- event:
105+ case <-ctx.Done():
106+ }
107+}
@@ -0,0 +1,107 @@
1+package modes
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "time"
8+
9+ "github.com/bnema/zut/packages/tui"
10+)
11+
12+const (
13+ themeWatchInterval = 500 * time.Millisecond
14+ themeMissingGrace = 2 * time.Second
15+)
16+
17+type themeWatchEvent struct {
18+ path string
19+ source *tui.ThemeSource
20+ err error
21+ deleted bool
22+}
23+
24+func (i *Interactive) restartThemeWatch() {
25+ if i.themeWatchCancel != nil {
26+ i.themeWatchCancel()
27+ i.themeWatchCancel = nil
28+ }
29+ if i.runCtx == nil || i.activeThemeSource == nil {
30+ return
31+ }
32+ ctx, cancel := context.WithCancel(i.runCtx)
33+ i.themeWatchCancel = cancel
34+ interval := i.cfg.ThemeWatchInterval
35+ if interval <= 0 {
36+ interval = themeWatchInterval
37+ }
38+ go watchThemeSource(ctx, i.cfg.ZutHome, i.cfg.EffectiveThemeName, i.activeThemeSource, interval, i.themeWatchEvents)
39+}
40+
41+func (i *Interactive) stopThemeWatch() {
42+ if i.themeWatchCancel != nil {
43+ i.themeWatchCancel()
44+ i.themeWatchCancel = nil
45+ }
46+}
47+
48+func watchThemeSource(ctx context.Context, home, preference string, accepted *tui.ThemeSource, interval time.Duration, events chan<- themeWatchEvent) {
49+ if accepted == nil || accepted.Path == "" {
50+ return
51+ }
52+ ticker := time.NewTicker(interval)
53+ defer ticker.Stop()
54+ watchThemeSourceTicks(ctx, home, preference, accepted, ticker.C, events)
55+}
56+
57+// watchThemeSourceTicks is the test seam for polling: tests advance explicit
58+// timestamps rather than synchronizing with sleeps.
59+func watchThemeSourceTicks(ctx context.Context, home, preference string, accepted *tui.ThemeSource, ticks <-chan time.Time, events chan<- themeWatchEvent) {
60+ if accepted == nil || accepted.Path == "" {
61+ return
62+ }
63+ lastDigest := accepted.Digest
64+ var missingSince time.Time
65+ lastError := ""
66+ for {
67+ select {
68+ case <-ctx.Done():
69+ return
70+ case now := <-ticks:
71+ if _, statErr := os.Stat(accepted.Path); errors.Is(statErr, os.ErrNotExist) {
72+ if missingSince.IsZero() {
73+ missingSince = now
74+ }
75+ if now.Sub(missingSince) >= themeMissingGrace {
76+ sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, deleted: true})
77+ return
78+ }
79+ continue
80+ }
81+ // A present-but-invalid revision remains watchable and must restart
82+ // the deletion grace period just like a valid replacement does.
83+ missingSince = time.Time{}
84+ source, err := tui.LoadThemeSource(home, preference)
85+ if err == nil && source != nil {
86+ lastError = ""
87+ if source.Path != accepted.Path || source.Digest == lastDigest {
88+ continue
89+ }
90+ lastDigest = source.Digest
91+ sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, source: source})
92+ continue
93+ }
94+ if err != nil && err.Error() != lastError {
95+ lastError = err.Error()
96+ sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, err: err})
97+ }
98+ }
99+ }
100+}
101+
102+func sendThemeWatchEvent(ctx context.Context, events chan<- themeWatchEvent, event themeWatchEvent) {
103+ select {
104+ case events <- event:
105+ case <-ctx.Done():
106+ }
107+}
A packages/agent/modes/theme_watch_test.go +114 -0
@@ -0,0 +1,114 @@
1+package modes
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/tui"
11+)
12+
13+func writeWatchedTheme(t *testing.T, home, body string) string {
14+ t.Helper()
15+ path := filepath.Join(home, "themes", "live.json")
16+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
17+ t.Fatal(err)
18+ }
19+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
20+ t.Fatal(err)
21+ }
22+ return path
23+}
24+
25+func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) {
26+ home := t.TempDir()
27+ path := writeWatchedTheme(t, home, `{"colors":{"accent":20}}`)
28+ accepted, err := tui.LoadThemeSource(home, "live")
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ ctx, cancel := context.WithCancel(context.Background())
33+ defer cancel()
34+ ticks := make(chan time.Time)
35+ events := make(chan themeWatchEvent, 3)
36+ go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)
37+
38+ if err := os.WriteFile(path, []byte(`{"colors":{"accent":21}}`), 0o644); err != nil {
39+ t.Fatal(err)
40+ }
41+ ticks <- time.Unix(1, 0)
42+ select {
43+ case event := <-events:
44+ if event.source == nil || event.source.File.Colors.Base.Accent == nil || event.source.File.Colors.Base.Accent.Index != 21 {
45+ t.Fatalf("valid revision event = %#v", event)
46+ }
47+ case <-time.After(time.Second):
48+ t.Fatal("watcher did not publish valid revision")
49+ }
50+
51+ if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil {
52+ t.Fatal(err)
53+ }
54+ ticks <- time.Unix(2, 0)
55+ select {
56+ case event := <-events:
57+ if event.err == nil || event.source != nil {
58+ t.Fatalf("invalid revision event = %#v", event)
59+ }
60+ case <-time.After(time.Second):
61+ t.Fatal("watcher did not report invalid revision")
62+ }
63+}
64+
65+func TestWatchThemeSourceTicksRestartsDeletionGraceAfterInvalidReappearance(t *testing.T) {
66+ home := t.TempDir()
67+ path := writeWatchedTheme(t, home, `{}`)
68+ accepted, err := tui.LoadThemeSource(home, "live")
69+ if err != nil {
70+ t.Fatal(err)
71+ }
72+ ctx, cancel := context.WithCancel(context.Background())
73+ defer cancel()
74+ ticks := make(chan time.Time)
75+ events := make(chan themeWatchEvent, 1)
76+ go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)
77+
78+ if err := os.Remove(path); err != nil {
79+ t.Fatal(err)
80+ }
81+ ticks <- time.Unix(1, 0)
82+ if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil {
83+ t.Fatal(err)
84+ }
85+ ticks <- time.Unix(2, 0)
86+ select {
87+ case event := <-events:
88+ if event.err == nil {
89+ t.Fatalf("invalid reappearance event = %#v", event)
90+ }
91+ case <-time.After(time.Second):
92+ t.Fatal("watcher did not report invalid reappearance")
93+ }
94+
95+ if err := os.Remove(path); err != nil {
96+ t.Fatal(err)
97+ }
98+ ticks <- time.Unix(3, 0)
99+ ticks <- time.Unix(4, 0)
100+ select {
101+ case event := <-events:
102+ t.Fatalf("deletion happened before restarted grace period: %#v", event)
103+ default:
104+ }
105+ ticks <- time.Unix(6, 0)
106+ select {
107+ case event := <-events:
108+ if !event.deleted {
109+ t.Fatalf("persistent deletion event = %#v", event)
110+ }
111+ case <-time.After(time.Second):
112+ t.Fatal("watcher did not publish persistent deletion")
113+ }
114+}
@@ -0,0 +1,114 @@
1+package modes
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/tui"
11+)
12+
13+func writeWatchedTheme(t *testing.T, home, body string) string {
14+ t.Helper()
15+ path := filepath.Join(home, "themes", "live.json")
16+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
17+ t.Fatal(err)
18+ }
19+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
20+ t.Fatal(err)
21+ }
22+ return path
23+}
24+
25+func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) {
26+ home := t.TempDir()
27+ path := writeWatchedTheme(t, home, `{"colors":{"accent":20}}`)
28+ accepted, err := tui.LoadThemeSource(home, "live")
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ ctx, cancel := context.WithCancel(context.Background())
33+ defer cancel()
34+ ticks := make(chan time.Time)
35+ events := make(chan themeWatchEvent, 3)
36+ go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)
37+
38+ if err := os.WriteFile(path, []byte(`{"colors":{"accent":21}}`), 0o644); err != nil {
39+ t.Fatal(err)
40+ }
41+ ticks <- time.Unix(1, 0)
42+ select {
43+ case event := <-events:
44+ if event.source == nil || event.source.File.Colors.Base.Accent == nil || event.source.File.Colors.Base.Accent.Index != 21 {
45+ t.Fatalf("valid revision event = %#v", event)
46+ }
47+ case <-time.After(time.Second):
48+ t.Fatal("watcher did not publish valid revision")
49+ }
50+
51+ if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil {
52+ t.Fatal(err)
53+ }
54+ ticks <- time.Unix(2, 0)
55+ select {
56+ case event := <-events:
57+ if event.err == nil || event.source != nil {
58+ t.Fatalf("invalid revision event = %#v", event)
59+ }
60+ case <-time.After(time.Second):
61+ t.Fatal("watcher did not report invalid revision")
62+ }
63+}
64+
65+func TestWatchThemeSourceTicksRestartsDeletionGraceAfterInvalidReappearance(t *testing.T) {
66+ home := t.TempDir()
67+ path := writeWatchedTheme(t, home, `{}`)
68+ accepted, err := tui.LoadThemeSource(home, "live")
69+ if err != nil {
70+ t.Fatal(err)
71+ }
72+ ctx, cancel := context.WithCancel(context.Background())
73+ defer cancel()
74+ ticks := make(chan time.Time)
75+ events := make(chan themeWatchEvent, 1)
76+ go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)
77+
78+ if err := os.Remove(path); err != nil {
79+ t.Fatal(err)
80+ }
81+ ticks <- time.Unix(1, 0)
82+ if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil {
83+ t.Fatal(err)
84+ }
85+ ticks <- time.Unix(2, 0)
86+ select {
87+ case event := <-events:
88+ if event.err == nil {
89+ t.Fatalf("invalid reappearance event = %#v", event)
90+ }
91+ case <-time.After(time.Second):
92+ t.Fatal("watcher did not report invalid reappearance")
93+ }
94+
95+ if err := os.Remove(path); err != nil {
96+ t.Fatal(err)
97+ }
98+ ticks <- time.Unix(3, 0)
99+ ticks <- time.Unix(4, 0)
100+ select {
101+ case event := <-events:
102+ t.Fatalf("deletion happened before restarted grace period: %#v", event)
103+ default:
104+ }
105+ ticks <- time.Unix(6, 0)
106+ select {
107+ case event := <-events:
108+ if !event.deleted {
109+ t.Fatalf("persistent deletion event = %#v", event)
110+ }
111+ case <-time.After(time.Second):
112+ t.Fatal("watcher did not publish persistent deletion")
113+ }
114+}
M packages/agent/settings_store.go +1 -1
@@ -231,7 +231,7 @@ func (configSettingsStore) SetTheme(name string) error {
231231 if err != nil {
232232 return err
233233 }
234- if name == "auto" {
234+ if name == "auto" || name == "inherited" {
235235 name = ""
236236 }
237237 cfg.Theme = name
@@ -231,7 +231,7 @@ func (configSettingsStore) SetTheme(name string) error {
231 if err != nil {231 if err != nil {
232 return err232 return err
233 }233 }
234- if name == "auto" {234+ if name == "auto" || name == "inherited" {
235 name = ""235 name = ""
236 }236 }
237 cfg.Theme = name237 cfg.Theme = name
M packages/agent/settings_store_test.go +3 -3
@@ -202,7 +202,7 @@ func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) {
202202 }
203203 }
204204
205-func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) {
205+func TestConfigSettingsStoreResetsRemovedInheritedThemeToAuto(t *testing.T) {
206206 t.Setenv("ZUT_HOME", t.TempDir())
207207 if err := SaveConfig(Config{Theme: "dark"}); err != nil {
208208 t.Fatal(err)
@@ -215,8 +215,8 @@ func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) {
215215 if err != nil {
216216 t.Fatal(err)
217217 }
218- if cfg.Theme != "inherited" {
219- t.Fatalf("theme = %q, want inherited", cfg.Theme)
218+ if cfg.Theme != "" {
219+ t.Fatalf("theme = %q, want auto", cfg.Theme)
220220 }
221221 }
222222
@@ -202,7 +202,7 @@ func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) {
202 }202 }
203 }203 }
204 204
205-func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) {205+func TestConfigSettingsStoreResetsRemovedInheritedThemeToAuto(t *testing.T) {
206 t.Setenv("ZUT_HOME", t.TempDir())206 t.Setenv("ZUT_HOME", t.TempDir())
207 if err := SaveConfig(Config{Theme: "dark"}); err != nil {207 if err := SaveConfig(Config{Theme: "dark"}); err != nil {
208 t.Fatal(err)208 t.Fatal(err)
@@ -215,8 +215,8 @@ func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) {
215 if err != nil {215 if err != nil {
216 t.Fatal(err)216 t.Fatal(err)
217 }217 }
218- if cfg.Theme != "inherited" {218+ if cfg.Theme != "" {
219- t.Fatalf("theme = %q, want inherited", cfg.Theme)219+ t.Fatalf("theme = %q, want auto", cfg.Theme)
220 }220 }
221 }221 }
222 222
M packages/agent/skills/builtin/write-zut-themes/SKILL.md +31 -284
@@ -5,32 +5,24 @@ description: Help the user create, install, or package zut themes, including the
55
66 # Writing zut themes
77
8-Use this skill when the user asks for help creating, editing,
9-installing, debugging, or packaging a zut color theme. Read this skill
10-before generating a theme file or advising on theme extension layout.
8+Use this skill when creating, editing, installing, debugging, or packaging a
9+zut theme. Read [docs/themes.md](../../../../../docs/themes.md) before
10+recommending a file shape.
1111
12-## What a zut theme is
12+## Theme model
1313
14-A zut theme is a JSON file that overrides any subset of zut's built-in
15-light/dark theme values. Theme files are intentionally permissive:
16-nothing is required. A file may contain only colors, only spinner
17-settings, only syntax colors, or only metadata. Missing values always
18-inherit from the built-in detected light/dark default.
14+`auto` is terminal-owned: it follows terminal defaults, ANSI palette, color
15+depth, and live appearance changes. A JSON theme overlays that adaptive base,
16+so missing fields remain terminal-owned. `dark` and `light` are fixed explicit
17+choices. There is no `inherited` selection.
1918
20-The `/settings` picker also provides an **inherited (from terminal)**
21-built-in theme. It uses the terminal's reported colors and emits truecolor
22-when advertised, with a best-effort xterm-256 fallback otherwise. JSON themes
23-use the same first-class terminal-color model: every semantic role accepts
24-xterm-256, ANSI, or RGB values, with RGB quantized when truecolor is
25-unavailable.
26-
27-User themes are discovered from:
19+User themes live at:
2820
2921 ```text
3022 $ZUT_HOME/themes/*.json
3123 ```
3224
33-Extension themes are discovered in-place from loaded extension dirs:
25+Extension-owned files are loaded in place:
3426
3527 ```text
3628 $ZUT_HOME/extensions/<extension>/theme.json
@@ -39,36 +31,19 @@ $ZUT_HOME/extensions/<extension>/themes/theme.json
3931 <project>/.zut/extensions/<extension>/themes/theme.json
4032 ```
4133
42-zut does **not** copy extension themes into `$ZUT_HOME/themes`; extension
43-owned themes stay in the extension directory. The settings picker stores
44-an absolute path for extension-owned themes and loads that file directly.
34+An active custom file reloads during interactive sessions. Always produce
35+complete valid JSON: zut keeps the prior valid file after a temporary invalid
36+save, and resets to auto only after persistent deletion.
4537
4638 ## Minimal examples
4739
48-### Empty / metadata-only theme
49-
50-Valid; inherits all colors/spinner/syntax from zut defaults.
51-
52-```json
53-{
54- "name": "my-theme",
55- "description": "Metadata only; all visuals inherit zut defaults."
56-}
57-```
58-
59-### Change one color for both light and dark
60-
6140 ```json
6241 {
6342 "name": "pink-accent",
64- "colors": {
65- "accent": 204
66- }
43+ "colors": { "accent": 204 }
6744 }
6845 ```
6946
70-### Change one color per mode
71-
7247 ```json
7348 {
7449 "name": "split-accent",
@@ -79,132 +54,17 @@ Valid; inherits all colors/spinner/syntax from zut defaults.
7954 }
8055 ```
8156
82-### Spinner-only theme
83-
84-Top-level spinner overrides are valid and apply to both modes.
85-
86-```json
87-{
88- "name": "custom-spinner",
89- "description": "Only changes the busy spinner.",
90- "spinner_frames": ["◢", "◣", "◤", "◥"],
91- "spinner_interval_ms": 120
92-}
93-```
94-
95-### Dark-only theme also works in light terminals
96-
97-If `colors.light` is missing, zut applies `colors.dark` overrides on
98-top of the built-in light default when running in light mode. The
99-inverse is also true: if dark is missing but light exists, light
100-settings are used on dark defaults.
101-
102-```json
103-{
104- "name": "custom-spinner",
105- "description": "An alternative busy-spinner animation for zut.",
106- "colors": {
107- "dark": {
108- "spinner_frames": ["◢", "◣", "◤", "◥"],
109- "spinner_interval_ms": 120
110- }
111- }
112-}
113-```
114-
115-## Full theme shape
57+Top-level and `colors` values apply to both branches. Missing light or dark
58+branches use the available branch over the adaptive base.
11659
117-All fields below are optional.
60+## Valid values
11861
119-```json
120-{
121- "name": "my-theme",
122- "description": "Shown in /settings → color theme.",
123- "color_descriptions": {
124- "accent": "Optional documentation for humans. zut ignores this object."
125- },
126- "colors": {
127- "dark": {
128- "fg": 253,
129- "muted": 244,
130- "accent": 111,
131- "user": 180,
132- "user_bubble_bg": "#42454b",
133- "user_bubble_fg": 248,
134- "assistant": 117,
135- "tool": 114,
136- "tool_out": 245,
137- "error": 203,
138- "warning": 214,
139- "spinner": 183,
140- "selection_bg": 24,
141- "selection_fg": 231,
142- "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
143- "spinner_interval_ms": 80,
144- "syntax_base_style": "monokai",
145- "syntax": {
146- "keyword": "#81a1c1 bold",
147- "keyword_constant": "#81a1c1",
148- "keyword_declaration": "#81a1c1",
149- "keyword_namespace": "#81a1c1",
150- "keyword_reserved": "#81a1c1 bold",
151- "keyword_type": "#88c0d0",
152- "name_builtin": "#88c0d0",
153- "name_function": "#8fbcbb",
154- "name_class": "#a3be8c bold",
155- "name_decorator": "#b48ead",
156- "literal_string": "#a3be8c",
157- "literal_string_escape": "#bf616a",
158- "literal_number": "#d08770",
159- "comment": "#616e88 italic",
160- "comment_preproc": "#b48ead",
161- "operator": "#eceff4",
162- "punctuation": "#d8dee9",
163- "text": "#e5e9f0"
164- }
165- },
166- "light": {
167- "fg": 236,
168- "muted": 244,
169- "accent": 33
170- }
171- }
172-}
173-```
62+All fields are optional. Semantic roles include `fg`, `muted`, `accent`,
63+`background`, `user`, `user_bubble_bg`, `user_bubble_fg`, `assistant`, `tool`,
64+`tool_out`, `error`, `warning`, `spinner`, `thinking_max`, `selection_bg`, and
65+`selection_fg`.
17466
175-You may also put overrides directly at the top level, or directly
176-under `colors`, when they should apply to both modes:
177-
178-```json
179-{
180- "name": "tiny",
181- "accent": 204,
182- "colors": {
183- "spinner_frames": ["◐", "◓", "◑", "◒"]
184- }
185-}
186-```
187-
188-## Color fields
189-
190-- `fg` — default foreground text.
191-- `muted` — secondary text, dividers, gutters, inactive hints.
192-- `accent` — prompt bar, bullets, links, headings, active markers.
193-- `background` — optional full-row TUI background; missing values leave the
194- terminal background unchanged.
195-- `user` — user role label color; mostly compatibility.
196-- `user_bubble_bg` — background behind user message rows.
197-- `user_bubble_fg` — foreground inside user message rows.
198-- `assistant` — assistant/zut accent and spinner text.
199-- `tool` — tool names, success marks, diff additions.
200-- `tool_out` — plain tool-output text.
201-- `error` — errors, refused calls, diff deletions.
202-- `warning` — warnings and high context-usage state.
203-- `spinner` — reserved spinner color slot.
204-- `selection_bg` — highlighted row background.
205-- `selection_fg` — highlighted row foreground.
206-
207-Every color field accepts the following JSON forms:
67+Use explicit color values only when an override is intended:
20868
20969 ```json
21070 254
@@ -214,115 +74,19 @@ Every color field accepts the following JSON forms:
21474 { "mode": "rgb", "r": 66, "g": 69, "b": 75 }
21575 ```
21676
217-Numeric values are xterm-256 indexes. ANSI values use SGR codes, with
218-standard foreground/background ranges normalized to the role where necessary.
219-RGB values are emitted directly when truecolor is advertised and quantized to
220-the nearest xterm-256 color otherwise. The forms apply to `background`,
221-`user_bubble_bg`, and all other semantic theme colors.
222-
223-## Spinner fields
224-
225-Spinner settings can appear at top level, under `colors`, or under
226-`colors.dark` / `colors.light`.
227-
228-- `spinner_frames` — list of frame strings. Use single-cell glyphs
229- when possible so status-bar alignment stays clean.
230-- `spinner_interval_ms` — frame interval in milliseconds; must be
231- positive. Missing/invalid falls back to 80ms.
232-
233-The working-status label belongs to zut and always describes the current
234-operation. The legacy `spinner_messages` field is ignored.
77+Numeric values are literal xterm-256 indexes, not terminal palette slots. RGB
78+uses truecolor when available and falls back to terminal capability otherwise.
79+Omit `background` for terminal background ownership; setting it intentionally
80+paints full zut rows.
23581
236-Example:
237-
238-```json
239-{
240- "spinner_frames": ["◐", "◓", "◑", "◒"],
241- "spinner_interval_ms": 120
242-}
243-```
244-
245-## Syntax fields
246-
247-Syntax highlighting uses Chroma style entries. Values may include
248-attributes after the color, such as `bold`, `italic`, or `underline`.
249-
250-Common fields:
251-
252-- `keyword`
253-- `keyword_constant`
254-- `keyword_declaration`
255-- `keyword_namespace`
256-- `keyword_reserved`
257-- `keyword_type`
258-- `name_builtin`
259-- `name_function`
260-- `name_class`
261-- `name_decorator`
262-- `literal_string`
263-- `literal_string_escape`
264-- `literal_number`
265-- `comment`
266-- `comment_preproc`
267-- `operator`
268-- `punctuation`
269-- `text`
270-
271-Example:
272-
273-```json
274-{
275- "colors": {
276- "dark": {
277- "syntax_base_style": "monokai",
278- "syntax": {
279- "keyword": "#f05b8d",
280- "name_function": "#b675f1",
281- "literal_string": "#58c760",
282- "comment": "#a1a1a1 italic"
283- }
284- }
285- }
286-}
287-```
288-
289-## Installing a user theme
290-
291-```bash
292-mkdir -p "$ZUT_HOME/themes"
293-cp my-theme.json "$ZUT_HOME/themes/my-theme.json"
294-```
295-
296-Then open `/settings` and choose **color theme**. zut switches theme
297-immediately and persists the selection in `$ZUT_HOME/config.json`.
298-
299-If a selected theme file is deleted, zut resets the setting to the
300-built-in auto/default theme.
82+`spinner_frames` accepts 1–64 non-empty entries and `spinner_interval_ms` must
83+be 10–10000. Files must not exceed 1 MiB. Custom `syntax` entries use Chroma
84+style entries, such as `"#f05b8d bold"`.
30185
30286 ## Theme-only extensions
30387
304-A zut extension can exist only to ship a theme. No slash command,
305-subprocess, or executable is required when the extension contains a
306-valid theme file.
307-
308-Layout:
309-
310-```text
311-$ZUT_HOME/extensions/my-theme-extension/
312-├── extension.json
313-└── theme.json
314-```
315-
316-or:
317-
318-```text
319-$ZUT_HOME/extensions/my-theme-extension/
320-├── extension.json
321-└── themes/
322- └── theme.json
323-```
324-
325-`extension.json` for a theme-only extension:
88+A theme-only extension needs an `extension.json` and `theme.json`; no command
89+or executable is required.
32690
32791 ```json
32892 {
@@ -332,20 +96,3 @@ $ZUT_HOME/extensions/my-theme-extension/
33296 "enabled": true
33397 }
33498 ```
335-
336-No `exec` is needed when `theme.json` or `themes/theme.json` exists.
337-If `exec` is present, zut treats it as a normal extension too.
338-
339-In `/settings → color theme`, extension themes show source info in
340-the description, e.g. `from extension my-theme-extension — ...`.
341-
342-## Recommendations
343-
344-- Prefer short descriptions; settings wraps them, but concise is
345- easier to scan.
346-- Keep spinner frames single-width when possible.
347-- For theme packages, use a unique extension name so the source label
348- is clear.
349-- For user-editable themes, keep comments out of JSON; JSON comments
350- are not supported.
351-- Validate with `python3 -m json.tool theme.json` before installing.
@@ -5,32 +5,24 @@ description: Help the user create, install, or package zut themes, including the
5 5
6 # Writing zut themes6 # Writing zut themes
7 7
8-Use this skill when the user asks for help creating, editing,8+Use this skill when creating, editing, installing, debugging, or packaging a
9-installing, debugging, or packaging a zut color theme. Read this skill9+zut theme. Read [docs/themes.md](../../../../../docs/themes.md) before
10-before generating a theme file or advising on theme extension layout.10+recommending a file shape.
11 11
12-## What a zut theme is12+## Theme model
13 13
14-A zut theme is a JSON file that overrides any subset of zut's built-in14+`auto` is terminal-owned: it follows terminal defaults, ANSI palette, color
15-light/dark theme values. Theme files are intentionally permissive:15+depth, and live appearance changes. A JSON theme overlays that adaptive base,
16-nothing is required. A file may contain only colors, only spinner16+so missing fields remain terminal-owned. `dark` and `light` are fixed explicit
17-settings, only syntax colors, or only metadata. Missing values always17+choices. There is no `inherited` selection.
18-inherit from the built-in detected light/dark default.
19 18
20-The `/settings` picker also provides an **inherited (from terminal)**19+User themes live at:
21-built-in theme. It uses the terminal's reported colors and emits truecolor
22-when advertised, with a best-effort xterm-256 fallback otherwise. JSON themes
23-use the same first-class terminal-color model: every semantic role accepts
24-xterm-256, ANSI, or RGB values, with RGB quantized when truecolor is
25-unavailable.
26-
27-User themes are discovered from:
28 20
29 ```text21 ```text
30 $ZUT_HOME/themes/*.json22 $ZUT_HOME/themes/*.json
31 ```23 ```
32 24
33-Extension themes are discovered in-place from loaded extension dirs:25+Extension-owned files are loaded in place:
34 26
35 ```text27 ```text
36 $ZUT_HOME/extensions/<extension>/theme.json28 $ZUT_HOME/extensions/<extension>/theme.json
@@ -39,36 +31,19 @@ $ZUT_HOME/extensions/<extension>/themes/theme.json
39 <project>/.zut/extensions/<extension>/themes/theme.json31 <project>/.zut/extensions/<extension>/themes/theme.json
40 ```32 ```
41 33
42-zut does **not** copy extension themes into `$ZUT_HOME/themes`; extension34+An active custom file reloads during interactive sessions. Always produce
43-owned themes stay in the extension directory. The settings picker stores35+complete valid JSON: zut keeps the prior valid file after a temporary invalid
44-an absolute path for extension-owned themes and loads that file directly.36+save, and resets to auto only after persistent deletion.
45 37
46 ## Minimal examples38 ## Minimal examples
47 39
48-### Empty / metadata-only theme
49-
50-Valid; inherits all colors/spinner/syntax from zut defaults.
51-
52-```json
53-{
54- "name": "my-theme",
55- "description": "Metadata only; all visuals inherit zut defaults."
56-}
57-```
58-
59-### Change one color for both light and dark
60-
61 ```json40 ```json
62 {41 {
63 "name": "pink-accent",42 "name": "pink-accent",
64- "colors": {43+ "colors": { "accent": 204 }
65- "accent": 204
66- }
67 }44 }
68 ```45 ```
69 46
70-### Change one color per mode
71-
72 ```json47 ```json
73 {48 {
74 "name": "split-accent",49 "name": "split-accent",
@@ -79,132 +54,17 @@ Valid; inherits all colors/spinner/syntax from zut defaults.
79 }54 }
80 ```55 ```
81 56
82-### Spinner-only theme57+Top-level and `colors` values apply to both branches. Missing light or dark
83-58+branches use the available branch over the adaptive base.
84-Top-level spinner overrides are valid and apply to both modes.
85-
86-```json
87-{
88- "name": "custom-spinner",
89- "description": "Only changes the busy spinner.",
90- "spinner_frames": ["◢", "◣", "◤", "◥"],
91- "spinner_interval_ms": 120
92-}
93-```
94-
95-### Dark-only theme also works in light terminals
96-
97-If `colors.light` is missing, zut applies `colors.dark` overrides on
98-top of the built-in light default when running in light mode. The
99-inverse is also true: if dark is missing but light exists, light
100-settings are used on dark defaults.
101-
102-```json
103-{
104- "name": "custom-spinner",
105- "description": "An alternative busy-spinner animation for zut.",
106- "colors": {
107- "dark": {
108- "spinner_frames": ["◢", "◣", "◤", "◥"],
109- "spinner_interval_ms": 120
110- }
111- }
112-}
113-```
114-
115-## Full theme shape
116 59
117-All fields below are optional.60+## Valid values
118 61
119-```json62+All fields are optional. Semantic roles include `fg`, `muted`, `accent`,
120-{63+`background`, `user`, `user_bubble_bg`, `user_bubble_fg`, `assistant`, `tool`,
121- "name": "my-theme",64+`tool_out`, `error`, `warning`, `spinner`, `thinking_max`, `selection_bg`, and
122- "description": "Shown in /settings → color theme.",65+`selection_fg`.
123- "color_descriptions": {
124- "accent": "Optional documentation for humans. zut ignores this object."
125- },
126- "colors": {
127- "dark": {
128- "fg": 253,
129- "muted": 244,
130- "accent": 111,
131- "user": 180,
132- "user_bubble_bg": "#42454b",
133- "user_bubble_fg": 248,
134- "assistant": 117,
135- "tool": 114,
136- "tool_out": 245,
137- "error": 203,
138- "warning": 214,
139- "spinner": 183,
140- "selection_bg": 24,
141- "selection_fg": 231,
142- "spinner_frames": ["⠋", "⠙", "⠚", "⠞", "⠖", "⠦", "⠴", "⠲", "⠳", "⠓"],
143- "spinner_interval_ms": 80,
144- "syntax_base_style": "monokai",
145- "syntax": {
146- "keyword": "#81a1c1 bold",
147- "keyword_constant": "#81a1c1",
148- "keyword_declaration": "#81a1c1",
149- "keyword_namespace": "#81a1c1",
150- "keyword_reserved": "#81a1c1 bold",
151- "keyword_type": "#88c0d0",
152- "name_builtin": "#88c0d0",
153- "name_function": "#8fbcbb",
154- "name_class": "#a3be8c bold",
155- "name_decorator": "#b48ead",
156- "literal_string": "#a3be8c",
157- "literal_string_escape": "#bf616a",
158- "literal_number": "#d08770",
159- "comment": "#616e88 italic",
160- "comment_preproc": "#b48ead",
161- "operator": "#eceff4",
162- "punctuation": "#d8dee9",
163- "text": "#e5e9f0"
164- }
165- },
166- "light": {
167- "fg": 236,
168- "muted": 244,
169- "accent": 33
170- }
171- }
172-}
173-```
174 66
175-You may also put overrides directly at the top level, or directly67+Use explicit color values only when an override is intended:
176-under `colors`, when they should apply to both modes:
177-
178-```json
179-{
180- "name": "tiny",
181- "accent": 204,
182- "colors": {
183- "spinner_frames": ["◐", "◓", "◑", "◒"]
184- }
185-}
186-```
187-
188-## Color fields
189-
190-- `fg` — default foreground text.
191-- `muted` — secondary text, dividers, gutters, inactive hints.
192-- `accent` — prompt bar, bullets, links, headings, active markers.
193-- `background` — optional full-row TUI background; missing values leave the
194- terminal background unchanged.
195-- `user` — user role label color; mostly compatibility.
196-- `user_bubble_bg` — background behind user message rows.
197-- `user_bubble_fg` — foreground inside user message rows.
198-- `assistant` — assistant/zut accent and spinner text.
199-- `tool` — tool names, success marks, diff additions.
200-- `tool_out` — plain tool-output text.
201-- `error` — errors, refused calls, diff deletions.
202-- `warning` — warnings and high context-usage state.
203-- `spinner` — reserved spinner color slot.
204-- `selection_bg` — highlighted row background.
205-- `selection_fg` — highlighted row foreground.
206-
207-Every color field accepts the following JSON forms:
208 68
209 ```json69 ```json
210 25470 254
@@ -214,115 +74,19 @@ Every color field accepts the following JSON forms:
214 { "mode": "rgb", "r": 66, "g": 69, "b": 75 }74 { "mode": "rgb", "r": 66, "g": 69, "b": 75 }
215 ```75 ```
216 76
217-Numeric values are xterm-256 indexes. ANSI values use SGR codes, with77+Numeric values are literal xterm-256 indexes, not terminal palette slots. RGB
218-standard foreground/background ranges normalized to the role where necessary.78+uses truecolor when available and falls back to terminal capability otherwise.
219-RGB values are emitted directly when truecolor is advertised and quantized to79+Omit `background` for terminal background ownership; setting it intentionally
220-the nearest xterm-256 color otherwise. The forms apply to `background`,80+paints full zut rows.
221-`user_bubble_bg`, and all other semantic theme colors.
222-
223-## Spinner fields
224-
225-Spinner settings can appear at top level, under `colors`, or under
226-`colors.dark` / `colors.light`.
227-
228-- `spinner_frames` — list of frame strings. Use single-cell glyphs
229- when possible so status-bar alignment stays clean.
230-- `spinner_interval_ms` — frame interval in milliseconds; must be
231- positive. Missing/invalid falls back to 80ms.
232-
233-The working-status label belongs to zut and always describes the current
234-operation. The legacy `spinner_messages` field is ignored.
235 81
236-Example:82+`spinner_frames` accepts 1–64 non-empty entries and `spinner_interval_ms` must
237-83+be 10–10000. Files must not exceed 1 MiB. Custom `syntax` entries use Chroma
238-```json84+style entries, such as `"#f05b8d bold"`.
239-{
240- "spinner_frames": ["◐", "◓", "◑", "◒"],
241- "spinner_interval_ms": 120
242-}
243-```
244-
245-## Syntax fields
246-
247-Syntax highlighting uses Chroma style entries. Values may include
248-attributes after the color, such as `bold`, `italic`, or `underline`.
249-
250-Common fields:
251-
252-- `keyword`
253-- `keyword_constant`
254-- `keyword_declaration`
255-- `keyword_namespace`
256-- `keyword_reserved`
257-- `keyword_type`
258-- `name_builtin`
259-- `name_function`
260-- `name_class`
261-- `name_decorator`
262-- `literal_string`
263-- `literal_string_escape`
264-- `literal_number`
265-- `comment`
266-- `comment_preproc`
267-- `operator`
268-- `punctuation`
269-- `text`
270-
271-Example:
272-
273-```json
274-{
275- "colors": {
276- "dark": {
277- "syntax_base_style": "monokai",
278- "syntax": {
279- "keyword": "#f05b8d",
280- "name_function": "#b675f1",
281- "literal_string": "#58c760",
282- "comment": "#a1a1a1 italic"
283- }
284- }
285- }
286-}
287-```
288-
289-## Installing a user theme
290-
291-```bash
292-mkdir -p "$ZUT_HOME/themes"
293-cp my-theme.json "$ZUT_HOME/themes/my-theme.json"
294-```
295-
296-Then open `/settings` and choose **color theme**. zut switches theme
297-immediately and persists the selection in `$ZUT_HOME/config.json`.
298-
299-If a selected theme file is deleted, zut resets the setting to the
300-built-in auto/default theme.
301 85
302 ## Theme-only extensions86 ## Theme-only extensions
303 87
304-A zut extension can exist only to ship a theme. No slash command,88+A theme-only extension needs an `extension.json` and `theme.json`; no command
305-subprocess, or executable is required when the extension contains a89+or executable is required.
306-valid theme file.
307-
308-Layout:
309-
310-```text
311-$ZUT_HOME/extensions/my-theme-extension/
312-├── extension.json
313-└── theme.json
314-```
315-
316-or:
317-
318-```text
319-$ZUT_HOME/extensions/my-theme-extension/
320-├── extension.json
321-└── themes/
322- └── theme.json
323-```
324-
325-`extension.json` for a theme-only extension:
326 90
327 ```json91 ```json
328 {92 {
@@ -332,20 +96,3 @@ $ZUT_HOME/extensions/my-theme-extension/
332 "enabled": true96 "enabled": true
333 }97 }
334 ```98 ```
335-
336-No `exec` is needed when `theme.json` or `themes/theme.json` exists.
337-If `exec` is present, zut treats it as a normal extension too.
338-
339-In `/settings → color theme`, extension themes show source info in
340-the description, e.g. `from extension my-theme-extension — ...`.
341-
342-## Recommendations
343-
344-- Prefer short descriptions; settings wraps them, but concise is
345- easier to scan.
346-- Keep spinner frames single-width when possible.
347-- For theme packages, use a unique extension name so the source label
348- is clear.
349-- For user-editable themes, keep comments out of JSON; JSON comments
350- are not supported.
351-- Validate with `python3 -m json.tool theme.json` before installing.
A packages/tui/appearance_protocol.go +307 -0
@@ -0,0 +1,307 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "strconv"
6+ "strings"
7+ "sync"
8+ "time"
9+)
10+
11+// Appearance query and DEC mode 2031 control sequences.
12+const (
13+ SeqQueryColorScheme = "\x1b[?996n"
14+ SeqQueryMode2031 = "\x1b[?2031$p"
15+ SeqEnableMode2031 = "\x1b[?2031h"
16+ SeqDisableMode2031 = "\x1b[?2031l"
17+)
18+
19+// AppearanceQuery returns the complete profile query used at startup and on a
20+// replacement generation. The ANSI palette is requested in one OSC 4 batch.
21+func AppearanceQuery() string {
22+ var b strings.Builder
23+ b.WriteString(SeqQueryColorScheme)
24+ b.WriteString("\x1b]10;?\a\x1b]11;?\a\x1b]4;")
25+ for slot := 0; slot < 16; slot++ {
26+ if slot > 0 {
27+ b.WriteByte(';')
28+ }
29+ b.WriteString(strconv.Itoa(slot))
30+ b.WriteString(";?")
31+ }
32+ b.WriteByte('\a')
33+ return b.String()
34+}
35+
36+// SchemeEvent is a reported terminal color-scheme preference. Solicited is
37+// true only for the reply to a query initiated by zut.
38+type SchemeEvent struct {
39+ Light bool
40+ Solicited bool
41+}
42+
43+// ColorEvent is one default foreground/background or ANSI-palette reply.
44+type ColorEvent struct {
45+ Kind int // 10 foreground, 11 background, 4 palette
46+ Slot int
47+ Color TerminalColor
48+}
49+
50+// ModeReportEvent is a DECRQM response. Status follows the terminal protocol:
51+// 1/3 set, 2 reset, 0/4 unsupported or permanently reset.
52+type ModeReportEvent struct {
53+ Mode int
54+ Status int
55+}
56+
57+// InputEvent is emitted by the appearance-aware input path. Exactly one field
58+// is set. Key bytes that do not form an accepted protocol reply remain keys.
59+type InputEvent struct {
60+ Key *Key
61+ Scheme *SchemeEvent
62+ Color *ColorEvent
63+ Mode *ModeReportEvent
64+}
65+
66+// AppearanceParser controls which terminal replies may be consumed. Keeping
67+// the gate here prevents OSC-looking editor input from disappearing unless zut
68+// actively issued a matching query, while 997 notifications are accepted only
69+// after notification support is known.
70+type AppearanceParser struct {
71+ mu sync.RWMutex
72+ pendingColors bool
73+ pendingScheme bool
74+ notifications bool
75+}
76+
77+func (p *AppearanceParser) SetPendingColors(pending bool) {
78+ p.mu.Lock()
79+ p.pendingColors = pending
80+ p.mu.Unlock()
81+}
82+
83+func (p *AppearanceParser) SetNotifications(enabled bool) {
84+ p.mu.Lock()
85+ p.notifications = enabled
86+ p.mu.Unlock()
87+}
88+
89+// SetPendingScheme permits one solicited 997 response and marks it so the
90+// event loop does not mistake it for a new terminal notification.
91+func (p *AppearanceParser) SetPendingScheme(pending bool) {
92+ p.mu.Lock()
93+ p.pendingScheme = pending
94+ p.mu.Unlock()
95+}
96+
97+func (p *AppearanceParser) acceptsColors() bool {
98+ p.mu.RLock()
99+ defer p.mu.RUnlock()
100+ return p.pendingColors
101+}
102+
103+func (p *AppearanceParser) acceptsNotifications() bool {
104+ p.mu.RLock()
105+ defer p.mu.RUnlock()
106+ return p.notifications
107+}
108+
109+func (p *AppearanceParser) consumeScheme() (solicited bool, accepted bool) {
110+ p.mu.Lock()
111+ defer p.mu.Unlock()
112+ if p.pendingScheme {
113+ p.pendingScheme = false
114+ return true, true
115+ }
116+ return false, p.notifications
117+}
118+
119+// ParseAppearanceOSC parses one complete BEL/ST-terminated OSC sequence.
120+// Combined OSC 4 responses produce one ColorEvent for every valid pair.
121+func ParseAppearanceOSC(raw []byte) []ColorEvent {
122+ s := strings.TrimSuffix(string(raw), "\a")
123+ s = strings.TrimSuffix(s, "\x1b\\")
124+ if !strings.HasPrefix(s, "\x1b]") {
125+ return nil
126+ }
127+ body := strings.TrimPrefix(s, "\x1b]")
128+ parts := strings.Split(body, ";")
129+ if len(parts) < 2 {
130+ return nil
131+ }
132+ kind, err := strconv.Atoi(parts[0])
133+ if err != nil {
134+ return nil
135+ }
136+ switch kind {
137+ case 10, 11:
138+ color, ok := parseTerminalRGB(strings.Join(parts[1:], ";"))
139+ if !ok {
140+ return nil
141+ }
142+ return []ColorEvent{{Kind: kind, Slot: -1, Color: color}}
143+ case 4:
144+ var events []ColorEvent
145+ for idx := 1; idx+1 < len(parts); idx += 2 {
146+ slot, err := strconv.Atoi(parts[idx])
147+ if err != nil || slot < 0 || slot >= 16 {
148+ continue
149+ }
150+ color, ok := parseTerminalRGB(parts[idx+1])
151+ if ok {
152+ events = append(events, ColorEvent{Kind: 4, Slot: slot, Color: color})
153+ }
154+ }
155+ return events
156+ default:
157+ return nil
158+ }
159+}
160+
161+// ParseAppearanceCSI parses the two runtime CSI response families used by
162+// terminal-owned themes: current scheme (997) and mode ownership (DECRQM).
163+func ParseAppearanceCSI(raw []byte) (scheme *SchemeEvent, mode *ModeReportEvent) {
164+ s := string(raw)
165+ if !strings.HasPrefix(s, "\x1b[") {
166+ return nil, nil
167+ }
168+ body := strings.TrimPrefix(s, "\x1b[")
169+ if strings.HasPrefix(body, "?997;") && strings.HasSuffix(body, "n") {
170+ value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?997;"), "n"))
171+ if err == nil && (value == 1 || value == 2) {
172+ return &SchemeEvent{Light: value == 2}, nil
173+ }
174+ }
175+ if strings.HasPrefix(body, "?2031;") && strings.HasSuffix(body, "$y") {
176+ value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?2031;"), "$y"))
177+ if err == nil {
178+ return nil, &ModeReportEvent{Mode: 2031, Status: value}
179+ }
180+ }
181+ return nil, nil
182+}
183+
184+// AppearanceSource filters accepted terminal appearance replies before they
185+// reach Reader. It deliberately leaves bracketed-paste payload opaque.
186+type AppearanceSource struct {
187+ read func() (byte, error)
188+ peek func(time.Duration) (byte, bool, error)
189+ parser *AppearanceParser
190+ emit func(InputEvent)
191+
192+ pending []byte
193+ inPaste bool
194+ pasteTail []byte
195+}
196+
197+func NewAppearanceSource(read func() (byte, error), peek func(time.Duration) (byte, bool, error), parser *AppearanceParser, emit func(InputEvent)) *AppearanceSource {
198+ return &AppearanceSource{read: read, peek: peek, parser: parser, emit: emit}
199+}
200+
201+func (s *AppearanceSource) ReadByte() (byte, error) {
202+ if len(s.pending) > 0 {
203+ b := s.pending[0]
204+ s.pending = s.pending[1:]
205+ return b, nil
206+ }
207+ for {
208+ b, err := s.read()
209+ if err != nil || b != '\x1b' || s.inPaste || s.peek == nil {
210+ s.trackPaste(b)
211+ return b, err
212+ }
213+ lookahead := time.Millisecond
214+ if s.parser != nil && (s.parser.acceptsColors() || s.parser.acceptsNotifications()) {
215+ lookahead = 50 * time.Millisecond
216+ }
217+ next, ok, err := s.peek(lookahead)
218+ if err != nil || !ok || (next != ']' && next != '[') {
219+ if ok {
220+ s.pending = append(s.pending, next)
221+ }
222+ return b, err
223+ }
224+ raw := []byte{'\x1b', next}
225+ if next == ']' {
226+ sequence, err := s.readOSC(raw)
227+ if err != nil {
228+ return 0, err
229+ }
230+ if events := ParseAppearanceOSC(sequence); len(events) != 0 && s.parser != nil && s.parser.acceptsColors() {
231+ for _, event := range events {
232+ e := event
233+ s.emit(InputEvent{Color: &e})
234+ }
235+ continue
236+ }
237+ s.pending = append(s.pending, sequence[1:]...)
238+ return '\x1b', nil
239+ }
240+ sequence, err := s.readCSI(raw)
241+ if err != nil {
242+ return 0, err
243+ }
244+ if bytes.Equal(sequence, []byte("\x1b[200~")) {
245+ s.inPaste = true
246+ }
247+ scheme, mode := ParseAppearanceCSI(sequence)
248+ if scheme != nil && s.parser != nil {
249+ if solicited, accepted := s.parser.consumeScheme(); accepted {
250+ scheme.Solicited = solicited
251+ s.emit(InputEvent{Scheme: scheme})
252+ continue
253+ }
254+ }
255+ if mode != nil {
256+ s.emit(InputEvent{Mode: mode})
257+ continue
258+ }
259+ s.pending = append(s.pending, sequence[1:]...)
260+ return '\x1b', nil
261+ }
262+}
263+
264+func (s *AppearanceSource) readOSC(raw []byte) ([]byte, error) {
265+ const limit = 4096
266+ for len(raw) < limit {
267+ b, err := s.read()
268+ if err != nil {
269+ return raw, err
270+ }
271+ raw = append(raw, b)
272+ if b == '\a' || (len(raw) >= 2 && raw[len(raw)-2] == '\x1b' && b == '\\') {
273+ return raw, nil
274+ }
275+ }
276+ return raw, nil
277+}
278+
279+func (s *AppearanceSource) readCSI(raw []byte) ([]byte, error) {
280+ const limit = 256
281+ for len(raw) < limit {
282+ b, err := s.read()
283+ if err != nil {
284+ return raw, err
285+ }
286+ raw = append(raw, b)
287+ if b >= 0x40 && b <= 0x7e {
288+ return raw, nil
289+ }
290+ }
291+ return raw, nil
292+}
293+
294+func (s *AppearanceSource) trackPaste(b byte) {
295+ if !s.inPaste {
296+ return
297+ }
298+ const end = "\x1b[201~"
299+ s.pasteTail = append(s.pasteTail, b)
300+ if len(s.pasteTail) > len(end) {
301+ s.pasteTail = s.pasteTail[1:]
302+ }
303+ if string(s.pasteTail) == end {
304+ s.inPaste = false
305+ s.pasteTail = nil
306+ }
307+}
@@ -0,0 +1,307 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "strconv"
6+ "strings"
7+ "sync"
8+ "time"
9+)
10+
11+// Appearance query and DEC mode 2031 control sequences.
12+const (
13+ SeqQueryColorScheme = "\x1b[?996n"
14+ SeqQueryMode2031 = "\x1b[?2031$p"
15+ SeqEnableMode2031 = "\x1b[?2031h"
16+ SeqDisableMode2031 = "\x1b[?2031l"
17+)
18+
19+// AppearanceQuery returns the complete profile query used at startup and on a
20+// replacement generation. The ANSI palette is requested in one OSC 4 batch.
21+func AppearanceQuery() string {
22+ var b strings.Builder
23+ b.WriteString(SeqQueryColorScheme)
24+ b.WriteString("\x1b]10;?\a\x1b]11;?\a\x1b]4;")
25+ for slot := 0; slot < 16; slot++ {
26+ if slot > 0 {
27+ b.WriteByte(';')
28+ }
29+ b.WriteString(strconv.Itoa(slot))
30+ b.WriteString(";?")
31+ }
32+ b.WriteByte('\a')
33+ return b.String()
34+}
35+
36+// SchemeEvent is a reported terminal color-scheme preference. Solicited is
37+// true only for the reply to a query initiated by zut.
38+type SchemeEvent struct {
39+ Light bool
40+ Solicited bool
41+}
42+
43+// ColorEvent is one default foreground/background or ANSI-palette reply.
44+type ColorEvent struct {
45+ Kind int // 10 foreground, 11 background, 4 palette
46+ Slot int
47+ Color TerminalColor
48+}
49+
50+// ModeReportEvent is a DECRQM response. Status follows the terminal protocol:
51+// 1/3 set, 2 reset, 0/4 unsupported or permanently reset.
52+type ModeReportEvent struct {
53+ Mode int
54+ Status int
55+}
56+
57+// InputEvent is emitted by the appearance-aware input path. Exactly one field
58+// is set. Key bytes that do not form an accepted protocol reply remain keys.
59+type InputEvent struct {
60+ Key *Key
61+ Scheme *SchemeEvent
62+ Color *ColorEvent
63+ Mode *ModeReportEvent
64+}
65+
66+// AppearanceParser controls which terminal replies may be consumed. Keeping
67+// the gate here prevents OSC-looking editor input from disappearing unless zut
68+// actively issued a matching query, while 997 notifications are accepted only
69+// after notification support is known.
70+type AppearanceParser struct {
71+ mu sync.RWMutex
72+ pendingColors bool
73+ pendingScheme bool
74+ notifications bool
75+}
76+
77+func (p *AppearanceParser) SetPendingColors(pending bool) {
78+ p.mu.Lock()
79+ p.pendingColors = pending
80+ p.mu.Unlock()
81+}
82+
83+func (p *AppearanceParser) SetNotifications(enabled bool) {
84+ p.mu.Lock()
85+ p.notifications = enabled
86+ p.mu.Unlock()
87+}
88+
89+// SetPendingScheme permits one solicited 997 response and marks it so the
90+// event loop does not mistake it for a new terminal notification.
91+func (p *AppearanceParser) SetPendingScheme(pending bool) {
92+ p.mu.Lock()
93+ p.pendingScheme = pending
94+ p.mu.Unlock()
95+}
96+
97+func (p *AppearanceParser) acceptsColors() bool {
98+ p.mu.RLock()
99+ defer p.mu.RUnlock()
100+ return p.pendingColors
101+}
102+
103+func (p *AppearanceParser) acceptsNotifications() bool {
104+ p.mu.RLock()
105+ defer p.mu.RUnlock()
106+ return p.notifications
107+}
108+
109+func (p *AppearanceParser) consumeScheme() (solicited bool, accepted bool) {
110+ p.mu.Lock()
111+ defer p.mu.Unlock()
112+ if p.pendingScheme {
113+ p.pendingScheme = false
114+ return true, true
115+ }
116+ return false, p.notifications
117+}
118+
119+// ParseAppearanceOSC parses one complete BEL/ST-terminated OSC sequence.
120+// Combined OSC 4 responses produce one ColorEvent for every valid pair.
121+func ParseAppearanceOSC(raw []byte) []ColorEvent {
122+ s := strings.TrimSuffix(string(raw), "\a")
123+ s = strings.TrimSuffix(s, "\x1b\\")
124+ if !strings.HasPrefix(s, "\x1b]") {
125+ return nil
126+ }
127+ body := strings.TrimPrefix(s, "\x1b]")
128+ parts := strings.Split(body, ";")
129+ if len(parts) < 2 {
130+ return nil
131+ }
132+ kind, err := strconv.Atoi(parts[0])
133+ if err != nil {
134+ return nil
135+ }
136+ switch kind {
137+ case 10, 11:
138+ color, ok := parseTerminalRGB(strings.Join(parts[1:], ";"))
139+ if !ok {
140+ return nil
141+ }
142+ return []ColorEvent{{Kind: kind, Slot: -1, Color: color}}
143+ case 4:
144+ var events []ColorEvent
145+ for idx := 1; idx+1 < len(parts); idx += 2 {
146+ slot, err := strconv.Atoi(parts[idx])
147+ if err != nil || slot < 0 || slot >= 16 {
148+ continue
149+ }
150+ color, ok := parseTerminalRGB(parts[idx+1])
151+ if ok {
152+ events = append(events, ColorEvent{Kind: 4, Slot: slot, Color: color})
153+ }
154+ }
155+ return events
156+ default:
157+ return nil
158+ }
159+}
160+
161+// ParseAppearanceCSI parses the two runtime CSI response families used by
162+// terminal-owned themes: current scheme (997) and mode ownership (DECRQM).
163+func ParseAppearanceCSI(raw []byte) (scheme *SchemeEvent, mode *ModeReportEvent) {
164+ s := string(raw)
165+ if !strings.HasPrefix(s, "\x1b[") {
166+ return nil, nil
167+ }
168+ body := strings.TrimPrefix(s, "\x1b[")
169+ if strings.HasPrefix(body, "?997;") && strings.HasSuffix(body, "n") {
170+ value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?997;"), "n"))
171+ if err == nil && (value == 1 || value == 2) {
172+ return &SchemeEvent{Light: value == 2}, nil
173+ }
174+ }
175+ if strings.HasPrefix(body, "?2031;") && strings.HasSuffix(body, "$y") {
176+ value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?2031;"), "$y"))
177+ if err == nil {
178+ return nil, &ModeReportEvent{Mode: 2031, Status: value}
179+ }
180+ }
181+ return nil, nil
182+}
183+
184+// AppearanceSource filters accepted terminal appearance replies before they
185+// reach Reader. It deliberately leaves bracketed-paste payload opaque.
186+type AppearanceSource struct {
187+ read func() (byte, error)
188+ peek func(time.Duration) (byte, bool, error)
189+ parser *AppearanceParser
190+ emit func(InputEvent)
191+
192+ pending []byte
193+ inPaste bool
194+ pasteTail []byte
195+}
196+
197+func NewAppearanceSource(read func() (byte, error), peek func(time.Duration) (byte, bool, error), parser *AppearanceParser, emit func(InputEvent)) *AppearanceSource {
198+ return &AppearanceSource{read: read, peek: peek, parser: parser, emit: emit}
199+}
200+
201+func (s *AppearanceSource) ReadByte() (byte, error) {
202+ if len(s.pending) > 0 {
203+ b := s.pending[0]
204+ s.pending = s.pending[1:]
205+ return b, nil
206+ }
207+ for {
208+ b, err := s.read()
209+ if err != nil || b != '\x1b' || s.inPaste || s.peek == nil {
210+ s.trackPaste(b)
211+ return b, err
212+ }
213+ lookahead := time.Millisecond
214+ if s.parser != nil && (s.parser.acceptsColors() || s.parser.acceptsNotifications()) {
215+ lookahead = 50 * time.Millisecond
216+ }
217+ next, ok, err := s.peek(lookahead)
218+ if err != nil || !ok || (next != ']' && next != '[') {
219+ if ok {
220+ s.pending = append(s.pending, next)
221+ }
222+ return b, err
223+ }
224+ raw := []byte{'\x1b', next}
225+ if next == ']' {
226+ sequence, err := s.readOSC(raw)
227+ if err != nil {
228+ return 0, err
229+ }
230+ if events := ParseAppearanceOSC(sequence); len(events) != 0 && s.parser != nil && s.parser.acceptsColors() {
231+ for _, event := range events {
232+ e := event
233+ s.emit(InputEvent{Color: &e})
234+ }
235+ continue
236+ }
237+ s.pending = append(s.pending, sequence[1:]...)
238+ return '\x1b', nil
239+ }
240+ sequence, err := s.readCSI(raw)
241+ if err != nil {
242+ return 0, err
243+ }
244+ if bytes.Equal(sequence, []byte("\x1b[200~")) {
245+ s.inPaste = true
246+ }
247+ scheme, mode := ParseAppearanceCSI(sequence)
248+ if scheme != nil && s.parser != nil {
249+ if solicited, accepted := s.parser.consumeScheme(); accepted {
250+ scheme.Solicited = solicited
251+ s.emit(InputEvent{Scheme: scheme})
252+ continue
253+ }
254+ }
255+ if mode != nil {
256+ s.emit(InputEvent{Mode: mode})
257+ continue
258+ }
259+ s.pending = append(s.pending, sequence[1:]...)
260+ return '\x1b', nil
261+ }
262+}
263+
264+func (s *AppearanceSource) readOSC(raw []byte) ([]byte, error) {
265+ const limit = 4096
266+ for len(raw) < limit {
267+ b, err := s.read()
268+ if err != nil {
269+ return raw, err
270+ }
271+ raw = append(raw, b)
272+ if b == '\a' || (len(raw) >= 2 && raw[len(raw)-2] == '\x1b' && b == '\\') {
273+ return raw, nil
274+ }
275+ }
276+ return raw, nil
277+}
278+
279+func (s *AppearanceSource) readCSI(raw []byte) ([]byte, error) {
280+ const limit = 256
281+ for len(raw) < limit {
282+ b, err := s.read()
283+ if err != nil {
284+ return raw, err
285+ }
286+ raw = append(raw, b)
287+ if b >= 0x40 && b <= 0x7e {
288+ return raw, nil
289+ }
290+ }
291+ return raw, nil
292+}
293+
294+func (s *AppearanceSource) trackPaste(b byte) {
295+ if !s.inPaste {
296+ return
297+ }
298+ const end = "\x1b[201~"
299+ s.pasteTail = append(s.pasteTail, b)
300+ if len(s.pasteTail) > len(end) {
301+ s.pasteTail = s.pasteTail[1:]
302+ }
303+ if string(s.pasteTail) == end {
304+ s.inPaste = false
305+ s.pasteTail = nil
306+ }
307+}
A packages/tui/appearance_protocol_test.go +136 -0
@@ -0,0 +1,136 @@
1+package tui
2+
3+import (
4+ "io"
5+ "testing"
6+ "time"
7+)
8+
9+func TestParseAppearanceOSC(t *testing.T) {
10+ events := ParseAppearanceOSC([]byte("\x1b]4;1;rgb:ff/00/00;12;#123456\x1b\\"))
11+ if len(events) != 2 {
12+ t.Fatalf("events = %#v, want two palette entries", events)
13+ }
14+ if events[0].Slot != 1 || events[0].Color != ColorRGB(255, 0, 0) {
15+ t.Fatalf("first event = %#v", events[0])
16+ }
17+ if events[1].Slot != 12 || events[1].Color != ColorRGB(0x12, 0x34, 0x56) {
18+ t.Fatalf("second event = %#v", events[1])
19+ }
20+}
21+
22+func TestParseAppearanceCSI(t *testing.T) {
23+ scheme, mode := ParseAppearanceCSI([]byte("\x1b[?997;2n"))
24+ if scheme == nil || !scheme.Light || mode != nil {
25+ t.Fatalf("scheme parse = %#v, %#v", scheme, mode)
26+ }
27+ scheme, mode = ParseAppearanceCSI([]byte("\x1b[?2031;2$y"))
28+ if scheme != nil || mode == nil || mode.Status != 2 {
29+ t.Fatalf("mode parse = %#v, %#v", scheme, mode)
30+ }
31+}
32+
33+type appearanceBytes struct {
34+ data []byte
35+ minPeek time.Duration
36+}
37+
38+func (s *appearanceBytes) read() (byte, error) {
39+ if len(s.data) == 0 {
40+ return 0, io.EOF
41+ }
42+ b := s.data[0]
43+ s.data = s.data[1:]
44+ return b, nil
45+}
46+
47+func (s *appearanceBytes) peek(timeout time.Duration) (byte, bool, error) {
48+ if timeout < s.minPeek {
49+ return 0, false, nil
50+ }
51+ if len(s.data) == 0 {
52+ return 0, false, nil
53+ }
54+ b := s.data[0]
55+ s.data = s.data[1:]
56+ return b, true, nil
57+}
58+
59+func TestAppearanceSourceConsumesOnlyPendingReplies(t *testing.T) {
60+ input := &appearanceBytes{data: []byte("a\x1b]11;#123456\ab")}
61+ parser := &AppearanceParser{}
62+ parser.SetPendingColors(true)
63+ var events []InputEvent
64+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
65+ var got []byte
66+ for {
67+ b, err := source.ReadByte()
68+ if err == io.EOF {
69+ break
70+ }
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ got = append(got, b)
75+ }
76+ if string(got) != "ab" {
77+ t.Fatalf("remaining bytes = %q, want ab", got)
78+ }
79+ if len(events) != 1 || events[0].Color == nil || events[0].Color.Kind != 11 {
80+ t.Fatalf("events = %#v", events)
81+ }
82+}
83+
84+func TestAppearanceSourceAcceptsDelayedReplyWhileQueryPending(t *testing.T) {
85+ input := &appearanceBytes{data: []byte("\x1b]11;#123456\a"), minPeek: 2 * time.Millisecond}
86+ parser := &AppearanceParser{}
87+ parser.SetPendingColors(true)
88+ var events []InputEvent
89+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
90+ if _, err := source.ReadByte(); err != io.EOF {
91+ t.Fatalf("ReadByte error = %v, want EOF after consuming reply", err)
92+ }
93+ if len(events) != 1 || events[0].Color == nil || events[0].Color.Kind != 11 {
94+ t.Fatalf("delayed reply events = %#v", events)
95+ }
96+}
97+
98+func TestAppearanceSourceMarksOnlyPendingSchemeReplySolicited(t *testing.T) {
99+ input := &appearanceBytes{data: []byte("\x1b[?997;1n\x1b[?997;2n")}
100+ parser := &AppearanceParser{}
101+ parser.SetPendingScheme(true)
102+ parser.SetNotifications(true)
103+ var events []InputEvent
104+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
105+ for {
106+ _, err := source.ReadByte()
107+ if err == io.EOF {
108+ break
109+ }
110+ if err != nil {
111+ t.Fatal(err)
112+ }
113+ }
114+ if len(events) != 2 || events[0].Scheme == nil || events[1].Scheme == nil || !events[0].Scheme.Solicited || events[1].Scheme.Solicited {
115+ t.Fatalf("scheme events = %#v", events)
116+ }
117+}
118+
119+func TestAppearanceSourceLeavesPasteOpaque(t *testing.T) {
120+ input := &appearanceBytes{data: []byte("\x1b[200~x\x1b]11;#123456\ay\x1b[201~")}
121+ parser := &AppearanceParser{}
122+ parser.SetPendingColors(true)
123+ var events []InputEvent
124+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
125+ reader := NewReader(source.ReadByte)
126+ key, err := reader.Read()
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ if key.Kind != KeyPaste || key.Paste != "x\x1b]11;#123456\ay" {
131+ t.Fatalf("paste = %#v", key)
132+ }
133+ if len(events) != 0 {
134+ t.Fatalf("appearance events leaked from paste: %#v", events)
135+ }
136+}
@@ -0,0 +1,136 @@
1+package tui
2+
3+import (
4+ "io"
5+ "testing"
6+ "time"
7+)
8+
9+func TestParseAppearanceOSC(t *testing.T) {
10+ events := ParseAppearanceOSC([]byte("\x1b]4;1;rgb:ff/00/00;12;#123456\x1b\\"))
11+ if len(events) != 2 {
12+ t.Fatalf("events = %#v, want two palette entries", events)
13+ }
14+ if events[0].Slot != 1 || events[0].Color != ColorRGB(255, 0, 0) {
15+ t.Fatalf("first event = %#v", events[0])
16+ }
17+ if events[1].Slot != 12 || events[1].Color != ColorRGB(0x12, 0x34, 0x56) {
18+ t.Fatalf("second event = %#v", events[1])
19+ }
20+}
21+
22+func TestParseAppearanceCSI(t *testing.T) {
23+ scheme, mode := ParseAppearanceCSI([]byte("\x1b[?997;2n"))
24+ if scheme == nil || !scheme.Light || mode != nil {
25+ t.Fatalf("scheme parse = %#v, %#v", scheme, mode)
26+ }
27+ scheme, mode = ParseAppearanceCSI([]byte("\x1b[?2031;2$y"))
28+ if scheme != nil || mode == nil || mode.Status != 2 {
29+ t.Fatalf("mode parse = %#v, %#v", scheme, mode)
30+ }
31+}
32+
33+type appearanceBytes struct {
34+ data []byte
35+ minPeek time.Duration
36+}
37+
38+func (s *appearanceBytes) read() (byte, error) {
39+ if len(s.data) == 0 {
40+ return 0, io.EOF
41+ }
42+ b := s.data[0]
43+ s.data = s.data[1:]
44+ return b, nil
45+}
46+
47+func (s *appearanceBytes) peek(timeout time.Duration) (byte, bool, error) {
48+ if timeout < s.minPeek {
49+ return 0, false, nil
50+ }
51+ if len(s.data) == 0 {
52+ return 0, false, nil
53+ }
54+ b := s.data[0]
55+ s.data = s.data[1:]
56+ return b, true, nil
57+}
58+
59+func TestAppearanceSourceConsumesOnlyPendingReplies(t *testing.T) {
60+ input := &appearanceBytes{data: []byte("a\x1b]11;#123456\ab")}
61+ parser := &AppearanceParser{}
62+ parser.SetPendingColors(true)
63+ var events []InputEvent
64+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
65+ var got []byte
66+ for {
67+ b, err := source.ReadByte()
68+ if err == io.EOF {
69+ break
70+ }
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ got = append(got, b)
75+ }
76+ if string(got) != "ab" {
77+ t.Fatalf("remaining bytes = %q, want ab", got)
78+ }
79+ if len(events) != 1 || events[0].Color == nil || events[0].Color.Kind != 11 {
80+ t.Fatalf("events = %#v", events)
81+ }
82+}
83+
84+func TestAppearanceSourceAcceptsDelayedReplyWhileQueryPending(t *testing.T) {
85+ input := &appearanceBytes{data: []byte("\x1b]11;#123456\a"), minPeek: 2 * time.Millisecond}
86+ parser := &AppearanceParser{}
87+ parser.SetPendingColors(true)
88+ var events []InputEvent
89+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
90+ if _, err := source.ReadByte(); err != io.EOF {
91+ t.Fatalf("ReadByte error = %v, want EOF after consuming reply", err)
92+ }
93+ if len(events) != 1 || events[0].Color == nil || events[0].Color.Kind != 11 {
94+ t.Fatalf("delayed reply events = %#v", events)
95+ }
96+}
97+
98+func TestAppearanceSourceMarksOnlyPendingSchemeReplySolicited(t *testing.T) {
99+ input := &appearanceBytes{data: []byte("\x1b[?997;1n\x1b[?997;2n")}
100+ parser := &AppearanceParser{}
101+ parser.SetPendingScheme(true)
102+ parser.SetNotifications(true)
103+ var events []InputEvent
104+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
105+ for {
106+ _, err := source.ReadByte()
107+ if err == io.EOF {
108+ break
109+ }
110+ if err != nil {
111+ t.Fatal(err)
112+ }
113+ }
114+ if len(events) != 2 || events[0].Scheme == nil || events[1].Scheme == nil || !events[0].Scheme.Solicited || events[1].Scheme.Solicited {
115+ t.Fatalf("scheme events = %#v", events)
116+ }
117+}
118+
119+func TestAppearanceSourceLeavesPasteOpaque(t *testing.T) {
120+ input := &appearanceBytes{data: []byte("\x1b[200~x\x1b]11;#123456\ay\x1b[201~")}
121+ parser := &AppearanceParser{}
122+ parser.SetPendingColors(true)
123+ var events []InputEvent
124+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
125+ reader := NewReader(source.ReadByte)
126+ key, err := reader.Read()
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ if key.Kind != KeyPaste || key.Paste != "x\x1b]11;#123456\ay" {
131+ t.Fatalf("paste = %#v", key)
132+ }
133+ if len(events) != 0 {
134+ t.Fatalf("appearance events leaked from paste: %#v", events)
135+ }
136+}
M packages/tui/detect_bg.go +22 -33
@@ -21,39 +21,28 @@ func DetectTrueColor(termEnv, colorTerm string) bool {
2121 return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct")
2222 }
2323
24+// DetectColorDepth conservatively chooses the highest color mode advertised
25+// by the environment. Terminals without a 256-color signal stay ANSI16.
26+func DetectColorDepth(termEnv, colorTerm string) ColorDepth {
27+ if DetectTrueColor(termEnv, colorTerm) {
28+ return ColorDepthTrueColor
29+ }
30+ termEnv = strings.ToLower(strings.TrimSpace(termEnv))
31+ if strings.Contains(termEnv, "256color") {
32+ return ColorDepthIndexed256
33+ }
34+ return ColorDepthANSI16
35+}
36+
2437 // DetectThemeFromBackground queries the controlling tty for its current
25-// foreground, background, and ANSI palette using OSC 10/11/4. The returned
26-// theme keeps the detected light/dark built-in colors for the normal auto
27-// theme and carries the terminal snapshot for the inherited theme.
38+// foreground, background, and ANSI palette using OSC 10/11/4. Auto is always
39+// terminal-owned; the snapshot is retained for subsequent runtime resolution.
2840 //
2941 // The query / parse runs synchronously before the TUI is initialised so the
3042 // returned snapshot can drive the entire session. We briefly put stdin into
3143 // raw mode and disable echo so OSC replies do not leak onto the user's screen.
3244 func DetectThemeFromBackground(timeout time.Duration) Theme {
33- profile := detectTerminalProfile(timeout)
34- base := Dark
35-
36- // Honour explicit override env vars first; some users and CI environments
37- // know better than the heuristic. The terminal snapshot is still retained
38- // so selecting the inherited theme later remains useful.
39- switch strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME"))) {
40- case "dark":
41- base = Dark
42- case "light":
43- base = Light
44- default:
45- if profile.HasBackground {
46- luma := 0.2126*float64(profile.Background.R) +
47- 0.7152*float64(profile.Background.G) +
48- 0.0722*float64(profile.Background.B)
49- if luma >= 127.5 {
50- base = Light
51- }
52- }
53- }
54-
55- base.Terminal = profile
56- return base
45+ return TerminalTheme(detectTerminalProfile(timeout))
5746 }
5847
5948 type oscColorResponse struct {
@@ -63,7 +52,7 @@ type oscColorResponse struct {
6352 }
6453
6554 func detectTerminalProfile(timeout time.Duration) TerminalProfile {
66- profile := TerminalProfile{TrueColor: DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"))}
55+ profile := TerminalProfile{Depth: DetectColorDepth(os.Getenv("TERM"), os.Getenv("COLORTERM")), TrueColor: DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"))}
6756 if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) || timeout <= 0 {
6857 return profile
6958 }
@@ -97,11 +86,11 @@ func detectTerminalProfile(timeout time.Duration) TerminalProfile {
9786 }
9887 }
9988 if profile.HasBackground {
100- luma := 0.2126*float64(profile.Background.R) +
101- 0.7152*float64(profile.Background.G) +
102- 0.0722*float64(profile.Background.B)
103- profile.Light = luma >= 127.5
104- profile.SchemeKnown = true
89+ // This is a branch-selection fallback, not a reported scheme. Runtime
90+ // CSI 997 replies alone set SchemeKnown.
91+ profile.Light = 0.2126*float64(profile.Background.R)+
92+ 0.7152*float64(profile.Background.G)+
93+ 0.0722*float64(profile.Background.B) >= 127.5
10594 }
10695 return profile
10796 }
@@ -21,39 +21,28 @@ func DetectTrueColor(termEnv, colorTerm string) bool {
21 return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct")21 return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct")
22 }22 }
23 23
24+// DetectColorDepth conservatively chooses the highest color mode advertised
25+// by the environment. Terminals without a 256-color signal stay ANSI16.
26+func DetectColorDepth(termEnv, colorTerm string) ColorDepth {
27+ if DetectTrueColor(termEnv, colorTerm) {
28+ return ColorDepthTrueColor
29+ }
30+ termEnv = strings.ToLower(strings.TrimSpace(termEnv))
31+ if strings.Contains(termEnv, "256color") {
32+ return ColorDepthIndexed256
33+ }
34+ return ColorDepthANSI16
35+}
36+
24 // DetectThemeFromBackground queries the controlling tty for its current37 // DetectThemeFromBackground queries the controlling tty for its current
25-// foreground, background, and ANSI palette using OSC 10/11/4. The returned38+// foreground, background, and ANSI palette using OSC 10/11/4. Auto is always
26-// theme keeps the detected light/dark built-in colors for the normal auto39+// terminal-owned; the snapshot is retained for subsequent runtime resolution.
27-// theme and carries the terminal snapshot for the inherited theme.
28 //40 //
29 // The query / parse runs synchronously before the TUI is initialised so the41 // The query / parse runs synchronously before the TUI is initialised so the
30 // returned snapshot can drive the entire session. We briefly put stdin into42 // returned snapshot can drive the entire session. We briefly put stdin into
31 // raw mode and disable echo so OSC replies do not leak onto the user's screen.43 // raw mode and disable echo so OSC replies do not leak onto the user's screen.
32 func DetectThemeFromBackground(timeout time.Duration) Theme {44 func DetectThemeFromBackground(timeout time.Duration) Theme {
33- profile := detectTerminalProfile(timeout)45+ return TerminalTheme(detectTerminalProfile(timeout))
34- base := Dark
35-
36- // Honour explicit override env vars first; some users and CI environments
37- // know better than the heuristic. The terminal snapshot is still retained
38- // so selecting the inherited theme later remains useful.
39- switch strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME"))) {
40- case "dark":
41- base = Dark
42- case "light":
43- base = Light
44- default:
45- if profile.HasBackground {
46- luma := 0.2126*float64(profile.Background.R) +
47- 0.7152*float64(profile.Background.G) +
48- 0.0722*float64(profile.Background.B)
49- if luma >= 127.5 {
50- base = Light
51- }
52- }
53- }
54-
55- base.Terminal = profile
56- return base
57 }46 }
58 47
59 type oscColorResponse struct {48 type oscColorResponse struct {
@@ -63,7 +52,7 @@ type oscColorResponse struct {
63 }52 }
64 53
65 func detectTerminalProfile(timeout time.Duration) TerminalProfile {54 func detectTerminalProfile(timeout time.Duration) TerminalProfile {
66- profile := TerminalProfile{TrueColor: DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"))}55+ profile := TerminalProfile{Depth: DetectColorDepth(os.Getenv("TERM"), os.Getenv("COLORTERM")), TrueColor: DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"))}
67 if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) || timeout <= 0 {56 if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) || timeout <= 0 {
68 return profile57 return profile
69 }58 }
@@ -97,11 +86,11 @@ func detectTerminalProfile(timeout time.Duration) TerminalProfile {
97 }86 }
98 }87 }
99 if profile.HasBackground {88 if profile.HasBackground {
100- luma := 0.2126*float64(profile.Background.R) +89+ // This is a branch-selection fallback, not a reported scheme. Runtime
101- 0.7152*float64(profile.Background.G) +90+ // CSI 997 replies alone set SchemeKnown.
102- 0.0722*float64(profile.Background.B)91+ profile.Light = 0.2126*float64(profile.Background.R)+
103- profile.Light = luma >= 127.592+ 0.7152*float64(profile.Background.G)+
104- profile.SchemeKnown = true93+ 0.0722*float64(profile.Background.B) >= 127.5
105 }94 }
106 return profile95 return profile
107 }96 }
M packages/tui/highlight.go +175 -13
@@ -3,6 +3,7 @@ package tui
33 import (
44 "bytes"
55 "path/filepath"
6+ "strconv"
67 "strings"
78 "sync"
89
@@ -36,22 +37,26 @@ func (th Theme) HighlightCode(src, lang string) []string {
3637 highlightCache.store(styleKey, lang, src, out)
3738 return out
3839 }
40+ iterator, err := lexer.Tokenise(nil, src)
41+ if err != nil {
42+ out := strings.Split(src, "\n")
43+ highlightCache.store(styleKey, lang, src, out)
44+ return out
45+ }
46+ if th.UseTerminalPalette {
47+ out := formatTerminalTokens(th, iterator)
48+ highlightCache.store(styleKey, lang, src, out)
49+ return out
50+ }
3951 style := th.chromaStyle()
4052 formatterName := "terminal256"
41- if th.Terminal.TrueColor {
53+ if th.Terminal.ColorDepth() == ColorDepthTrueColor {
4254 formatterName = "terminal16m"
4355 }
4456 formatter := formatters.Get(formatterName)
4557 if formatter == nil {
4658 return strings.Split(src, "\n")
4759 }
48-
49- iterator, err := lexer.Tokenise(nil, src)
50- if err != nil {
51- out := strings.Split(src, "\n")
52- highlightCache.store(styleKey, lang, src, out)
53- return out
54- }
5560 var buf bytes.Buffer
5661 if err := formatter.Format(&buf, style, iterator); err != nil {
5762 out := strings.Split(src, "\n")
@@ -63,6 +68,134 @@ func (th Theme) HighlightCode(src, lang string) []string {
6368 return out
6469 }
6570
71+// formatTerminalTokens keeps Chroma's lexer but owns terminal formatting for
72+// adaptive themes. Chroma's terminal formatters map duplicate ANSI RGB values
73+// through the xterm cube, losing an intended palette slot such as bright blue.
74+func formatTerminalTokens(th Theme, iterator chroma.Iterator) []string {
75+ var out strings.Builder
76+ for token := iterator(); token != chroma.EOF; token = iterator() {
77+ color, bold, italic := terminalTokenStyle(th, token.Type)
78+ prefix := th.fgPrefix(color)
79+ if bold {
80+ prefix += "\x1b[1m"
81+ }
82+ if italic {
83+ prefix += "\x1b[3m"
84+ }
85+ // Coalesced Chroma tokens can span rows. Close the style before each
86+ // newline and reopen it for the next segment so every rendered row is
87+ // self-contained and cannot bleed into padding or following text.
88+ for index, segment := range strings.Split(token.Value, "\n") {
89+ if index > 0 {
90+ out.WriteByte('\n')
91+ }
92+ if segment == "" {
93+ continue
94+ }
95+ out.WriteString(prefix)
96+ out.WriteString(segment)
97+ out.WriteString(reset)
98+ }
99+ }
100+ return strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
101+}
102+
103+func terminalTokenStyle(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {
104+ color, bold, italic := terminalTokenDefault(th, typ)
105+ if entry, ok := terminalSyntaxEntry(th, typ); ok {
106+ if entry.Colour.IsSet() {
107+ color = ColorRGB(int(entry.Colour.Red()), int(entry.Colour.Green()), int(entry.Colour.Blue()))
108+ }
109+ if entry.Bold != chroma.Pass {
110+ bold = entry.Bold == chroma.Yes
111+ }
112+ if entry.Italic != chroma.Pass {
113+ italic = entry.Italic == chroma.Yes
114+ }
115+ }
116+ return color, bold, italic
117+}
118+
119+func terminalTokenDefault(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {
120+ switch {
121+ case typ.InCategory(chroma.Comment):
122+ return th.Muted, false, true
123+ case typ == chroma.LiteralStringEscape || typ == chroma.Error:
124+ return th.Error, false, false
125+ case typ.InSubCategory(chroma.LiteralString):
126+ return th.Tool, false, false
127+ case typ.InSubCategory(chroma.LiteralNumber):
128+ return th.Warning, false, false
129+ case typ.InCategory(chroma.Keyword):
130+ return th.Accent, true, false
131+ case typ.InCategory(chroma.Name):
132+ return TerminalPaletteSlot(14), false, false
133+ default:
134+ return TerminalDefault(), false, false
135+ }
136+}
137+
138+func terminalSyntaxEntry(th Theme, typ chroma.TokenType) (chroma.StyleEntry, bool) {
139+ if entry := terminalSyntaxOverride(th.Syntax, typ); entry != "" {
140+ parsed, err := chroma.ParseStyleEntry(entry)
141+ return parsed, err == nil
142+ }
143+ if th.SyntaxBaseStyle != "" {
144+ return styles.Get(th.SyntaxBaseStyle).Get(typ), true
145+ }
146+ return chroma.StyleEntry{}, false
147+}
148+
149+func terminalSyntaxOverride(s SyntaxTheme, typ chroma.TokenType) string {
150+ switch {
151+ case typ == chroma.KeywordConstant:
152+ return firstSyntaxOverride(s.KeywordConstant, s.Keyword)
153+ case typ == chroma.KeywordDeclaration:
154+ return firstSyntaxOverride(s.KeywordDeclaration, s.Keyword)
155+ case typ == chroma.KeywordNamespace:
156+ return firstSyntaxOverride(s.KeywordNamespace, s.Keyword)
157+ case typ == chroma.KeywordReserved:
158+ return firstSyntaxOverride(s.KeywordReserved, s.Keyword)
159+ case typ == chroma.KeywordType:
160+ return firstSyntaxOverride(s.KeywordType, s.Keyword)
161+ case typ.InCategory(chroma.Keyword):
162+ return s.Keyword
163+ case typ == chroma.NameBuiltin:
164+ return firstSyntaxOverride(s.NameBuiltin, s.NameFunction)
165+ case typ == chroma.NameFunction:
166+ return s.NameFunction
167+ case typ == chroma.NameClass:
168+ return firstSyntaxOverride(s.NameClass, s.NameFunction)
169+ case typ == chroma.NameDecorator:
170+ return firstSyntaxOverride(s.NameDecorator, s.NameFunction)
171+ case typ == chroma.LiteralStringEscape:
172+ return firstSyntaxOverride(s.LiteralStringEscape, s.LiteralString)
173+ case typ.InSubCategory(chroma.LiteralString):
174+ return s.LiteralString
175+ case typ.InSubCategory(chroma.LiteralNumber):
176+ return s.LiteralNumber
177+ case typ == chroma.CommentPreproc:
178+ return firstSyntaxOverride(s.CommentPreproc, s.Comment)
179+ case typ.InCategory(chroma.Comment):
180+ return s.Comment
181+ case typ.InCategory(chroma.Operator):
182+ return s.Operator
183+ case typ.InCategory(chroma.Punctuation):
184+ return s.Punctuation
185+ default:
186+ return s.Text
187+ }
188+}
189+
190+func firstSyntaxOverride(entries ...string) string {
191+ for _, entry := range entries {
192+ if entry != "" {
193+ return entry
194+ }
195+ }
196+ return ""
197+}
198+
66199 // highlightResultCache is a simple LRU-ish cache: when it exceeds its
67200 // capacity we drop half of the entries (the oldest, based on insertion
68201 // order). That's good enough since tool_result text doesn't change once
@@ -242,12 +375,8 @@ func (th Theme) chromaStyle() *chroma.Style {
242375
243376 func (th Theme) syntaxKey() string {
244377 syn := th.Syntax
245- outputMode := "256"
246- if th.Terminal.TrueColor {
247- outputMode = "truecolor"
248- }
249378 return strings.Join([]string{
250- outputMode,
379+ th.terminalSyntaxKeyPart(),
251380 th.SyntaxBaseStyle,
252381 syn.Keyword,
253382 syn.KeywordConstant,
@@ -270,6 +399,39 @@ func (th Theme) syntaxKey() string {
270399 }, "\x1f")
271400 }
272401
402+func (th Theme) terminalSyntaxKeyPart() string {
403+ h := fnv64aInit
404+ h = fnv64aWriteByte(h, byte(th.Terminal.ColorDepth()))
405+ if th.UseTerminalPalette {
406+ h = fnv64aWriteByte(h, 1)
407+ } else {
408+ h = fnv64aWriteByte(h, 0)
409+ }
410+ h = fnv64aWriteUint64(h, uint64(th.Terminal.PaletteKnown))
411+ for _, color := range th.Terminal.Palette {
412+ h = hashThemeColor(h, color)
413+ }
414+ for _, color := range []TerminalColor{
415+ th.Terminal.Foreground,
416+ th.Terminal.Background,
417+ th.Accent,
418+ th.Muted,
419+ th.Tool,
420+ th.Warning,
421+ th.Error,
422+ } {
423+ h = hashThemeColor(h, color)
424+ }
425+ for _, value := range []bool{th.Terminal.HasForeground, th.Terminal.HasBackground, th.Terminal.TrueColor, th.Terminal.Light, th.Terminal.SchemeKnown} {
426+ if value {
427+ h = fnv64aWriteByte(h, 1)
428+ } else {
429+ h = fnv64aWriteByte(h, 0)
430+ }
431+ }
432+ return strconv.FormatUint(h, 16)
433+}
434+
273435 // extLang maps file extensions to chroma lexer names. Only
274436 // extensions that chroma supports are included.
275437 var extLang = map[string]string{
@@ -3,6 +3,7 @@ package tui
3 import (3 import (
4 "bytes"4 "bytes"
5 "path/filepath"5 "path/filepath"
6+ "strconv"
6 "strings"7 "strings"
7 "sync"8 "sync"
8 9
@@ -36,22 +37,26 @@ func (th Theme) HighlightCode(src, lang string) []string {
36 highlightCache.store(styleKey, lang, src, out)37 highlightCache.store(styleKey, lang, src, out)
37 return out38 return out
38 }39 }
40+ iterator, err := lexer.Tokenise(nil, src)
41+ if err != nil {
42+ out := strings.Split(src, "\n")
43+ highlightCache.store(styleKey, lang, src, out)
44+ return out
45+ }
46+ if th.UseTerminalPalette {
47+ out := formatTerminalTokens(th, iterator)
48+ highlightCache.store(styleKey, lang, src, out)
49+ return out
50+ }
39 style := th.chromaStyle()51 style := th.chromaStyle()
40 formatterName := "terminal256"52 formatterName := "terminal256"
41- if th.Terminal.TrueColor {53+ if th.Terminal.ColorDepth() == ColorDepthTrueColor {
42 formatterName = "terminal16m"54 formatterName = "terminal16m"
43 }55 }
44 formatter := formatters.Get(formatterName)56 formatter := formatters.Get(formatterName)
45 if formatter == nil {57 if formatter == nil {
46 return strings.Split(src, "\n")58 return strings.Split(src, "\n")
47 }59 }
48-
49- iterator, err := lexer.Tokenise(nil, src)
50- if err != nil {
51- out := strings.Split(src, "\n")
52- highlightCache.store(styleKey, lang, src, out)
53- return out
54- }
55 var buf bytes.Buffer60 var buf bytes.Buffer
56 if err := formatter.Format(&buf, style, iterator); err != nil {61 if err := formatter.Format(&buf, style, iterator); err != nil {
57 out := strings.Split(src, "\n")62 out := strings.Split(src, "\n")
@@ -63,6 +68,134 @@ func (th Theme) HighlightCode(src, lang string) []string {
63 return out68 return out
64 }69 }
65 70
71+// formatTerminalTokens keeps Chroma's lexer but owns terminal formatting for
72+// adaptive themes. Chroma's terminal formatters map duplicate ANSI RGB values
73+// through the xterm cube, losing an intended palette slot such as bright blue.
74+func formatTerminalTokens(th Theme, iterator chroma.Iterator) []string {
75+ var out strings.Builder
76+ for token := iterator(); token != chroma.EOF; token = iterator() {
77+ color, bold, italic := terminalTokenStyle(th, token.Type)
78+ prefix := th.fgPrefix(color)
79+ if bold {
80+ prefix += "\x1b[1m"
81+ }
82+ if italic {
83+ prefix += "\x1b[3m"
84+ }
85+ // Coalesced Chroma tokens can span rows. Close the style before each
86+ // newline and reopen it for the next segment so every rendered row is
87+ // self-contained and cannot bleed into padding or following text.
88+ for index, segment := range strings.Split(token.Value, "\n") {
89+ if index > 0 {
90+ out.WriteByte('\n')
91+ }
92+ if segment == "" {
93+ continue
94+ }
95+ out.WriteString(prefix)
96+ out.WriteString(segment)
97+ out.WriteString(reset)
98+ }
99+ }
100+ return strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
101+}
102+
103+func terminalTokenStyle(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {
104+ color, bold, italic := terminalTokenDefault(th, typ)
105+ if entry, ok := terminalSyntaxEntry(th, typ); ok {
106+ if entry.Colour.IsSet() {
107+ color = ColorRGB(int(entry.Colour.Red()), int(entry.Colour.Green()), int(entry.Colour.Blue()))
108+ }
109+ if entry.Bold != chroma.Pass {
110+ bold = entry.Bold == chroma.Yes
111+ }
112+ if entry.Italic != chroma.Pass {
113+ italic = entry.Italic == chroma.Yes
114+ }
115+ }
116+ return color, bold, italic
117+}
118+
119+func terminalTokenDefault(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {
120+ switch {
121+ case typ.InCategory(chroma.Comment):
122+ return th.Muted, false, true
123+ case typ == chroma.LiteralStringEscape || typ == chroma.Error:
124+ return th.Error, false, false
125+ case typ.InSubCategory(chroma.LiteralString):
126+ return th.Tool, false, false
127+ case typ.InSubCategory(chroma.LiteralNumber):
128+ return th.Warning, false, false
129+ case typ.InCategory(chroma.Keyword):
130+ return th.Accent, true, false
131+ case typ.InCategory(chroma.Name):
132+ return TerminalPaletteSlot(14), false, false
133+ default:
134+ return TerminalDefault(), false, false
135+ }
136+}
137+
138+func terminalSyntaxEntry(th Theme, typ chroma.TokenType) (chroma.StyleEntry, bool) {
139+ if entry := terminalSyntaxOverride(th.Syntax, typ); entry != "" {
140+ parsed, err := chroma.ParseStyleEntry(entry)
141+ return parsed, err == nil
142+ }
143+ if th.SyntaxBaseStyle != "" {
144+ return styles.Get(th.SyntaxBaseStyle).Get(typ), true
145+ }
146+ return chroma.StyleEntry{}, false
147+}
148+
149+func terminalSyntaxOverride(s SyntaxTheme, typ chroma.TokenType) string {
150+ switch {
151+ case typ == chroma.KeywordConstant:
152+ return firstSyntaxOverride(s.KeywordConstant, s.Keyword)
153+ case typ == chroma.KeywordDeclaration:
154+ return firstSyntaxOverride(s.KeywordDeclaration, s.Keyword)
155+ case typ == chroma.KeywordNamespace:
156+ return firstSyntaxOverride(s.KeywordNamespace, s.Keyword)
157+ case typ == chroma.KeywordReserved:
158+ return firstSyntaxOverride(s.KeywordReserved, s.Keyword)
159+ case typ == chroma.KeywordType:
160+ return firstSyntaxOverride(s.KeywordType, s.Keyword)
161+ case typ.InCategory(chroma.Keyword):
162+ return s.Keyword
163+ case typ == chroma.NameBuiltin:
164+ return firstSyntaxOverride(s.NameBuiltin, s.NameFunction)
165+ case typ == chroma.NameFunction:
166+ return s.NameFunction
167+ case typ == chroma.NameClass:
168+ return firstSyntaxOverride(s.NameClass, s.NameFunction)
169+ case typ == chroma.NameDecorator:
170+ return firstSyntaxOverride(s.NameDecorator, s.NameFunction)
171+ case typ == chroma.LiteralStringEscape:
172+ return firstSyntaxOverride(s.LiteralStringEscape, s.LiteralString)
173+ case typ.InSubCategory(chroma.LiteralString):
174+ return s.LiteralString
175+ case typ.InSubCategory(chroma.LiteralNumber):
176+ return s.LiteralNumber
177+ case typ == chroma.CommentPreproc:
178+ return firstSyntaxOverride(s.CommentPreproc, s.Comment)
179+ case typ.InCategory(chroma.Comment):
180+ return s.Comment
181+ case typ.InCategory(chroma.Operator):
182+ return s.Operator
183+ case typ.InCategory(chroma.Punctuation):
184+ return s.Punctuation
185+ default:
186+ return s.Text
187+ }
188+}
189+
190+func firstSyntaxOverride(entries ...string) string {
191+ for _, entry := range entries {
192+ if entry != "" {
193+ return entry
194+ }
195+ }
196+ return ""
197+}
198+
66 // highlightResultCache is a simple LRU-ish cache: when it exceeds its199 // highlightResultCache is a simple LRU-ish cache: when it exceeds its
67 // capacity we drop half of the entries (the oldest, based on insertion200 // capacity we drop half of the entries (the oldest, based on insertion
68 // order). That's good enough since tool_result text doesn't change once201 // order). That's good enough since tool_result text doesn't change once
@@ -242,12 +375,8 @@ func (th Theme) chromaStyle() *chroma.Style {
242 375
243 func (th Theme) syntaxKey() string {376 func (th Theme) syntaxKey() string {
244 syn := th.Syntax377 syn := th.Syntax
245- outputMode := "256"
246- if th.Terminal.TrueColor {
247- outputMode = "truecolor"
248- }
249 return strings.Join([]string{378 return strings.Join([]string{
250- outputMode,379+ th.terminalSyntaxKeyPart(),
251 th.SyntaxBaseStyle,380 th.SyntaxBaseStyle,
252 syn.Keyword,381 syn.Keyword,
253 syn.KeywordConstant,382 syn.KeywordConstant,
@@ -270,6 +399,39 @@ func (th Theme) syntaxKey() string {
270 }, "\x1f")399 }, "\x1f")
271 }400 }
272 401
402+func (th Theme) terminalSyntaxKeyPart() string {
403+ h := fnv64aInit
404+ h = fnv64aWriteByte(h, byte(th.Terminal.ColorDepth()))
405+ if th.UseTerminalPalette {
406+ h = fnv64aWriteByte(h, 1)
407+ } else {
408+ h = fnv64aWriteByte(h, 0)
409+ }
410+ h = fnv64aWriteUint64(h, uint64(th.Terminal.PaletteKnown))
411+ for _, color := range th.Terminal.Palette {
412+ h = hashThemeColor(h, color)
413+ }
414+ for _, color := range []TerminalColor{
415+ th.Terminal.Foreground,
416+ th.Terminal.Background,
417+ th.Accent,
418+ th.Muted,
419+ th.Tool,
420+ th.Warning,
421+ th.Error,
422+ } {
423+ h = hashThemeColor(h, color)
424+ }
425+ for _, value := range []bool{th.Terminal.HasForeground, th.Terminal.HasBackground, th.Terminal.TrueColor, th.Terminal.Light, th.Terminal.SchemeKnown} {
426+ if value {
427+ h = fnv64aWriteByte(h, 1)
428+ } else {
429+ h = fnv64aWriteByte(h, 0)
430+ }
431+ }
432+ return strconv.FormatUint(h, 16)
433+}
434+
273 // extLang maps file extensions to chroma lexer names. Only435 // extLang maps file extensions to chroma lexer names. Only
274 // extensions that chroma supports are included.436 // extensions that chroma supports are included.
275 var extLang = map[string]string{437 var extLang = map[string]string{
A packages/tui/highlight_terminal_test.go +76 -0
@@ -0,0 +1,76 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestTerminalSyntaxPreservesPaletteSlots(t *testing.T) {
9+ profile := TerminalProfile{Depth: ColorDepthIndexed256}
10+ th := TerminalTheme(profile)
11+ got := strings.Join(th.HighlightCode("func main() { return 1 }", "go"), "\n")
12+ for _, sequence := range []string{"\x1b[94m", "\x1b[93m", "\x1b[39m"} {
13+ if !strings.Contains(got, sequence) {
14+ t.Fatalf("highlight output missing %q: %q", sequence, got)
15+ }
16+ }
17+ if strings.Contains(got, "\x1b[38;5;196m") {
18+ t.Fatalf("terminal formatter remapped ANSI slots through xterm cube: %q", got)
19+ }
20+}
21+
22+func TestTerminalSyntaxUsesReportedRGBPalette(t *testing.T) {
23+ profile := terminalTestProfile(ColorDepthTrueColor)
24+ th := TerminalTheme(profile)
25+ got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n")
26+ if !strings.Contains(got, "\x1b[38;2;40;100;240m") {
27+ t.Fatalf("highlight output did not use reported bright-blue palette: %q", got)
28+ }
29+}
30+
31+func TestTerminalSyntaxCacheIncludesSemanticRoles(t *testing.T) {
32+ profile := TerminalProfile{Depth: ColorDepthTrueColor}
33+ first := TerminalTheme(profile)
34+ first.Accent = ColorRGB(1, 2, 3)
35+ second := TerminalTheme(profile)
36+ second.Accent = ColorRGB(4, 5, 6)
37+
38+ const source = "func terminalSyntaxCacheSentinel() {}"
39+ if got := strings.Join(first.HighlightCode(source, "go"), "\n"); !strings.Contains(got, "\x1b[38;2;1;2;3m") {
40+ t.Fatalf("first output = %q, want first accent", got)
41+ }
42+ if got := strings.Join(second.HighlightCode(source, "go"), "\n"); !strings.Contains(got, "\x1b[38;2;4;5;6m") {
43+ t.Fatalf("second output reused first semantic color: %q", got)
44+ }
45+}
46+
47+func TestTerminalSyntaxHonorsCustomOverrides(t *testing.T) {
48+ th := TerminalTheme(TerminalProfile{Depth: ColorDepthTrueColor})
49+ th.Syntax.Keyword = "#112233 bold"
50+ got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n")
51+ if !strings.Contains(got, "\x1b[38;2;17;34;51m\x1b[1mfunc") {
52+ t.Fatalf("terminal formatter ignored custom keyword override: %q", got)
53+ }
54+}
55+
56+func TestTerminalSyntaxHonorsCustomBaseStyle(t *testing.T) {
57+ th := TerminalTheme(TerminalProfile{Depth: ColorDepthTrueColor})
58+ th.SyntaxBaseStyle = "monokai"
59+ got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n")
60+ if !strings.Contains(got, "\x1b[38;2;102;217;239m") {
61+ t.Fatalf("terminal formatter ignored custom syntax base style: %q", got)
62+ }
63+}
64+
65+func TestTerminalSyntaxBalancesMultilineTokens(t *testing.T) {
66+ th := TerminalTheme(TerminalProfile{Depth: ColorDepthIndexed256})
67+ lines := th.HighlightCode("/* first\nsecond */", "go")
68+ if len(lines) != 2 {
69+ t.Fatalf("line count = %d, want 2: %#v", len(lines), lines)
70+ }
71+ for _, line := range lines {
72+ if !strings.HasSuffix(line, reset) {
73+ t.Fatalf("line does not reset terminal style: %q", line)
74+ }
75+ }
76+}
@@ -0,0 +1,76 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestTerminalSyntaxPreservesPaletteSlots(t *testing.T) {
9+ profile := TerminalProfile{Depth: ColorDepthIndexed256}
10+ th := TerminalTheme(profile)
11+ got := strings.Join(th.HighlightCode("func main() { return 1 }", "go"), "\n")
12+ for _, sequence := range []string{"\x1b[94m", "\x1b[93m", "\x1b[39m"} {
13+ if !strings.Contains(got, sequence) {
14+ t.Fatalf("highlight output missing %q: %q", sequence, got)
15+ }
16+ }
17+ if strings.Contains(got, "\x1b[38;5;196m") {
18+ t.Fatalf("terminal formatter remapped ANSI slots through xterm cube: %q", got)
19+ }
20+}
21+
22+func TestTerminalSyntaxUsesReportedRGBPalette(t *testing.T) {
23+ profile := terminalTestProfile(ColorDepthTrueColor)
24+ th := TerminalTheme(profile)
25+ got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n")
26+ if !strings.Contains(got, "\x1b[38;2;40;100;240m") {
27+ t.Fatalf("highlight output did not use reported bright-blue palette: %q", got)
28+ }
29+}
30+
31+func TestTerminalSyntaxCacheIncludesSemanticRoles(t *testing.T) {
32+ profile := TerminalProfile{Depth: ColorDepthTrueColor}
33+ first := TerminalTheme(profile)
34+ first.Accent = ColorRGB(1, 2, 3)
35+ second := TerminalTheme(profile)
36+ second.Accent = ColorRGB(4, 5, 6)
37+
38+ const source = "func terminalSyntaxCacheSentinel() {}"
39+ if got := strings.Join(first.HighlightCode(source, "go"), "\n"); !strings.Contains(got, "\x1b[38;2;1;2;3m") {
40+ t.Fatalf("first output = %q, want first accent", got)
41+ }
42+ if got := strings.Join(second.HighlightCode(source, "go"), "\n"); !strings.Contains(got, "\x1b[38;2;4;5;6m") {
43+ t.Fatalf("second output reused first semantic color: %q", got)
44+ }
45+}
46+
47+func TestTerminalSyntaxHonorsCustomOverrides(t *testing.T) {
48+ th := TerminalTheme(TerminalProfile{Depth: ColorDepthTrueColor})
49+ th.Syntax.Keyword = "#112233 bold"
50+ got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n")
51+ if !strings.Contains(got, "\x1b[38;2;17;34;51m\x1b[1mfunc") {
52+ t.Fatalf("terminal formatter ignored custom keyword override: %q", got)
53+ }
54+}
55+
56+func TestTerminalSyntaxHonorsCustomBaseStyle(t *testing.T) {
57+ th := TerminalTheme(TerminalProfile{Depth: ColorDepthTrueColor})
58+ th.SyntaxBaseStyle = "monokai"
59+ got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n")
60+ if !strings.Contains(got, "\x1b[38;2;102;217;239m") {
61+ t.Fatalf("terminal formatter ignored custom syntax base style: %q", got)
62+ }
63+}
64+
65+func TestTerminalSyntaxBalancesMultilineTokens(t *testing.T) {
66+ th := TerminalTheme(TerminalProfile{Depth: ColorDepthIndexed256})
67+ lines := th.HighlightCode("/* first\nsecond */", "go")
68+ if len(lines) != 2 {
69+ t.Fatalf("line count = %d, want 2: %#v", len(lines), lines)
70+ }
71+ for _, line := range lines {
72+ if !strings.HasSuffix(line, reset) {
73+ t.Fatalf("line does not reset terminal style: %q", line)
74+ }
75+ }
76+}
M packages/tui/terminal.go +7 -1
@@ -5,6 +5,7 @@ import (
55 "os"
66 "path/filepath"
77 "strings"
8+ "sync"
89 "time"
910 "unicode"
1011
@@ -36,6 +37,7 @@ type ProcTerm struct {
3637 out *os.File
3738 in *os.File
3839 resizeCBs []func()
40+ writeMu sync.Mutex
3941 }
4042
4143 // NewProcTerm returns a Terminal bound to stdin/stdout.
@@ -43,7 +45,11 @@ func NewProcTerm() *ProcTerm {
4345 return &ProcTerm{out: os.Stdout, in: os.Stdin}
4446 }
4547
46-func (p *ProcTerm) Write(b []byte) (int, error) { return p.out.Write(b) }
48+func (p *ProcTerm) Write(b []byte) (int, error) {
49+ p.writeMu.Lock()
50+ defer p.writeMu.Unlock()
51+ return p.out.Write(b)
52+}
4753
4854 // WriteBell emits one standalone terminal alert character. Callers should
4955 // route it through the same output writer used for the rest of the terminal
@@ -5,6 +5,7 @@ import (
5 "os"5 "os"
6 "path/filepath"6 "path/filepath"
7 "strings"7 "strings"
8+ "sync"
8 "time"9 "time"
9 "unicode"10 "unicode"
10 11
@@ -36,6 +37,7 @@ type ProcTerm struct {
36 out *os.File37 out *os.File
37 in *os.File38 in *os.File
38 resizeCBs []func()39 resizeCBs []func()
40+ writeMu sync.Mutex
39 }41 }
40 42
41 // NewProcTerm returns a Terminal bound to stdin/stdout.43 // NewProcTerm returns a Terminal bound to stdin/stdout.
@@ -43,7 +45,11 @@ func NewProcTerm() *ProcTerm {
43 return &ProcTerm{out: os.Stdout, in: os.Stdin}45 return &ProcTerm{out: os.Stdout, in: os.Stdin}
44 }46 }
45 47
46-func (p *ProcTerm) Write(b []byte) (int, error) { return p.out.Write(b) }48+func (p *ProcTerm) Write(b []byte) (int, error) {
49+ p.writeMu.Lock()
50+ defer p.writeMu.Unlock()
51+ return p.out.Write(b)
52+}
47 53
48 // WriteBell emits one standalone terminal alert character. Callers should54 // WriteBell emits one standalone terminal alert character. Callers should
49 // route it through the same output writer used for the rest of the terminal55 // route it through the same output writer used for the rest of the terminal
M packages/tui/theme.go +107 -28
@@ -8,9 +8,10 @@ import (
88 "sync/atomic"
99 )
1010
11-// TerminalColor describes a theme colour in one of the colour spaces
12-// terminals commonly support. Every semantic theme colour uses this type;
13-// xterm-256, ANSI, and RGB values are rendered according to terminal capability.
11+// TerminalColor describes both explicit colors and terminal-owned colors.
12+// TerminalDefault and TerminalPaletteSlot intentionally differ from literal
13+// xterm-256 values: an adaptive theme follows the terminal palette while a
14+// custom numeric value retains its xterm meaning.
1415 type TerminalColor struct {
1516 Mode terminalColorMode
1617 Index int
@@ -22,20 +23,37 @@ type TerminalColor struct {
2223 type terminalColorMode int
2324
2425 const (
25- terminalColor256 terminalColorMode = iota
26+ terminalColorDefault terminalColorMode = iota
27+ terminalColorPalette
28+ terminalColor256
2629 terminalColorANSI
2730 terminalColorRGB
2831 )
2932
33+func TerminalDefault() TerminalColor { return TerminalColor{Mode: terminalColorDefault} }
34+func TerminalPaletteSlot(index int) TerminalColor {
35+ return TerminalColor{Mode: terminalColorPalette, Index: index}
36+}
3037 func Color256(index int) TerminalColor { return TerminalColor{Mode: terminalColor256, Index: index} }
3138 func ColorANSI(sgr int) TerminalColor { return TerminalColor{Mode: terminalColorANSI, Index: sgr} }
3239 func ColorRGB(r, g, b int) TerminalColor {
3340 return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b}
3441 }
3542
43+// ColorDepth is the terminal output capability used when resolving a color.
44+type ColorDepth uint8
45+
46+const (
47+ // Indexed256 is the zero value to preserve the legacy behavior for
48+ // profiles assembled by callers that predate ColorDepth.
49+ ColorDepthIndexed256 ColorDepth = iota
50+ ColorDepthANSI16
51+ ColorDepthTrueColor
52+)
53+
3654 // TerminalProfile is the best-effort color snapshot reported by the
37-// controlling terminal. The ANSI palette is optional; unknown slots are
38-// resolved through the classic xterm-256 palette.
55+// controlling terminal. Palette entries are optional; an unknown slot is
56+// emitted as its direct ANSI SGR rather than substituted with a fixed zut RGB.
3957 type TerminalProfile struct {
4058 Foreground TerminalColor
4159 Background TerminalColor
@@ -45,9 +63,22 @@ type TerminalProfile struct {
4563
4664 Palette [16]TerminalColor
4765 PaletteKnown uint16
48- TrueColor bool
49- Light bool
50- SchemeKnown bool
66+ Depth ColorDepth
67+ // TrueColor is retained for callers compiled against the older profile.
68+ // Depth is authoritative when it is ColorDepthTrueColor.
69+ TrueColor bool
70+ Light bool
71+ SchemeKnown bool
72+}
73+
74+func (p TerminalProfile) ColorDepth() ColorDepth {
75+ if p.Depth == ColorDepthTrueColor || p.TrueColor {
76+ return ColorDepthTrueColor
77+ }
78+ if p.Depth == ColorDepthANSI16 {
79+ return ColorDepthANSI16
80+ }
81+ return ColorDepthIndexed256
5182 }
5283
5384 // PaletteColor returns a reported ANSI palette entry when available.
@@ -78,12 +109,11 @@ type Theme struct {
78109 SelectionBG TerminalColor // background for highlighted rows
79110 SelectionFG TerminalColor // foreground for highlighted rows
80111
81- // Inherited selects terminal-owned colors. Terminal.TrueColor controls
82- // whether fixed indexed values are emitted as xterm-256 or truecolor and
83- // whether RGB values are quantized for terminals without direct-color
84- // support.
85- Inherited bool
86- Terminal TerminalProfile
112+ // UseTerminalPalette resolves TerminalPaletteSlot roles against the
113+ // reported palette on truecolor terminals. It has no effect on explicit
114+ // Color256 values from a custom theme.
115+ UseTerminalPalette bool
116+ Terminal TerminalProfile
87117
88118 SpinnerFrames []string
89119 SpinnerIntervalMS int
@@ -231,8 +261,7 @@ func (t Theme) bgPrefix(color TerminalColor) string {
231261 }
232262
233263 // DimColor fades a theme color toward the terminal background. It is used
234-// by inherited theme construction and is also available to callers that need
235-// terminal-aware dimming for overlays.
264+// while resolving adaptive terminal surfaces and by overlay callers.
236265 func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor {
237266 percent = clampPercent(percent)
238267 foreground := t.resolveColor(color)
@@ -250,31 +279,42 @@ func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor {
250279 }
251280
252281 func (t Theme) resolveColor(color TerminalColor) TerminalColor {
282+ depth := t.Terminal.ColorDepth()
253283 switch color.Mode {
284+ case terminalColorDefault:
285+ return color
286+ case terminalColorPalette:
287+ index := clampANSIIndex(color.Index)
288+ if depth == ColorDepthTrueColor {
289+ if palette, ok := t.Terminal.PaletteColor(index); ok {
290+ return palette
291+ }
292+ }
293+ return ColorANSI(ansiIndexForegroundSGR(index))
254294 case terminalColor256:
255295 index := clampXtermIndex(color.Index)
256- if t.Terminal.TrueColor {
257- if t.Inherited {
258- if palette, ok := t.Terminal.PaletteColor(index); ok {
259- return palette
260- }
261- }
296+ switch depth {
297+ case ColorDepthTrueColor:
262298 r, g, b := xterm256RGB(index)
263299 return ColorRGB(r, g, b)
300+ case ColorDepthANSI16:
301+ return ColorANSI(ansiIndexForegroundSGR(nearestANSIColor(index)))
302+ default:
303+ return Color256(index)
264304 }
265- return Color256(index)
266305 case terminalColorANSI:
267- if t.Inherited && t.Terminal.TrueColor {
306+ if depth == ColorDepthTrueColor {
268307 if index, ok := ansiSGRToXtermIndex(color.Index); ok {
269308 if palette, ok := t.Terminal.PaletteColor(index); ok {
270309 return palette
271310 }
272- r, g, b := xterm256RGB(index)
273- return ColorRGB(r, g, b)
274311 }
275312 }
276313 case terminalColorRGB:
277- if !t.Terminal.TrueColor {
314+ if depth == ColorDepthANSI16 {
315+ return ColorANSI(ansiIndexForegroundSGR(nearestANSIColor(nearestXtermColor(color.R, color.G, color.B))))
316+ }
317+ if depth != ColorDepthTrueColor {
278318 return Color256(nearestXtermColor(color.R, color.G, color.B))
279319 }
280320 }
@@ -283,6 +323,8 @@ func (t Theme) resolveColor(color TerminalColor) TerminalColor {
283323
284324 func sgrFGColor(color TerminalColor) string {
285325 switch color.Mode {
326+ case terminalColorDefault:
327+ return "\x1b[39m"
286328 case terminalColorANSI:
287329 return "\x1b[" + itoa(ansiForegroundSGR(color.Index)) + "m"
288330 case terminalColorRGB:
@@ -413,6 +455,8 @@ func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m"
413455 func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }
414456 func sgrBGColor(c TerminalColor) string {
415457 switch c.Mode {
458+ case terminalColorDefault:
459+ return "\x1b[49m"
416460 case terminalColorANSI:
417461 return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m"
418462 case terminalColorRGB:
@@ -422,6 +466,38 @@ func sgrBGColor(c TerminalColor) string {
422466 }
423467 }
424468
469+func clampANSIIndex(index int) int {
470+ if index < 0 {
471+ return 0
472+ }
473+ if index > 15 {
474+ return 15
475+ }
476+ return index
477+}
478+
479+func ansiIndexForegroundSGR(index int) int {
480+ index = clampANSIIndex(index)
481+ if index < 8 {
482+ return 30 + index
483+ }
484+ return 90 + index - 8
485+}
486+
487+func nearestANSIColor(index int) int {
488+ r, g, b := xterm256RGB(clampXtermIndex(index))
489+ best, bestDistance := 0, int(^uint(0)>>1)
490+ for slot := 0; slot < 16; slot++ {
491+ pr, pg, pb := xterm256RGB(slot)
492+ dr, dg, db := r-pr, g-pg, b-pb
493+ distance := dr*dr + dg*dg + db*db
494+ if distance < bestDistance {
495+ best, bestDistance = slot, distance
496+ }
497+ }
498+ return best
499+}
500+
425501 func clampXtermIndex(index int) int {
426502 if index < 0 {
427503 return 0
@@ -459,6 +535,9 @@ func rgbForTerminalColor(color TerminalColor) ([3]int, bool) {
459535 case terminalColor256:
460536 r, g, b := xterm256RGB(clampXtermIndex(color.Index))
461537 return [3]int{r, g, b}, true
538+ case terminalColorPalette:
539+ r, g, b := xterm256RGB(clampANSIIndex(color.Index))
540+ return [3]int{r, g, b}, true
462541 case terminalColorANSI:
463542 if index, ok := ansiSGRToXtermIndex(color.Index); ok {
464543 r, g, b := xterm256RGB(index)
@@ -8,9 +8,10 @@ import (
8 "sync/atomic"8 "sync/atomic"
9 )9 )
10 10
11-// TerminalColor describes a theme colour in one of the colour spaces11+// TerminalColor describes both explicit colors and terminal-owned colors.
12-// terminals commonly support. Every semantic theme colour uses this type;12+// TerminalDefault and TerminalPaletteSlot intentionally differ from literal
13-// xterm-256, ANSI, and RGB values are rendered according to terminal capability.13+// xterm-256 values: an adaptive theme follows the terminal palette while a
14+// custom numeric value retains its xterm meaning.
14 type TerminalColor struct {15 type TerminalColor struct {
15 Mode terminalColorMode16 Mode terminalColorMode
16 Index int17 Index int
@@ -22,20 +23,37 @@ type TerminalColor struct {
22 type terminalColorMode int23 type terminalColorMode int
23 24
24 const (25 const (
25- terminalColor256 terminalColorMode = iota26+ terminalColorDefault terminalColorMode = iota
27+ terminalColorPalette
28+ terminalColor256
26 terminalColorANSI29 terminalColorANSI
27 terminalColorRGB30 terminalColorRGB
28 )31 )
29 32
33+func TerminalDefault() TerminalColor { return TerminalColor{Mode: terminalColorDefault} }
34+func TerminalPaletteSlot(index int) TerminalColor {
35+ return TerminalColor{Mode: terminalColorPalette, Index: index}
36+}
30 func Color256(index int) TerminalColor { return TerminalColor{Mode: terminalColor256, Index: index} }37 func Color256(index int) TerminalColor { return TerminalColor{Mode: terminalColor256, Index: index} }
31 func ColorANSI(sgr int) TerminalColor { return TerminalColor{Mode: terminalColorANSI, Index: sgr} }38 func ColorANSI(sgr int) TerminalColor { return TerminalColor{Mode: terminalColorANSI, Index: sgr} }
32 func ColorRGB(r, g, b int) TerminalColor {39 func ColorRGB(r, g, b int) TerminalColor {
33 return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b}40 return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b}
34 }41 }
35 42
43+// ColorDepth is the terminal output capability used when resolving a color.
44+type ColorDepth uint8
45+
46+const (
47+ // Indexed256 is the zero value to preserve the legacy behavior for
48+ // profiles assembled by callers that predate ColorDepth.
49+ ColorDepthIndexed256 ColorDepth = iota
50+ ColorDepthANSI16
51+ ColorDepthTrueColor
52+)
53+
36 // TerminalProfile is the best-effort color snapshot reported by the54 // TerminalProfile is the best-effort color snapshot reported by the
37-// controlling terminal. The ANSI palette is optional; unknown slots are55+// controlling terminal. Palette entries are optional; an unknown slot is
38-// resolved through the classic xterm-256 palette.56+// emitted as its direct ANSI SGR rather than substituted with a fixed zut RGB.
39 type TerminalProfile struct {57 type TerminalProfile struct {
40 Foreground TerminalColor58 Foreground TerminalColor
41 Background TerminalColor59 Background TerminalColor
@@ -45,9 +63,22 @@ type TerminalProfile struct {
45 63
46 Palette [16]TerminalColor64 Palette [16]TerminalColor
47 PaletteKnown uint1665 PaletteKnown uint16
48- TrueColor bool66+ Depth ColorDepth
49- Light bool67+ // TrueColor is retained for callers compiled against the older profile.
50- SchemeKnown bool68+ // Depth is authoritative when it is ColorDepthTrueColor.
69+ TrueColor bool
70+ Light bool
71+ SchemeKnown bool
72+}
73+
74+func (p TerminalProfile) ColorDepth() ColorDepth {
75+ if p.Depth == ColorDepthTrueColor || p.TrueColor {
76+ return ColorDepthTrueColor
77+ }
78+ if p.Depth == ColorDepthANSI16 {
79+ return ColorDepthANSI16
80+ }
81+ return ColorDepthIndexed256
51 }82 }
52 83
53 // PaletteColor returns a reported ANSI palette entry when available.84 // PaletteColor returns a reported ANSI palette entry when available.
@@ -78,12 +109,11 @@ type Theme struct {
78 SelectionBG TerminalColor // background for highlighted rows109 SelectionBG TerminalColor // background for highlighted rows
79 SelectionFG TerminalColor // foreground for highlighted rows110 SelectionFG TerminalColor // foreground for highlighted rows
80 111
81- // Inherited selects terminal-owned colors. Terminal.TrueColor controls112+ // UseTerminalPalette resolves TerminalPaletteSlot roles against the
82- // whether fixed indexed values are emitted as xterm-256 or truecolor and113+ // reported palette on truecolor terminals. It has no effect on explicit
83- // whether RGB values are quantized for terminals without direct-color114+ // Color256 values from a custom theme.
84- // support.115+ UseTerminalPalette bool
85- Inherited bool116+ Terminal TerminalProfile
86- Terminal TerminalProfile
87 117
88 SpinnerFrames []string118 SpinnerFrames []string
89 SpinnerIntervalMS int119 SpinnerIntervalMS int
@@ -231,8 +261,7 @@ func (t Theme) bgPrefix(color TerminalColor) string {
231 }261 }
232 262
233 // DimColor fades a theme color toward the terminal background. It is used263 // DimColor fades a theme color toward the terminal background. It is used
234-// by inherited theme construction and is also available to callers that need264+// while resolving adaptive terminal surfaces and by overlay callers.
235-// terminal-aware dimming for overlays.
236 func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor {265 func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor {
237 percent = clampPercent(percent)266 percent = clampPercent(percent)
238 foreground := t.resolveColor(color)267 foreground := t.resolveColor(color)
@@ -250,31 +279,42 @@ func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor {
250 }279 }
251 280
252 func (t Theme) resolveColor(color TerminalColor) TerminalColor {281 func (t Theme) resolveColor(color TerminalColor) TerminalColor {
282+ depth := t.Terminal.ColorDepth()
253 switch color.Mode {283 switch color.Mode {
284+ case terminalColorDefault:
285+ return color
286+ case terminalColorPalette:
287+ index := clampANSIIndex(color.Index)
288+ if depth == ColorDepthTrueColor {
289+ if palette, ok := t.Terminal.PaletteColor(index); ok {
290+ return palette
291+ }
292+ }
293+ return ColorANSI(ansiIndexForegroundSGR(index))
254 case terminalColor256:294 case terminalColor256:
255 index := clampXtermIndex(color.Index)295 index := clampXtermIndex(color.Index)
256- if t.Terminal.TrueColor {296+ switch depth {
257- if t.Inherited {297+ case ColorDepthTrueColor:
258- if palette, ok := t.Terminal.PaletteColor(index); ok {
259- return palette
260- }
261- }
262 r, g, b := xterm256RGB(index)298 r, g, b := xterm256RGB(index)
263 return ColorRGB(r, g, b)299 return ColorRGB(r, g, b)
300+ case ColorDepthANSI16:
301+ return ColorANSI(ansiIndexForegroundSGR(nearestANSIColor(index)))
302+ default:
303+ return Color256(index)
264 }304 }
265- return Color256(index)
266 case terminalColorANSI:305 case terminalColorANSI:
267- if t.Inherited && t.Terminal.TrueColor {306+ if depth == ColorDepthTrueColor {
268 if index, ok := ansiSGRToXtermIndex(color.Index); ok {307 if index, ok := ansiSGRToXtermIndex(color.Index); ok {
269 if palette, ok := t.Terminal.PaletteColor(index); ok {308 if palette, ok := t.Terminal.PaletteColor(index); ok {
270 return palette309 return palette
271 }310 }
272- r, g, b := xterm256RGB(index)
273- return ColorRGB(r, g, b)
274 }311 }
275 }312 }
276 case terminalColorRGB:313 case terminalColorRGB:
277- if !t.Terminal.TrueColor {314+ if depth == ColorDepthANSI16 {
315+ return ColorANSI(ansiIndexForegroundSGR(nearestANSIColor(nearestXtermColor(color.R, color.G, color.B))))
316+ }
317+ if depth != ColorDepthTrueColor {
278 return Color256(nearestXtermColor(color.R, color.G, color.B))318 return Color256(nearestXtermColor(color.R, color.G, color.B))
279 }319 }
280 }320 }
@@ -283,6 +323,8 @@ func (t Theme) resolveColor(color TerminalColor) TerminalColor {
283 323
284 func sgrFGColor(color TerminalColor) string {324 func sgrFGColor(color TerminalColor) string {
285 switch color.Mode {325 switch color.Mode {
326+ case terminalColorDefault:
327+ return "\x1b[39m"
286 case terminalColorANSI:328 case terminalColorANSI:
287 return "\x1b[" + itoa(ansiForegroundSGR(color.Index)) + "m"329 return "\x1b[" + itoa(ansiForegroundSGR(color.Index)) + "m"
288 case terminalColorRGB:330 case terminalColorRGB:
@@ -413,6 +455,8 @@ func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m"
413 func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }455 func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }
414 func sgrBGColor(c TerminalColor) string {456 func sgrBGColor(c TerminalColor) string {
415 switch c.Mode {457 switch c.Mode {
458+ case terminalColorDefault:
459+ return "\x1b[49m"
416 case terminalColorANSI:460 case terminalColorANSI:
417 return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m"461 return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m"
418 case terminalColorRGB:462 case terminalColorRGB:
@@ -422,6 +466,38 @@ func sgrBGColor(c TerminalColor) string {
422 }466 }
423 }467 }
424 468
469+func clampANSIIndex(index int) int {
470+ if index < 0 {
471+ return 0
472+ }
473+ if index > 15 {
474+ return 15
475+ }
476+ return index
477+}
478+
479+func ansiIndexForegroundSGR(index int) int {
480+ index = clampANSIIndex(index)
481+ if index < 8 {
482+ return 30 + index
483+ }
484+ return 90 + index - 8
485+}
486+
487+func nearestANSIColor(index int) int {
488+ r, g, b := xterm256RGB(clampXtermIndex(index))
489+ best, bestDistance := 0, int(^uint(0)>>1)
490+ for slot := 0; slot < 16; slot++ {
491+ pr, pg, pb := xterm256RGB(slot)
492+ dr, dg, db := r-pr, g-pg, b-pb
493+ distance := dr*dr + dg*dg + db*db
494+ if distance < bestDistance {
495+ best, bestDistance = slot, distance
496+ }
497+ }
498+ return best
499+}
500+
425 func clampXtermIndex(index int) int {501 func clampXtermIndex(index int) int {
426 if index < 0 {502 if index < 0 {
427 return 0503 return 0
@@ -459,6 +535,9 @@ func rgbForTerminalColor(color TerminalColor) ([3]int, bool) {
459 case terminalColor256:535 case terminalColor256:
460 r, g, b := xterm256RGB(clampXtermIndex(color.Index))536 r, g, b := xterm256RGB(clampXtermIndex(color.Index))
461 return [3]int{r, g, b}, true537 return [3]int{r, g, b}, true
538+ case terminalColorPalette:
539+ r, g, b := xterm256RGB(clampANSIIndex(color.Index))
540+ return [3]int{r, g, b}, true
462 case terminalColorANSI:541 case terminalColorANSI:
463 if index, ok := ansiSGRToXtermIndex(color.Index); ok {542 if index, ok := ansiSGRToXtermIndex(color.Index); ok {
464 r, g, b := xterm256RGB(index)543 r, g, b := xterm256RGB(index)
M packages/tui/theme_inherited_test.go +48 -155
@@ -16,9 +16,7 @@ func TestDetectTrueColor(t *testing.T) {
1616 {name: "COLORTERM truecolor", term: "screen", colorTerm: "truecolor", want: true},
1717 {name: "COLORTERM 24bit", term: "screen", colorTerm: "24bit", want: true},
1818 {name: "direct TERM", term: "xterm-direct", want: true},
19- {name: "direct TERM suffix", term: "screen-direct", want: true},
20- {name: "256 color TERM", term: "xterm-256color", colorTerm: "", want: false},
21- {name: "unknown TERM", term: "dumb", colorTerm: "", want: false},
19+ {name: "256 color TERM", term: "xterm-256color", want: false},
2220 }
2321 for _, tt := range tests {
2422 t.Run(tt.name, func(t *testing.T) {
@@ -29,187 +27,82 @@ func TestDetectTrueColor(t *testing.T) {
2927 }
3028 }
3129
32-func TestParseOSCColorResponse(t *testing.T) {
33- tests := []struct {
34- name string
35- raw string
36- kind int
37- slot int
38- want TerminalColor
30+func TestDetectColorDepth(t *testing.T) {
31+ for _, tt := range []struct {
32+ term, colorTerm string
33+ want ColorDepth
3934 }{
40- {
41- name: "foreground hash",
42- raw: "\x1b]10;#123456\x07",
43- kind: 10,
44- want: ColorRGB(0x12, 0x34, 0x56),
45- },
46- {
47- name: "background rgb",
48- raw: "\x1b]11;rgb:ffff/8000/0000\x1b\\",
49- kind: 11,
50- want: ColorRGB(255, 127, 0),
51- },
52- {
53- name: "palette slot",
54- raw: "\x1b]4;12;rgb:12/34/56\x07",
55- kind: 4,
56- slot: 12,
57- want: ColorRGB(0x12, 0x34, 0x56),
58- },
59- }
60- for _, tt := range tests {
61- t.Run(tt.name, func(t *testing.T) {
62- got, ok := parseOSCColorResponse([]byte(tt.raw))
63- if !ok {
64- t.Fatal("parseOSCColorResponse returned !ok")
65- }
66- if got.kind != tt.kind || got.slot != tt.slot || got.color != tt.want {
67- t.Fatalf("response = %#v, want kind=%d slot=%d color=%#v", got, tt.kind, tt.slot, tt.want)
68- }
69- })
70- }
71-}
72-
73-func TestANSIThemeColorsAdaptToTargetSGR(t *testing.T) {
74- if got := Dark.FGColor(ColorANSI(100), "x"); got != "\x1b[90mx\x1b[0m" {
75- t.Fatalf("ANSI foreground = %q, want bright-black foreground", got)
76- }
77- if got := Dark.BG(ColorANSI(31), "x"); got != "\x1b[41mx\x1b[0m" {
78- t.Fatalf("ANSI background = %q, want red background", got)
79- }
80-}
81-
82-func TestNearestXtermColorCacheIsStable(t *testing.T) {
83- colors := [][3]int{
84- {0, 0, 0},
85- {66, 69, 75},
86- {200, 100, 50},
87- {-20, 300, 128},
88- }
89- for _, color := range colors {
90- first := nearestXtermColor(color[0], color[1], color[2])
91- second := nearestXtermColor(color[0], color[1], color[2])
92- if first != second {
93- t.Fatalf("nearest color changed for %#v: first=%d second=%d", color, first, second)
94- }
95- if first < 0 || first > 255 {
96- t.Fatalf("nearest color out of range for %#v: %d", color, first)
35+ {"xterm", "", ColorDepthANSI16},
36+ {"xterm-256color", "", ColorDepthIndexed256},
37+ {"screen", "truecolor", ColorDepthTrueColor},
38+ } {
39+ if got := DetectColorDepth(tt.term, tt.colorTerm); got != tt.want {
40+ t.Fatalf("DetectColorDepth(%q, %q) = %v, want %v", tt.term, tt.colorTerm, got, tt.want)
9741 }
9842 }
9943 }
10044
101-func inheritedTestTheme(trueColor bool) Theme {
102- detected := Dark
103- detected.Terminal = TerminalProfile{
45+func terminalTestProfile(depth ColorDepth) TerminalProfile {
46+ profile := TerminalProfile{
10447 Foreground: ColorRGB(220, 210, 200),
10548 Background: ColorRGB(12, 18, 24),
10649 HasForeground: true,
10750 HasBackground: true,
108- TrueColor: trueColor,
51+ Depth: depth,
10952 }
110- detected.Terminal.Palette[10] = ColorRGB(20, 220, 80)
111- detected.Terminal.Palette[12] = ColorRGB(40, 100, 240)
112- detected.Terminal.Palette[13] = ColorRGB(220, 70, 210)
113- detected.Terminal.PaletteKnown = 1<<10 | 1<<12 | 1<<13
114- return InheritedTheme(detected)
53+ profile.Palette[10] = ColorRGB(20, 220, 80)
54+ profile.Palette[12] = ColorRGB(40, 100, 240)
55+ profile.Palette[13] = ColorRGB(220, 70, 210)
56+ profile.PaletteKnown = 1<<10 | 1<<12 | 1<<13
57+ return profile
11558 }
11659
117-func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) {
118- th := inheritedTestTheme(true)
119- if !th.Inherited || !th.Terminal.TrueColor {
120- t.Fatalf("inherited theme mode = (inherited=%v, trueColor=%v), want truecolor", th.Inherited, th.Terminal.TrueColor)
121- }
122- if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") {
123- t.Fatalf("accent did not use the reported terminal palette: %q", got)
124- }
125- if got := th.FGColor(th.FG, "x"); !strings.Contains(got, "\x1b[38;2;220;210;200m") {
126- t.Fatalf("foreground did not inherit the terminal default: %q", got)
60+func TestTerminalThemeUsesTerminalDefaultsAndPaletteSlots(t *testing.T) {
61+ th := TerminalTheme(terminalTestProfile(ColorDepthTrueColor))
62+ if th.Background != nil {
63+ t.Fatal("terminal theme painted a full-row background")
12764 }
128- if got := th.UserBubble("x", 3); !strings.Contains(got, "\x1b[48;2;") {
129- t.Fatalf("user bubble did not use truecolor background: %q", got)
65+ if th.FG != TerminalDefault() || th.Accent != TerminalPaletteSlot(12) {
66+ t.Fatalf("terminal roles = fg %#v accent %#v", th.FG, th.Accent)
13067 }
131- if got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n"); !strings.Contains(got, "\x1b[38;2;") {
132- t.Fatalf("syntax highlighting did not use truecolor: %q", got)
68+ if got := th.FGColor(th.FG, "x"); got != "\x1b[39mx\x1b[0m" {
69+ t.Fatalf("default foreground = %q", got)
13370 }
134- if got := th.DimColor(th.FG, 45); got.Mode != terminalColorRGB {
135- t.Fatalf("DimColor mode = %v, want RGB", got.Mode)
136- }
137-}
138-
139-func TestInheritedThemeFallsBackTo256Colors(t *testing.T) {
140- th := inheritedTestTheme(false)
141- if !th.Inherited || th.Terminal.TrueColor {
142- t.Fatalf("inherited theme mode = (inherited=%v, trueColor=%v), want 256", th.Inherited, th.Terminal.TrueColor)
143- }
144- if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;5;12m") || strings.Contains(got, "\x1b[38;2;") {
145- t.Fatalf("accent did not use 256-color fallback: %q", got)
146- }
147- if got := th.UserBubble("x", 3); strings.Contains(got, "\x1b[48;2;") || !strings.Contains(got, "\x1b[48;5;") {
148- t.Fatalf("user bubble did not quantize its background: %q", got)
149- }
150- if got := th.DimColor(th.FG, 45); got.Mode != terminalColor256 {
151- t.Fatalf("DimColor mode = %v, want 256", got.Mode)
152- }
153- if got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n"); strings.Contains(got, "\x1b[38;2;") {
154- t.Fatalf("syntax highlighting unexpectedly used truecolor: %q", got)
71+ if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") {
72+ t.Fatalf("accent did not use reported palette: %q", got)
15573 }
15674 }
15775
158-func TestExplicitThemesFollowTerminalCapability(t *testing.T) {
159- if got, want := Dark.FGColor(Dark.Accent, "x"), "\x1b[38;5;111mx\x1b[0m"; got != want {
160- t.Fatalf("explicit theme output = %q, want %q", got, want)
161- }
162- if got := Dark.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;5;") || strings.Contains(got, "\x1b[48;2;") {
163- t.Fatalf("non-truecolor RGB bubble did not quantize: %q", got)
164- }
165-
166- trueColor := withTerminalProfile(Dark, Theme{Terminal: TerminalProfile{TrueColor: true}})
167- if got := trueColor.FGColor(trueColor.Accent, "x"); !strings.Contains(got, "\x1b[38;2;") {
168- t.Fatalf("truecolor explicit theme did not emit RGB: %q", got)
76+func TestTerminalThemeUnknownProfileUsesANSI(t *testing.T) {
77+ th := TerminalTheme(TerminalProfile{Depth: ColorDepthANSI16})
78+ if th.FG == Dark.FG || th.FG == Light.FG {
79+ t.Fatal("unknown auto profile selected a fixed built-in theme")
16980 }
170- if got := trueColor.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;2;66;69;75m") {
171- t.Fatalf("truecolor explicit RGB bubble did not emit RGB: %q", got)
81+ if got := th.FGColor(th.Accent, "x"); got != "\x1b[94mx\x1b[0m" {
82+ t.Fatalf("terminal palette fallback = %q", got)
17283 }
17384 }
17485
175-func TestAvailableThemesIncludesInherited(t *testing.T) {
176- options := AvailableThemes(t.TempDir())
177- for _, option := range options {
178- if option.Value == "inherited" {
179- if option.Label != "inherited (from terminal)" || !option.Builtin {
180- t.Fatalf("unexpected inherited option: %#v", option)
181- }
182- return
183- }
86+func TestTerminalPaletteSlotsDoNotAliasExplicitIndexedColors(t *testing.T) {
87+ profile := terminalTestProfile(ColorDepthTrueColor)
88+ th := TerminalTheme(profile)
89+ if got := th.FGColor(TerminalPaletteSlot(12), "x"); !strings.Contains(got, "40;100;240") {
90+ t.Fatalf("terminal slot = %q", got)
18491 }
185- t.Fatal("available themes did not include inherited")
186-}
187-
188-func TestIsLightThemePreservesExplicitDarkTheme(t *testing.T) {
189- dark := Dark
190- dark.Terminal.SchemeKnown = true
191- dark.Terminal.Light = true
192- if IsLightTheme(dark) {
193- t.Fatal("explicit dark theme was reclassified from the terminal scheme")
194- }
195-
196- inherited := InheritedTheme(dark)
197- if !IsLightTheme(inherited) {
198- t.Fatal("inherited light terminal theme was not recognized as light")
92+ if got := th.FGColor(Color256(12), "x"); !strings.Contains(got, "0;0;255") {
93+ t.Fatalf("explicit xterm index adopted terminal palette: %q", got)
19994 }
20095 }
20196
202-func TestRendererDetectsInheritedSelectionBackground(t *testing.T) {
97+func TestTerminalThemeRendererInvalidationDoesNotClearScrollback(t *testing.T) {
98+ t.Setenv("TERM_PROGRAM", "")
20399 var buf bytes.Buffer
204100 r := NewRenderer(&buf)
205101 r.Resize(20, 2)
206- r.SetTheme(inheritedTestTheme(true))
207- selected := r.theme.SelectionStyle() + "selected" + reset
208- r.Draw([]string{"", selected}, -1, -1)
209102 buf.Reset()
210-
211- r.Draw([]string{"", "plain"}, -1, -1)
212- if !strings.Contains(buf.String(), MoveTo(1, 1)) {
213- t.Fatalf("selection repaint did not include unchanged rows: %q", buf.String())
103+ r.SetTheme(TerminalTheme(terminalTestProfile(ColorDepthTrueColor)))
104+ r.Invalidate()
105+ if strings.Contains(buf.String(), SeqClearScrollback) {
106+ t.Fatalf("theme invalidation cleared scrollback: %q", buf.String())
214107 }
215108 }
@@ -16,9 +16,7 @@ func TestDetectTrueColor(t *testing.T) {
16 {name: "COLORTERM truecolor", term: "screen", colorTerm: "truecolor", want: true},16 {name: "COLORTERM truecolor", term: "screen", colorTerm: "truecolor", want: true},
17 {name: "COLORTERM 24bit", term: "screen", colorTerm: "24bit", want: true},17 {name: "COLORTERM 24bit", term: "screen", colorTerm: "24bit", want: true},
18 {name: "direct TERM", term: "xterm-direct", want: true},18 {name: "direct TERM", term: "xterm-direct", want: true},
19- {name: "direct TERM suffix", term: "screen-direct", want: true},19+ {name: "256 color TERM", term: "xterm-256color", want: false},
20- {name: "256 color TERM", term: "xterm-256color", colorTerm: "", want: false},
21- {name: "unknown TERM", term: "dumb", colorTerm: "", want: false},
22 }20 }
23 for _, tt := range tests {21 for _, tt := range tests {
24 t.Run(tt.name, func(t *testing.T) {22 t.Run(tt.name, func(t *testing.T) {
@@ -29,187 +27,82 @@ func TestDetectTrueColor(t *testing.T) {
29 }27 }
30 }28 }
31 29
32-func TestParseOSCColorResponse(t *testing.T) {30+func TestDetectColorDepth(t *testing.T) {
33- tests := []struct {31+ for _, tt := range []struct {
34- name string32+ term, colorTerm string
35- raw string33+ want ColorDepth
36- kind int
37- slot int
38- want TerminalColor
39 }{34 }{
40- {35+ {"xterm", "", ColorDepthANSI16},
41- name: "foreground hash",36+ {"xterm-256color", "", ColorDepthIndexed256},
42- raw: "\x1b]10;#123456\x07",37+ {"screen", "truecolor", ColorDepthTrueColor},
43- kind: 10,38+ } {
44- want: ColorRGB(0x12, 0x34, 0x56),39+ if got := DetectColorDepth(tt.term, tt.colorTerm); got != tt.want {
45- },40+ t.Fatalf("DetectColorDepth(%q, %q) = %v, want %v", tt.term, tt.colorTerm, got, tt.want)
46- {
47- name: "background rgb",
48- raw: "\x1b]11;rgb:ffff/8000/0000\x1b\\",
49- kind: 11,
50- want: ColorRGB(255, 127, 0),
51- },
52- {
53- name: "palette slot",
54- raw: "\x1b]4;12;rgb:12/34/56\x07",
55- kind: 4,
56- slot: 12,
57- want: ColorRGB(0x12, 0x34, 0x56),
58- },
59- }
60- for _, tt := range tests {
61- t.Run(tt.name, func(t *testing.T) {
62- got, ok := parseOSCColorResponse([]byte(tt.raw))
63- if !ok {
64- t.Fatal("parseOSCColorResponse returned !ok")
65- }
66- if got.kind != tt.kind || got.slot != tt.slot || got.color != tt.want {
67- t.Fatalf("response = %#v, want kind=%d slot=%d color=%#v", got, tt.kind, tt.slot, tt.want)
68- }
69- })
70- }
71-}
72-
73-func TestANSIThemeColorsAdaptToTargetSGR(t *testing.T) {
74- if got := Dark.FGColor(ColorANSI(100), "x"); got != "\x1b[90mx\x1b[0m" {
75- t.Fatalf("ANSI foreground = %q, want bright-black foreground", got)
76- }
77- if got := Dark.BG(ColorANSI(31), "x"); got != "\x1b[41mx\x1b[0m" {
78- t.Fatalf("ANSI background = %q, want red background", got)
79- }
80-}
81-
82-func TestNearestXtermColorCacheIsStable(t *testing.T) {
83- colors := [][3]int{
84- {0, 0, 0},
85- {66, 69, 75},
86- {200, 100, 50},
87- {-20, 300, 128},
88- }
89- for _, color := range colors {
90- first := nearestXtermColor(color[0], color[1], color[2])
91- second := nearestXtermColor(color[0], color[1], color[2])
92- if first != second {
93- t.Fatalf("nearest color changed for %#v: first=%d second=%d", color, first, second)
94- }
95- if first < 0 || first > 255 {
96- t.Fatalf("nearest color out of range for %#v: %d", color, first)
97 }41 }
98 }42 }
99 }43 }
100 44
101-func inheritedTestTheme(trueColor bool) Theme {45+func terminalTestProfile(depth ColorDepth) TerminalProfile {
102- detected := Dark46+ profile := TerminalProfile{
103- detected.Terminal = TerminalProfile{
104 Foreground: ColorRGB(220, 210, 200),47 Foreground: ColorRGB(220, 210, 200),
105 Background: ColorRGB(12, 18, 24),48 Background: ColorRGB(12, 18, 24),
106 HasForeground: true,49 HasForeground: true,
107 HasBackground: true,50 HasBackground: true,
108- TrueColor: trueColor,51+ Depth: depth,
109 }52 }
110- detected.Terminal.Palette[10] = ColorRGB(20, 220, 80)53+ profile.Palette[10] = ColorRGB(20, 220, 80)
111- detected.Terminal.Palette[12] = ColorRGB(40, 100, 240)54+ profile.Palette[12] = ColorRGB(40, 100, 240)
112- detected.Terminal.Palette[13] = ColorRGB(220, 70, 210)55+ profile.Palette[13] = ColorRGB(220, 70, 210)
113- detected.Terminal.PaletteKnown = 1<<10 | 1<<12 | 1<<1356+ profile.PaletteKnown = 1<<10 | 1<<12 | 1<<13
114- return InheritedTheme(detected)57+ return profile
115 }58 }
116 59
117-func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) {60+func TestTerminalThemeUsesTerminalDefaultsAndPaletteSlots(t *testing.T) {
118- th := inheritedTestTheme(true)61+ th := TerminalTheme(terminalTestProfile(ColorDepthTrueColor))
119- if !th.Inherited || !th.Terminal.TrueColor {62+ if th.Background != nil {
120- t.Fatalf("inherited theme mode = (inherited=%v, trueColor=%v), want truecolor", th.Inherited, th.Terminal.TrueColor)63+ t.Fatal("terminal theme painted a full-row background")
121- }
122- if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") {
123- t.Fatalf("accent did not use the reported terminal palette: %q", got)
124- }
125- if got := th.FGColor(th.FG, "x"); !strings.Contains(got, "\x1b[38;2;220;210;200m") {
126- t.Fatalf("foreground did not inherit the terminal default: %q", got)
127 }64 }
128- if got := th.UserBubble("x", 3); !strings.Contains(got, "\x1b[48;2;") {65+ if th.FG != TerminalDefault() || th.Accent != TerminalPaletteSlot(12) {
129- t.Fatalf("user bubble did not use truecolor background: %q", got)66+ t.Fatalf("terminal roles = fg %#v accent %#v", th.FG, th.Accent)
130 }67 }
131- if got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n"); !strings.Contains(got, "\x1b[38;2;") {68+ if got := th.FGColor(th.FG, "x"); got != "\x1b[39mx\x1b[0m" {
132- t.Fatalf("syntax highlighting did not use truecolor: %q", got)69+ t.Fatalf("default foreground = %q", got)
133 }70 }
134- if got := th.DimColor(th.FG, 45); got.Mode != terminalColorRGB {71+ if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") {
135- t.Fatalf("DimColor mode = %v, want RGB", got.Mode)72+ t.Fatalf("accent did not use reported palette: %q", got)
136- }
137-}
138-
139-func TestInheritedThemeFallsBackTo256Colors(t *testing.T) {
140- th := inheritedTestTheme(false)
141- if !th.Inherited || th.Terminal.TrueColor {
142- t.Fatalf("inherited theme mode = (inherited=%v, trueColor=%v), want 256", th.Inherited, th.Terminal.TrueColor)
143- }
144- if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;5;12m") || strings.Contains(got, "\x1b[38;2;") {
145- t.Fatalf("accent did not use 256-color fallback: %q", got)
146- }
147- if got := th.UserBubble("x", 3); strings.Contains(got, "\x1b[48;2;") || !strings.Contains(got, "\x1b[48;5;") {
148- t.Fatalf("user bubble did not quantize its background: %q", got)
149- }
150- if got := th.DimColor(th.FG, 45); got.Mode != terminalColor256 {
151- t.Fatalf("DimColor mode = %v, want 256", got.Mode)
152- }
153- if got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n"); strings.Contains(got, "\x1b[38;2;") {
154- t.Fatalf("syntax highlighting unexpectedly used truecolor: %q", got)
155 }73 }
156 }74 }
157 75
158-func TestExplicitThemesFollowTerminalCapability(t *testing.T) {76+func TestTerminalThemeUnknownProfileUsesANSI(t *testing.T) {
159- if got, want := Dark.FGColor(Dark.Accent, "x"), "\x1b[38;5;111mx\x1b[0m"; got != want {77+ th := TerminalTheme(TerminalProfile{Depth: ColorDepthANSI16})
160- t.Fatalf("explicit theme output = %q, want %q", got, want)78+ if th.FG == Dark.FG || th.FG == Light.FG {
161- }79+ t.Fatal("unknown auto profile selected a fixed built-in theme")
162- if got := Dark.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;5;") || strings.Contains(got, "\x1b[48;2;") {
163- t.Fatalf("non-truecolor RGB bubble did not quantize: %q", got)
164- }
165-
166- trueColor := withTerminalProfile(Dark, Theme{Terminal: TerminalProfile{TrueColor: true}})
167- if got := trueColor.FGColor(trueColor.Accent, "x"); !strings.Contains(got, "\x1b[38;2;") {
168- t.Fatalf("truecolor explicit theme did not emit RGB: %q", got)
169 }80 }
170- if got := trueColor.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;2;66;69;75m") {81+ if got := th.FGColor(th.Accent, "x"); got != "\x1b[94mx\x1b[0m" {
171- t.Fatalf("truecolor explicit RGB bubble did not emit RGB: %q", got)82+ t.Fatalf("terminal palette fallback = %q", got)
172 }83 }
173 }84 }
174 85
175-func TestAvailableThemesIncludesInherited(t *testing.T) {86+func TestTerminalPaletteSlotsDoNotAliasExplicitIndexedColors(t *testing.T) {
176- options := AvailableThemes(t.TempDir())87+ profile := terminalTestProfile(ColorDepthTrueColor)
177- for _, option := range options {88+ th := TerminalTheme(profile)
178- if option.Value == "inherited" {89+ if got := th.FGColor(TerminalPaletteSlot(12), "x"); !strings.Contains(got, "40;100;240") {
179- if option.Label != "inherited (from terminal)" || !option.Builtin {90+ t.Fatalf("terminal slot = %q", got)
180- t.Fatalf("unexpected inherited option: %#v", option)
181- }
182- return
183- }
184 }91 }
185- t.Fatal("available themes did not include inherited")92+ if got := th.FGColor(Color256(12), "x"); !strings.Contains(got, "0;0;255") {
186-}93+ t.Fatalf("explicit xterm index adopted terminal palette: %q", got)
187-
188-func TestIsLightThemePreservesExplicitDarkTheme(t *testing.T) {
189- dark := Dark
190- dark.Terminal.SchemeKnown = true
191- dark.Terminal.Light = true
192- if IsLightTheme(dark) {
193- t.Fatal("explicit dark theme was reclassified from the terminal scheme")
194- }
195-
196- inherited := InheritedTheme(dark)
197- if !IsLightTheme(inherited) {
198- t.Fatal("inherited light terminal theme was not recognized as light")
199 }94 }
200 }95 }
201 96
202-func TestRendererDetectsInheritedSelectionBackground(t *testing.T) {97+func TestTerminalThemeRendererInvalidationDoesNotClearScrollback(t *testing.T) {
98+ t.Setenv("TERM_PROGRAM", "")
203 var buf bytes.Buffer99 var buf bytes.Buffer
204 r := NewRenderer(&buf)100 r := NewRenderer(&buf)
205 r.Resize(20, 2)101 r.Resize(20, 2)
206- r.SetTheme(inheritedTestTheme(true))
207- selected := r.theme.SelectionStyle() + "selected" + reset
208- r.Draw([]string{"", selected}, -1, -1)
209 buf.Reset()102 buf.Reset()
210-103+ r.SetTheme(TerminalTheme(terminalTestProfile(ColorDepthTrueColor)))
211- r.Draw([]string{"", "plain"}, -1, -1)104+ r.Invalidate()
212- if !strings.Contains(buf.String(), MoveTo(1, 1)) {105+ if strings.Contains(buf.String(), SeqClearScrollback) {
213- t.Fatalf("selection repaint did not include unchanged rows: %q", buf.String())106+ t.Fatalf("theme invalidation cleared scrollback: %q", buf.String())
214 }107 }
215 }108 }
M packages/tui/theme_loader.go +240 -134
@@ -1,6 +1,7 @@
11 package tui
22
33 import (
4+ "crypto/sha256"
45 "encoding/json"
56 "errors"
67 "fmt"
@@ -9,6 +10,9 @@ import (
910 "sort"
1011 "strings"
1112 "time"
13+
14+ "github.com/alecthomas/chroma/v2"
15+ "github.com/alecthomas/chroma/v2/styles"
1216 )
1317
1418 // ThemeOption is one selectable theme discovered under $ZUT_HOME/themes.
@@ -41,9 +45,11 @@ func (tf *ThemeFile) UnmarshalJSON(data []byte) error {
4145 // {"spinner_frames":[".","o"],"spinner_interval_ms":120}.
4246 // Metadata fields are ignored by ThemeOverrides because they do not
4347 // have matching json tags.
44- _ = json.Unmarshal(data, &a.Overrides)
48+ if err := json.Unmarshal(data, &a.Overrides); err != nil {
49+ return fmt.Errorf("theme overrides: %w", err)
50+ }
4551 *tf = ThemeFile(a)
46- return nil
52+ return validateThemeFile(*tf)
4753 }
4854
4955 type ThemeFileColorModes struct {
@@ -61,16 +67,22 @@ func (m *ThemeFileColorModes) UnmarshalJSON(data []byte) error {
6167 }
6268 if v, ok := raw["dark"]; ok {
6369 m.HasDark = true
64- _ = json.Unmarshal(v, &m.Dark)
70+ if err := json.Unmarshal(v, &m.Dark); err != nil {
71+ return fmt.Errorf("dark overrides: %w", err)
72+ }
6573 }
6674 if v, ok := raw["light"]; ok {
6775 m.HasLight = true
68- _ = json.Unmarshal(v, &m.Light)
76+ if err := json.Unmarshal(v, &m.Light); err != nil {
77+ return fmt.Errorf("light overrides: %w", err)
78+ }
6979 }
7080 // Also allow colors to directly contain overrides shared by both
7181 // modes, e.g. {"colors":{"accent":204}}.
7282 var base ThemeOverrides
73- _ = json.Unmarshal(data, &base)
83+ if err := json.Unmarshal(data, &base); err != nil {
84+ return fmt.Errorf("shared overrides: %w", err)
85+ }
7486 m.Base = base
7587 return nil
7688 }
@@ -138,13 +150,13 @@ func (c *TerminalColorValue) UnmarshalJSON(data []byte) error {
138150 var index int
139151 if err := json.Unmarshal(data, &index); err == nil {
140152 c.TerminalColor = Color256(index)
141- return nil
153+ return validateExplicitTerminalColor(c.TerminalColor)
142154 }
143155 var s string
144156 if err := json.Unmarshal(data, &s); err == nil {
145157 if rgb, ok := parseHexColor(s); ok {
146158 c.TerminalColor = rgb
147- return nil
159+ return validateExplicitTerminalColor(c.TerminalColor)
148160 }
149161 return fmt.Errorf("invalid terminal color %q", s)
150162 }
@@ -168,58 +180,45 @@ func (c *TerminalColorValue) UnmarshalJSON(data []byte) error {
168180 default:
169181 return fmt.Errorf("unknown terminal color mode %q", obj.Mode)
170182 }
171- return nil
183+ return validateExplicitTerminalColor(c.TerminalColor)
172184 }
173185
174-// InheritedTheme returns a theme derived from the terminal snapshot carried
175-// by detected. It prefers the terminal's reported ANSI palette for semantic
176-// accents, derives muted and selected surfaces by fading toward the terminal
177-// background, and uses truecolor output only when the terminal advertises it.
178-func InheritedTheme(detected Theme) Theme {
179- base := Dark
180- if detected.Terminal.SchemeKnown {
181- if detected.Terminal.Light {
182- base = Light
183- }
184- } else if IsLightTheme(detected) {
185- base = Light
186- }
187- t := withTerminalProfile(base, detected)
188- t.Inherited = true
189-
190- if t.Terminal.HasForeground {
191- t.FG = t.Terminal.Foreground
192- }
193-
194- accentFallback := t.Accent
195- if IsLightTheme(t) {
196- accentFallback = Light.Accent
197- }
198- t.Accent = terminalPaletteColor(t.Terminal, accentFallback,
199- ansiBrightBlue, ansiBlue, ansiBrightCyan, ansiCyan, ansiBrightMagenta, ansiMagenta)
200- t.Assistant = t.Accent
201- t.Tool = terminalPaletteColor(t.Terminal, t.Tool, ansiBrightGreen, ansiGreen)
202- t.Error = terminalPaletteColor(t.Terminal, t.Error, ansiBrightRed, ansiRed)
203- t.Warning = terminalPaletteColor(t.Terminal, t.Warning, ansiBrightYellow, ansiYellow)
204- t.Spinner = terminalPaletteColor(t.Terminal, t.Spinner, ansiBrightMagenta, ansiMagenta)
205- t.ThinkingMax = terminalPaletteColor(t.Terminal, t.ThinkingMax, ansiBrightMagenta, ansiMagenta)
206- t.User = terminalPaletteColor(t.Terminal, t.User, ansiBrightYellow, ansiYellow)
207-
208- if t.Terminal.HasForeground && t.Terminal.HasBackground {
209- t.Muted = t.DimColor(t.FG, 45)
186+// TerminalTheme constructs zut's adaptive default from terminal-owned
187+// defaults and ANSI slots. It never chooses a fixed built-in palette merely
188+// because a terminal reports a light background.
189+func TerminalTheme(profile TerminalProfile) Theme {
190+ t := Theme{
191+ FG: TerminalDefault(),
192+ Muted: TerminalPaletteSlot(8),
193+ Accent: TerminalPaletteSlot(12),
194+ User: TerminalPaletteSlot(13),
195+ UserBubbleBG: TerminalPaletteSlot(8),
196+ UserBubbleFG: TerminalDefault(),
197+ Assistant: TerminalPaletteSlot(12),
198+ Tool: TerminalPaletteSlot(10),
199+ ToolOut: TerminalPaletteSlot(8),
200+ Error: TerminalPaletteSlot(9),
201+ Warning: TerminalPaletteSlot(11),
202+ Spinner: TerminalPaletteSlot(13),
203+ ThinkingMax: TerminalPaletteSlot(13),
204+ SelectionBG: TerminalPaletteSlot(4),
205+ SelectionFG: TerminalDefault(),
206+ UseTerminalPalette: true,
207+ Terminal: profile,
208+ SpinnerFrames: append([]string(nil), defaultSpinnerFrames...),
209+ SpinnerIntervalMS: 80,
210+ // Terminal syntax is resolved directly from semantic terminal roles.
211+ // Keep JSON syntax fields empty until a custom theme explicitly sets one.
212+ Syntax: SyntaxTheme{},
213+ }
214+ if profile.HasForeground && profile.HasBackground {
215+ t.Muted = t.DimColor(profile.Foreground, 45)
210216 t.ToolOut = t.Muted
211- background := t.Terminal.Background
212- foreground := t.Terminal.Foreground
213- t.UserBubbleBG = blendTerminalColors(background, foreground, 12)
214- t.SelectionBG = blendTerminalColors(background, foreground, 25)
217+ t.UserBubbleBG = blendTerminalColors(profile.Background, profile.Foreground, 12)
218+ t.SelectionBG = blendTerminalColors(profile.Background, profile.Foreground, 25)
215219 }
216-
217- t.UserBubbleFG = t.FG
218- t.SelectionFG = t.FG
219-
220- // Keep the terminal's configured background untouched. Painting an
221- // inherited RGB background across every row would make terminal theme
222- // changes and scrollback behavior less predictable.
220+ // Auto must leave both the terminal background and cursor color owned by
221+ // the terminal. Custom themes can intentionally override Background.
223222 t.Background = nil
224223 return t
225224 }
@@ -229,35 +228,6 @@ func withTerminalProfile(base, detected Theme) Theme {
229228 return base
230229 }
231230
232-// Standard ANSI palette slots used as inherited role preferences.
233-const (
234- ansiRed = 1
235- ansiGreen = 2
236- ansiYellow = 3
237- ansiBlue = 4
238- ansiMagenta = 5
239- ansiCyan = 6
240- ansiBrightRed = 9
241- ansiBrightGreen = 10
242- ansiBrightYellow = 11
243- ansiBrightBlue = 12
244- ansiBrightMagenta = 13
245- ansiBrightCyan = 14
246-)
247-
248-func terminalPaletteColor(profile TerminalProfile, fallback TerminalColor, preferred ...int) TerminalColor {
249- for _, index := range preferred {
250- if _, ok := profile.PaletteColor(index); ok {
251- // Keep the palette slot as the semantic value. Truecolor
252- // resolution expands it back to the terminal-reported RGB;
253- // 256-color output preserves the slot rather than re-quantizing
254- // the reported RGB to a nearby cube entry.
255- return Color256(index)
256- }
257- }
258- return fallback
259-}
260-
261231 func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
262232 fromRGB, _ := rgbForTerminalColor(from)
263233 toRGB, _ := rgbForTerminalColor(to)
@@ -269,67 +239,150 @@ func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
269239 )
270240 }
271241
272-// LoadThemeFromHome applies a custom theme from $ZUT_HOME/themes/*.json
273-// to detected. Empty/auto/default keeps the built-in detected theme.
274-// If preferred is set, it may be a theme name, a basename without
275-// .json, or an absolute/relative path.
276-func DetectThemeWithCustom(zutHome, preferred string, timeout time.Duration) (Theme, string, error) {
277- detected := DetectThemeFromBackground(timeout)
278- return LoadThemeFromHome(zutHome, preferred, detected)
242+const maxThemeFileSize = 1 << 20
243+
244+// ThemeSource is an immutable, fully validated custom theme revision. Runtime
245+// profile changes resolve this value without touching the filesystem again.
246+type ThemeSource struct {
247+ Name string
248+ Path string
249+ Digest [sha256.Size]byte
250+ File ThemeFile
279251 }
280252
281-func LoadThemeFromHome(zutHome, preferred string, detected Theme) (Theme, string, error) {
282- path, err := resolveThemePath(zutHome, preferred)
283- if err != nil || path == "" {
284- return detected, "", err
253+// ThemeResolution is the pure result of resolving a preference against one
254+// terminal snapshot and, for custom preferences, one accepted source file.
255+type ThemeResolution struct {
256+ Theme Theme
257+ Name string
258+}
259+
260+// ThemePreference separates the persisted setting from the process-only
261+// ZUT_THEME override. Only dark/light force a running session.
262+type ThemePreference struct {
263+ Persisted string
264+ Effective string
265+ Forced bool
266+}
267+
268+func ResolveThemePreference(persisted, env string) ThemePreference {
269+ preference := ThemePreference{Persisted: strings.TrimSpace(persisted), Effective: strings.TrimSpace(persisted)}
270+ switch strings.ToLower(strings.TrimSpace(env)) {
271+ case "dark", "light":
272+ preference.Effective = strings.ToLower(strings.TrimSpace(env))
273+ preference.Forced = true
274+ case "", "auto":
275+ // Persisted setting remains effective.
276+ }
277+ if preference.Effective == "" {
278+ preference.Effective = "auto"
279+ }
280+ return preference
281+}
282+
283+// LoadThemeSource resolves and validates a custom source. Built-in selections
284+// have no source and return nil, nil. The file limit prevents a polling reload
285+// from allocating an unbounded partial write.
286+func LoadThemeSource(zutHome, preference string) (*ThemeSource, error) {
287+ path, err := resolveThemePath(zutHome, preference)
288+ if err != nil || path == "" || strings.HasPrefix(path, "builtin:") {
289+ return nil, err
285290 }
286- switch path {
287- case "builtin:inherited":
288- return InheritedTheme(detected), "inherited", nil
289- case "builtin:dark":
290- return withTerminalProfile(Dark, detected), "dark", nil
291- case "builtin:light":
292- return withTerminalProfile(Light, detected), "light", nil
291+ info, err := os.Stat(path)
292+ if err != nil {
293+ return nil, fmt.Errorf("theme %s: %w", path, err)
294+ }
295+ if info.Size() > maxThemeFileSize {
296+ return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize)
293297 }
294298 b, err := os.ReadFile(path)
295299 if err != nil {
296- return detected, "", err
300+ return nil, fmt.Errorf("read theme %s: %w", path, err)
297301 }
298- var tf ThemeFile
299- if err := json.Unmarshal(b, &tf); err != nil {
300- return detected, "", fmt.Errorf("parse theme %s: %w", path, err)
301- }
302- if tf.Name == "" {
303- tf.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
304- }
305- base := applyThemeOverrides(detected, tf.Overrides)
306- base = applyThemeOverrides(base, tf.Colors.Base)
307- if isLightTheme(detected) {
308- if tf.Colors.HasLight {
309- base = applyThemeOverrides(base, tf.Colors.Light)
310- } else if tf.Colors.HasDark {
311- base = applyThemeOverrides(base, tf.Colors.Dark)
312- }
313- } else {
314- if tf.Colors.HasDark {
315- base = applyThemeOverrides(base, tf.Colors.Dark)
316- } else if tf.Colors.HasLight {
317- base = applyThemeOverrides(base, tf.Colors.Light)
302+ if len(b) > maxThemeFileSize {
303+ return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize)
304+ }
305+ var file ThemeFile
306+ if err := json.Unmarshal(b, &file); err != nil {
307+ return nil, fmt.Errorf("parse theme %s: %w", path, err)
308+ }
309+ if file.Name == "" {
310+ file.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
311+ }
312+ return &ThemeSource{Name: file.Name, Path: path, Digest: sha256.Sum256(b), File: file}, nil
313+}
314+
315+// ResolveTheme performs no I/O. Custom themes always overlay TerminalTheme,
316+// so omitted roles continue to follow the controlling terminal.
317+func ResolveTheme(preference string, source *ThemeSource, profile TerminalProfile) ThemeResolution {
318+ preference = strings.ToLower(strings.TrimSpace(preference))
319+ switch preference {
320+ case "", "auto", "default", "system":
321+ return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"}
322+ case "dark":
323+ return ThemeResolution{Theme: withTerminalProfile(Dark, Theme{Terminal: profile}), Name: "dark"}
324+ case "light":
325+ return ThemeResolution{Theme: withTerminalProfile(Light, Theme{Terminal: profile}), Name: "light"}
326+ }
327+ if source == nil {
328+ return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"}
329+ }
330+ base := TerminalTheme(profile)
331+ base = applyThemeOverrides(base, source.File.Overrides)
332+ base = applyThemeOverrides(base, source.File.Colors.Base)
333+ if profileIsLight(profile) {
334+ if source.File.Colors.HasLight {
335+ base = applyThemeOverrides(base, source.File.Colors.Light)
336+ } else if source.File.Colors.HasDark {
337+ base = applyThemeOverrides(base, source.File.Colors.Dark)
318338 }
339+ } else if source.File.Colors.HasDark {
340+ base = applyThemeOverrides(base, source.File.Colors.Dark)
341+ } else if source.File.Colors.HasLight {
342+ base = applyThemeOverrides(base, source.File.Colors.Light)
343+ }
344+ return ThemeResolution{Theme: base, Name: source.Name}
345+}
346+
347+func profileIsLight(profile TerminalProfile) bool {
348+ if profile.SchemeKnown {
349+ return profile.Light
350+ }
351+ if profile.HasBackground {
352+ r, g, b := profile.Background.R, profile.Background.G, profile.Background.B
353+ return 0.2126*float64(r)+0.7152*float64(g)+0.0722*float64(b) >= 127.5
354+ }
355+ return false
356+}
357+
358+// DetectThemeWithCustom performs the initial bounded profile query then uses
359+// the same loader and pure resolver as runtime selection.
360+func DetectThemeWithCustom(zutHome, preferred string, timeout time.Duration) (Theme, string, error) {
361+ detected := DetectThemeFromBackground(timeout)
362+ return LoadThemeFromHome(zutHome, preferred, detected)
363+}
364+
365+// LoadThemeFromHome is retained as the startup convenience wrapper. New
366+// runtime code should keep the ThemeSource and call ResolveTheme directly.
367+func LoadThemeFromHome(zutHome, preferred string, detected Theme) (Theme, string, error) {
368+ profile := detected.Terminal
369+ source, err := LoadThemeSource(zutHome, preferred)
370+ if err != nil {
371+ return TerminalTheme(profile), "", err
319372 }
320- return base, tf.Name, nil
373+ returned := ResolveTheme(preferred, source, profile)
374+ return returned.Theme, returned.Name, nil
321375 }
322376
323377 // AvailableThemes returns built-in and user-installed themes suitable
324378 // for a settings picker. Invalid JSON files are skipped.
325379 func AvailableThemes(zutHome string) []ThemeOption {
326380 out := []ThemeOption{
327- {Value: "auto", Label: "auto", Description: "detect terminal background and use zut defaults", Builtin: true},
328- {Value: "inherited", Label: "inherited (from terminal)", Description: "use terminal colors with truecolor or best-effort 256-color output", Builtin: true},
381+ {Value: "auto", Label: "auto", Description: "follow terminal colors and appearance", Builtin: true},
329382 {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true},
330383 {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},
331384 }
332- seen := map[string]bool{"auto": true, "inherited": true, "dark": true, "light": true}
385+ seen := map[string]bool{"auto": true, "dark": true, "light": true}
333386 paths, _ := themeFilesIn(filepath.Join(zutHome, "themes"))
334387 sort.Strings(paths)
335388 for _, path := range paths {
@@ -399,8 +452,6 @@ func resolveThemePath(zutHome, preferred string) (string, error) {
399452 switch strings.ToLower(preferred) {
400453 case "", "auto", "default", "system":
401454 return "", nil
402- case "inherited", "terminal", "terminal-inherited":
403- return "builtin:inherited", nil
404455 case "dark":
405456 return "builtin:dark", nil
406457 case "light":
@@ -569,14 +620,69 @@ func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme {
569620 return s
570621 }
571622
572-func IsLightTheme(th Theme) bool {
573- if th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG {
574- return true
623+func validateExplicitTerminalColor(color TerminalColor) error {
624+ switch color.Mode {
625+ case terminalColor256:
626+ if color.Index < 0 || color.Index > 255 {
627+ return fmt.Errorf("xterm-256 index %d is outside 0..255", color.Index)
628+ }
629+ case terminalColorANSI:
630+ if _, ok := ansiSGRToXtermIndex(color.Index); !ok {
631+ return fmt.Errorf("ANSI SGR %d is not a palette color", color.Index)
632+ }
633+ case terminalColorRGB:
634+ if color.R < 0 || color.R > 255 || color.G < 0 || color.G > 255 || color.B < 0 || color.B > 255 {
635+ return fmt.Errorf("RGB color (%d,%d,%d) is outside 0..255", color.R, color.G, color.B)
636+ }
637+ default:
638+ return errors.New("theme colors must be explicit xterm-256, ANSI, or RGB values")
639+ }
640+ return nil
641+}
642+
643+func validateThemeFile(file ThemeFile) error {
644+ for _, overrides := range []ThemeOverrides{file.Overrides, file.Colors.Base, file.Colors.Dark, file.Colors.Light} {
645+ if err := validateThemeOverrides(overrides); err != nil {
646+ return err
647+ }
575648 }
576- return th.Inherited && th.Terminal.SchemeKnown && th.Terminal.Light
649+ return nil
577650 }
578651
579-func isLightTheme(th Theme) bool { return IsLightTheme(th) }
652+func validateThemeOverrides(o ThemeOverrides) error {
653+ for _, color := range []*TerminalColorValue{o.FG, o.Muted, o.Accent, o.Background, o.User, o.UserBubbleBG, o.UserBubbleFG, o.Assistant, o.Tool, o.ToolOut, o.Error, o.Warning, o.Spinner, o.ThinkingMax, o.ThinkingMaxCamel, o.SelectionBG, o.SelectionFG} {
654+ if color != nil {
655+ if err := validateExplicitTerminalColor(color.TerminalColor); err != nil {
656+ return err
657+ }
658+ }
659+ }
660+ if len(o.SpinnerFrames) > 64 {
661+ return errors.New("spinner_frames has more than 64 entries")
662+ }
663+ for _, frame := range o.SpinnerFrames {
664+ if frame == "" || len([]rune(frame)) > 16 {
665+ return fmt.Errorf("invalid spinner frame %q", frame)
666+ }
667+ }
668+ if o.SpinnerIntervalMS != nil && (*o.SpinnerIntervalMS < 10 || *o.SpinnerIntervalMS > 10000) {
669+ return fmt.Errorf("spinner_interval_ms %d is outside 10..10000", *o.SpinnerIntervalMS)
670+ }
671+ if o.SyntaxBaseStyle != nil && *o.SyntaxBaseStyle != "" {
672+ if _, ok := styles.Registry[*o.SyntaxBaseStyle]; !ok {
673+ return fmt.Errorf("unknown syntax_base_style %q", *o.SyntaxBaseStyle)
674+ }
675+ }
676+ for _, entry := range []*string{o.Syntax.Keyword, o.Syntax.KeywordConstant, o.Syntax.KeywordDeclaration, o.Syntax.KeywordNamespace, o.Syntax.KeywordReserved, o.Syntax.KeywordType, o.Syntax.NameBuiltin, o.Syntax.NameFunction, o.Syntax.NameClass, o.Syntax.NameDecorator, o.Syntax.LiteralString, o.Syntax.LiteralStringEscape, o.Syntax.LiteralNumber, o.Syntax.Comment, o.Syntax.CommentPreproc, o.Syntax.Operator, o.Syntax.Punctuation, o.Syntax.Text} {
677+ if entry == nil {
678+ continue
679+ }
680+ if _, err := chroma.ParseStyleEntry(*entry); err != nil {
681+ return fmt.Errorf("invalid syntax style %q: %w", *entry, err)
682+ }
683+ }
684+ return nil
685+}
580686
581687 func parseHexColor(s string) (TerminalColor, bool) {
582688 s = strings.TrimPrefix(strings.TrimSpace(s), "#")
@@ -1,6 +1,7 @@
1 package tui1 package tui
2 2
3 import (3 import (
4+ "crypto/sha256"
4 "encoding/json"5 "encoding/json"
5 "errors"6 "errors"
6 "fmt"7 "fmt"
@@ -9,6 +10,9 @@ import (
9 "sort"10 "sort"
10 "strings"11 "strings"
11 "time"12 "time"
13+
14+ "github.com/alecthomas/chroma/v2"
15+ "github.com/alecthomas/chroma/v2/styles"
12 )16 )
13 17
14 // ThemeOption is one selectable theme discovered under $ZUT_HOME/themes.18 // ThemeOption is one selectable theme discovered under $ZUT_HOME/themes.
@@ -41,9 +45,11 @@ func (tf *ThemeFile) UnmarshalJSON(data []byte) error {
41 // {"spinner_frames":[".","o"],"spinner_interval_ms":120}.45 // {"spinner_frames":[".","o"],"spinner_interval_ms":120}.
42 // Metadata fields are ignored by ThemeOverrides because they do not46 // Metadata fields are ignored by ThemeOverrides because they do not
43 // have matching json tags.47 // have matching json tags.
44- _ = json.Unmarshal(data, &a.Overrides)48+ if err := json.Unmarshal(data, &a.Overrides); err != nil {
49+ return fmt.Errorf("theme overrides: %w", err)
50+ }
45 *tf = ThemeFile(a)51 *tf = ThemeFile(a)
46- return nil52+ return validateThemeFile(*tf)
47 }53 }
48 54
49 type ThemeFileColorModes struct {55 type ThemeFileColorModes struct {
@@ -61,16 +67,22 @@ func (m *ThemeFileColorModes) UnmarshalJSON(data []byte) error {
61 }67 }
62 if v, ok := raw["dark"]; ok {68 if v, ok := raw["dark"]; ok {
63 m.HasDark = true69 m.HasDark = true
64- _ = json.Unmarshal(v, &m.Dark)70+ if err := json.Unmarshal(v, &m.Dark); err != nil {
71+ return fmt.Errorf("dark overrides: %w", err)
72+ }
65 }73 }
66 if v, ok := raw["light"]; ok {74 if v, ok := raw["light"]; ok {
67 m.HasLight = true75 m.HasLight = true
68- _ = json.Unmarshal(v, &m.Light)76+ if err := json.Unmarshal(v, &m.Light); err != nil {
77+ return fmt.Errorf("light overrides: %w", err)
78+ }
69 }79 }
70 // Also allow colors to directly contain overrides shared by both80 // Also allow colors to directly contain overrides shared by both
71 // modes, e.g. {"colors":{"accent":204}}.81 // modes, e.g. {"colors":{"accent":204}}.
72 var base ThemeOverrides82 var base ThemeOverrides
73- _ = json.Unmarshal(data, &base)83+ if err := json.Unmarshal(data, &base); err != nil {
84+ return fmt.Errorf("shared overrides: %w", err)
85+ }
74 m.Base = base86 m.Base = base
75 return nil87 return nil
76 }88 }
@@ -138,13 +150,13 @@ func (c *TerminalColorValue) UnmarshalJSON(data []byte) error {
138 var index int150 var index int
139 if err := json.Unmarshal(data, &index); err == nil {151 if err := json.Unmarshal(data, &index); err == nil {
140 c.TerminalColor = Color256(index)152 c.TerminalColor = Color256(index)
141- return nil153+ return validateExplicitTerminalColor(c.TerminalColor)
142 }154 }
143 var s string155 var s string
144 if err := json.Unmarshal(data, &s); err == nil {156 if err := json.Unmarshal(data, &s); err == nil {
145 if rgb, ok := parseHexColor(s); ok {157 if rgb, ok := parseHexColor(s); ok {
146 c.TerminalColor = rgb158 c.TerminalColor = rgb
147- return nil159+ return validateExplicitTerminalColor(c.TerminalColor)
148 }160 }
149 return fmt.Errorf("invalid terminal color %q", s)161 return fmt.Errorf("invalid terminal color %q", s)
150 }162 }
@@ -168,58 +180,45 @@ func (c *TerminalColorValue) UnmarshalJSON(data []byte) error {
168 default:180 default:
169 return fmt.Errorf("unknown terminal color mode %q", obj.Mode)181 return fmt.Errorf("unknown terminal color mode %q", obj.Mode)
170 }182 }
171- return nil183+ return validateExplicitTerminalColor(c.TerminalColor)
172 }184 }
173 185
174-// InheritedTheme returns a theme derived from the terminal snapshot carried186+// TerminalTheme constructs zut's adaptive default from terminal-owned
175-// by detected. It prefers the terminal's reported ANSI palette for semantic187+// defaults and ANSI slots. It never chooses a fixed built-in palette merely
176-// accents, derives muted and selected surfaces by fading toward the terminal188+// because a terminal reports a light background.
177-// background, and uses truecolor output only when the terminal advertises it.189+func TerminalTheme(profile TerminalProfile) Theme {
178-func InheritedTheme(detected Theme) Theme {190+ t := Theme{
179- base := Dark191+ FG: TerminalDefault(),
180- if detected.Terminal.SchemeKnown {192+ Muted: TerminalPaletteSlot(8),
181- if detected.Terminal.Light {193+ Accent: TerminalPaletteSlot(12),
182- base = Light194+ User: TerminalPaletteSlot(13),
183- }195+ UserBubbleBG: TerminalPaletteSlot(8),
184- } else if IsLightTheme(detected) {196+ UserBubbleFG: TerminalDefault(),
185- base = Light197+ Assistant: TerminalPaletteSlot(12),
186- }198+ Tool: TerminalPaletteSlot(10),
187- t := withTerminalProfile(base, detected)199+ ToolOut: TerminalPaletteSlot(8),
188- t.Inherited = true200+ Error: TerminalPaletteSlot(9),
189-201+ Warning: TerminalPaletteSlot(11),
190- if t.Terminal.HasForeground {202+ Spinner: TerminalPaletteSlot(13),
191- t.FG = t.Terminal.Foreground203+ ThinkingMax: TerminalPaletteSlot(13),
192- }204+ SelectionBG: TerminalPaletteSlot(4),
193-205+ SelectionFG: TerminalDefault(),
194- accentFallback := t.Accent206+ UseTerminalPalette: true,
195- if IsLightTheme(t) {207+ Terminal: profile,
196- accentFallback = Light.Accent208+ SpinnerFrames: append([]string(nil), defaultSpinnerFrames...),
197- }209+ SpinnerIntervalMS: 80,
198- t.Accent = terminalPaletteColor(t.Terminal, accentFallback,210+ // Terminal syntax is resolved directly from semantic terminal roles.
199- ansiBrightBlue, ansiBlue, ansiBrightCyan, ansiCyan, ansiBrightMagenta, ansiMagenta)211+ // Keep JSON syntax fields empty until a custom theme explicitly sets one.
200- t.Assistant = t.Accent212+ Syntax: SyntaxTheme{},
201- t.Tool = terminalPaletteColor(t.Terminal, t.Tool, ansiBrightGreen, ansiGreen)213+ }
202- t.Error = terminalPaletteColor(t.Terminal, t.Error, ansiBrightRed, ansiRed)214+ if profile.HasForeground && profile.HasBackground {
203- t.Warning = terminalPaletteColor(t.Terminal, t.Warning, ansiBrightYellow, ansiYellow)215+ t.Muted = t.DimColor(profile.Foreground, 45)
204- t.Spinner = terminalPaletteColor(t.Terminal, t.Spinner, ansiBrightMagenta, ansiMagenta)
205- t.ThinkingMax = terminalPaletteColor(t.Terminal, t.ThinkingMax, ansiBrightMagenta, ansiMagenta)
206- t.User = terminalPaletteColor(t.Terminal, t.User, ansiBrightYellow, ansiYellow)
207-
208- if t.Terminal.HasForeground && t.Terminal.HasBackground {
209- t.Muted = t.DimColor(t.FG, 45)
210 t.ToolOut = t.Muted216 t.ToolOut = t.Muted
211- background := t.Terminal.Background217+ t.UserBubbleBG = blendTerminalColors(profile.Background, profile.Foreground, 12)
212- foreground := t.Terminal.Foreground218+ t.SelectionBG = blendTerminalColors(profile.Background, profile.Foreground, 25)
213- t.UserBubbleBG = blendTerminalColors(background, foreground, 12)
214- t.SelectionBG = blendTerminalColors(background, foreground, 25)
215 }219 }
216-220+ // Auto must leave both the terminal background and cursor color owned by
217- t.UserBubbleFG = t.FG221+ // the terminal. Custom themes can intentionally override Background.
218- t.SelectionFG = t.FG
219-
220- // Keep the terminal's configured background untouched. Painting an
221- // inherited RGB background across every row would make terminal theme
222- // changes and scrollback behavior less predictable.
223 t.Background = nil222 t.Background = nil
224 return t223 return t
225 }224 }
@@ -229,35 +228,6 @@ func withTerminalProfile(base, detected Theme) Theme {
229 return base228 return base
230 }229 }
231 230
232-// Standard ANSI palette slots used as inherited role preferences.
233-const (
234- ansiRed = 1
235- ansiGreen = 2
236- ansiYellow = 3
237- ansiBlue = 4
238- ansiMagenta = 5
239- ansiCyan = 6
240- ansiBrightRed = 9
241- ansiBrightGreen = 10
242- ansiBrightYellow = 11
243- ansiBrightBlue = 12
244- ansiBrightMagenta = 13
245- ansiBrightCyan = 14
246-)
247-
248-func terminalPaletteColor(profile TerminalProfile, fallback TerminalColor, preferred ...int) TerminalColor {
249- for _, index := range preferred {
250- if _, ok := profile.PaletteColor(index); ok {
251- // Keep the palette slot as the semantic value. Truecolor
252- // resolution expands it back to the terminal-reported RGB;
253- // 256-color output preserves the slot rather than re-quantizing
254- // the reported RGB to a nearby cube entry.
255- return Color256(index)
256- }
257- }
258- return fallback
259-}
260-
261 func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {231 func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
262 fromRGB, _ := rgbForTerminalColor(from)232 fromRGB, _ := rgbForTerminalColor(from)
263 toRGB, _ := rgbForTerminalColor(to)233 toRGB, _ := rgbForTerminalColor(to)
@@ -269,67 +239,150 @@ func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
269 )239 )
270 }240 }
271 241
272-// LoadThemeFromHome applies a custom theme from $ZUT_HOME/themes/*.json242+const maxThemeFileSize = 1 << 20
273-// to detected. Empty/auto/default keeps the built-in detected theme.243+
274-// If preferred is set, it may be a theme name, a basename without244+// ThemeSource is an immutable, fully validated custom theme revision. Runtime
275-// .json, or an absolute/relative path.245+// profile changes resolve this value without touching the filesystem again.
276-func DetectThemeWithCustom(zutHome, preferred string, timeout time.Duration) (Theme, string, error) {246+type ThemeSource struct {
277- detected := DetectThemeFromBackground(timeout)247+ Name string
278- return LoadThemeFromHome(zutHome, preferred, detected)248+ Path string
249+ Digest [sha256.Size]byte
250+ File ThemeFile
279 }251 }
280 252
281-func LoadThemeFromHome(zutHome, preferred string, detected Theme) (Theme, string, error) {253+// ThemeResolution is the pure result of resolving a preference against one
282- path, err := resolveThemePath(zutHome, preferred)254+// terminal snapshot and, for custom preferences, one accepted source file.
283- if err != nil || path == "" {255+type ThemeResolution struct {
284- return detected, "", err256+ Theme Theme
257+ Name string
258+}
259+
260+// ThemePreference separates the persisted setting from the process-only
261+// ZUT_THEME override. Only dark/light force a running session.
262+type ThemePreference struct {
263+ Persisted string
264+ Effective string
265+ Forced bool
266+}
267+
268+func ResolveThemePreference(persisted, env string) ThemePreference {
269+ preference := ThemePreference{Persisted: strings.TrimSpace(persisted), Effective: strings.TrimSpace(persisted)}
270+ switch strings.ToLower(strings.TrimSpace(env)) {
271+ case "dark", "light":
272+ preference.Effective = strings.ToLower(strings.TrimSpace(env))
273+ preference.Forced = true
274+ case "", "auto":
275+ // Persisted setting remains effective.
276+ }
277+ if preference.Effective == "" {
278+ preference.Effective = "auto"
279+ }
280+ return preference
281+}
282+
283+// LoadThemeSource resolves and validates a custom source. Built-in selections
284+// have no source and return nil, nil. The file limit prevents a polling reload
285+// from allocating an unbounded partial write.
286+func LoadThemeSource(zutHome, preference string) (*ThemeSource, error) {
287+ path, err := resolveThemePath(zutHome, preference)
288+ if err != nil || path == "" || strings.HasPrefix(path, "builtin:") {
289+ return nil, err
285 }290 }
286- switch path {291+ info, err := os.Stat(path)
287- case "builtin:inherited":292+ if err != nil {
288- return InheritedTheme(detected), "inherited", nil293+ return nil, fmt.Errorf("theme %s: %w", path, err)
289- case "builtin:dark":294+ }
290- return withTerminalProfile(Dark, detected), "dark", nil295+ if info.Size() > maxThemeFileSize {
291- case "builtin:light":296+ return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize)
292- return withTerminalProfile(Light, detected), "light", nil
293 }297 }
294 b, err := os.ReadFile(path)298 b, err := os.ReadFile(path)
295 if err != nil {299 if err != nil {
296- return detected, "", err300+ return nil, fmt.Errorf("read theme %s: %w", path, err)
297 }301 }
298- var tf ThemeFile302+ if len(b) > maxThemeFileSize {
299- if err := json.Unmarshal(b, &tf); err != nil {303+ return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize)
300- return detected, "", fmt.Errorf("parse theme %s: %w", path, err)304+ }
301- }305+ var file ThemeFile
302- if tf.Name == "" {306+ if err := json.Unmarshal(b, &file); err != nil {
303- tf.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))307+ return nil, fmt.Errorf("parse theme %s: %w", path, err)
304- }308+ }
305- base := applyThemeOverrides(detected, tf.Overrides)309+ if file.Name == "" {
306- base = applyThemeOverrides(base, tf.Colors.Base)310+ file.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
307- if isLightTheme(detected) {311+ }
308- if tf.Colors.HasLight {312+ return &ThemeSource{Name: file.Name, Path: path, Digest: sha256.Sum256(b), File: file}, nil
309- base = applyThemeOverrides(base, tf.Colors.Light)313+}
310- } else if tf.Colors.HasDark {314+
311- base = applyThemeOverrides(base, tf.Colors.Dark)315+// ResolveTheme performs no I/O. Custom themes always overlay TerminalTheme,
312- }316+// so omitted roles continue to follow the controlling terminal.
313- } else {317+func ResolveTheme(preference string, source *ThemeSource, profile TerminalProfile) ThemeResolution {
314- if tf.Colors.HasDark {318+ preference = strings.ToLower(strings.TrimSpace(preference))
315- base = applyThemeOverrides(base, tf.Colors.Dark)319+ switch preference {
316- } else if tf.Colors.HasLight {320+ case "", "auto", "default", "system":
317- base = applyThemeOverrides(base, tf.Colors.Light)321+ return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"}
322+ case "dark":
323+ return ThemeResolution{Theme: withTerminalProfile(Dark, Theme{Terminal: profile}), Name: "dark"}
324+ case "light":
325+ return ThemeResolution{Theme: withTerminalProfile(Light, Theme{Terminal: profile}), Name: "light"}
326+ }
327+ if source == nil {
328+ return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"}
329+ }
330+ base := TerminalTheme(profile)
331+ base = applyThemeOverrides(base, source.File.Overrides)
332+ base = applyThemeOverrides(base, source.File.Colors.Base)
333+ if profileIsLight(profile) {
334+ if source.File.Colors.HasLight {
335+ base = applyThemeOverrides(base, source.File.Colors.Light)
336+ } else if source.File.Colors.HasDark {
337+ base = applyThemeOverrides(base, source.File.Colors.Dark)
318 }338 }
339+ } else if source.File.Colors.HasDark {
340+ base = applyThemeOverrides(base, source.File.Colors.Dark)
341+ } else if source.File.Colors.HasLight {
342+ base = applyThemeOverrides(base, source.File.Colors.Light)
343+ }
344+ return ThemeResolution{Theme: base, Name: source.Name}
345+}
346+
347+func profileIsLight(profile TerminalProfile) bool {
348+ if profile.SchemeKnown {
349+ return profile.Light
350+ }
351+ if profile.HasBackground {
352+ r, g, b := profile.Background.R, profile.Background.G, profile.Background.B
353+ return 0.2126*float64(r)+0.7152*float64(g)+0.0722*float64(b) >= 127.5
354+ }
355+ return false
356+}
357+
358+// DetectThemeWithCustom performs the initial bounded profile query then uses
359+// the same loader and pure resolver as runtime selection.
360+func DetectThemeWithCustom(zutHome, preferred string, timeout time.Duration) (Theme, string, error) {
361+ detected := DetectThemeFromBackground(timeout)
362+ return LoadThemeFromHome(zutHome, preferred, detected)
363+}
364+
365+// LoadThemeFromHome is retained as the startup convenience wrapper. New
366+// runtime code should keep the ThemeSource and call ResolveTheme directly.
367+func LoadThemeFromHome(zutHome, preferred string, detected Theme) (Theme, string, error) {
368+ profile := detected.Terminal
369+ source, err := LoadThemeSource(zutHome, preferred)
370+ if err != nil {
371+ return TerminalTheme(profile), "", err
319 }372 }
320- return base, tf.Name, nil373+ returned := ResolveTheme(preferred, source, profile)
374+ return returned.Theme, returned.Name, nil
321 }375 }
322 376
323 // AvailableThemes returns built-in and user-installed themes suitable377 // AvailableThemes returns built-in and user-installed themes suitable
324 // for a settings picker. Invalid JSON files are skipped.378 // for a settings picker. Invalid JSON files are skipped.
325 func AvailableThemes(zutHome string) []ThemeOption {379 func AvailableThemes(zutHome string) []ThemeOption {
326 out := []ThemeOption{380 out := []ThemeOption{
327- {Value: "auto", Label: "auto", Description: "detect terminal background and use zut defaults", Builtin: true},381+ {Value: "auto", Label: "auto", Description: "follow terminal colors and appearance", Builtin: true},
328- {Value: "inherited", Label: "inherited (from terminal)", Description: "use terminal colors with truecolor or best-effort 256-color output", Builtin: true},
329 {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true},382 {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true},
330 {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},383 {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},
331 }384 }
332- seen := map[string]bool{"auto": true, "inherited": true, "dark": true, "light": true}385+ seen := map[string]bool{"auto": true, "dark": true, "light": true}
333 paths, _ := themeFilesIn(filepath.Join(zutHome, "themes"))386 paths, _ := themeFilesIn(filepath.Join(zutHome, "themes"))
334 sort.Strings(paths)387 sort.Strings(paths)
335 for _, path := range paths {388 for _, path := range paths {
@@ -399,8 +452,6 @@ func resolveThemePath(zutHome, preferred string) (string, error) {
399 switch strings.ToLower(preferred) {452 switch strings.ToLower(preferred) {
400 case "", "auto", "default", "system":453 case "", "auto", "default", "system":
401 return "", nil454 return "", nil
402- case "inherited", "terminal", "terminal-inherited":
403- return "builtin:inherited", nil
404 case "dark":455 case "dark":
405 return "builtin:dark", nil456 return "builtin:dark", nil
406 case "light":457 case "light":
@@ -569,14 +620,69 @@ func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme {
569 return s620 return s
570 }621 }
571 622
572-func IsLightTheme(th Theme) bool {623+func validateExplicitTerminalColor(color TerminalColor) error {
573- if th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG {624+ switch color.Mode {
574- return true625+ case terminalColor256:
626+ if color.Index < 0 || color.Index > 255 {
627+ return fmt.Errorf("xterm-256 index %d is outside 0..255", color.Index)
628+ }
629+ case terminalColorANSI:
630+ if _, ok := ansiSGRToXtermIndex(color.Index); !ok {
631+ return fmt.Errorf("ANSI SGR %d is not a palette color", color.Index)
632+ }
633+ case terminalColorRGB:
634+ if color.R < 0 || color.R > 255 || color.G < 0 || color.G > 255 || color.B < 0 || color.B > 255 {
635+ return fmt.Errorf("RGB color (%d,%d,%d) is outside 0..255", color.R, color.G, color.B)
636+ }
637+ default:
638+ return errors.New("theme colors must be explicit xterm-256, ANSI, or RGB values")
639+ }
640+ return nil
641+}
642+
643+func validateThemeFile(file ThemeFile) error {
644+ for _, overrides := range []ThemeOverrides{file.Overrides, file.Colors.Base, file.Colors.Dark, file.Colors.Light} {
645+ if err := validateThemeOverrides(overrides); err != nil {
646+ return err
647+ }
575 }648 }
576- return th.Inherited && th.Terminal.SchemeKnown && th.Terminal.Light649+ return nil
577 }650 }
578 651
579-func isLightTheme(th Theme) bool { return IsLightTheme(th) }652+func validateThemeOverrides(o ThemeOverrides) error {
653+ for _, color := range []*TerminalColorValue{o.FG, o.Muted, o.Accent, o.Background, o.User, o.UserBubbleBG, o.UserBubbleFG, o.Assistant, o.Tool, o.ToolOut, o.Error, o.Warning, o.Spinner, o.ThinkingMax, o.ThinkingMaxCamel, o.SelectionBG, o.SelectionFG} {
654+ if color != nil {
655+ if err := validateExplicitTerminalColor(color.TerminalColor); err != nil {
656+ return err
657+ }
658+ }
659+ }
660+ if len(o.SpinnerFrames) > 64 {
661+ return errors.New("spinner_frames has more than 64 entries")
662+ }
663+ for _, frame := range o.SpinnerFrames {
664+ if frame == "" || len([]rune(frame)) > 16 {
665+ return fmt.Errorf("invalid spinner frame %q", frame)
666+ }
667+ }
668+ if o.SpinnerIntervalMS != nil && (*o.SpinnerIntervalMS < 10 || *o.SpinnerIntervalMS > 10000) {
669+ return fmt.Errorf("spinner_interval_ms %d is outside 10..10000", *o.SpinnerIntervalMS)
670+ }
671+ if o.SyntaxBaseStyle != nil && *o.SyntaxBaseStyle != "" {
672+ if _, ok := styles.Registry[*o.SyntaxBaseStyle]; !ok {
673+ return fmt.Errorf("unknown syntax_base_style %q", *o.SyntaxBaseStyle)
674+ }
675+ }
676+ for _, entry := range []*string{o.Syntax.Keyword, o.Syntax.KeywordConstant, o.Syntax.KeywordDeclaration, o.Syntax.KeywordNamespace, o.Syntax.KeywordReserved, o.Syntax.KeywordType, o.Syntax.NameBuiltin, o.Syntax.NameFunction, o.Syntax.NameClass, o.Syntax.NameDecorator, o.Syntax.LiteralString, o.Syntax.LiteralStringEscape, o.Syntax.LiteralNumber, o.Syntax.Comment, o.Syntax.CommentPreproc, o.Syntax.Operator, o.Syntax.Punctuation, o.Syntax.Text} {
677+ if entry == nil {
678+ continue
679+ }
680+ if _, err := chroma.ParseStyleEntry(*entry); err != nil {
681+ return fmt.Errorf("invalid syntax style %q: %w", *entry, err)
682+ }
683+ }
684+ return nil
685+}
580 686
581 func parseHexColor(s string) (TerminalColor, bool) {687 func parseHexColor(s string) (TerminalColor, bool) {
582 s = strings.TrimPrefix(strings.TrimSpace(s), "#")688 s = strings.TrimPrefix(strings.TrimSpace(s), "#")
M packages/tui/theme_loader_test.go +41 -9
@@ -33,8 +33,8 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
3333 if th.Accent != Color256(204) {
3434 t.Fatalf("accent = %#v, want 204", th.Accent)
3535 }
36- if th.FG != Dark.FG {
37- t.Fatalf("fg = %#v, want inherited %#v", th.FG, Dark.FG)
36+ if th.FG != TerminalDefault() {
37+ t.Fatalf("fg = %#v, want terminal default", th.FG)
3838 }
3939 if len(th.SpinnerFrames) == 0 {
4040 t.Fatal("spinner frames should be inherited")
@@ -66,15 +66,17 @@ func TestLoadThemeAllowsSpinnerAppearanceOverrides(t *testing.T) {
6666 if th.SpinnerFrames[1] != "o" || th.SpinnerIntervalMS != 200 {
6767 t.Fatalf("spinner appearance overrides not applied: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS)
6868 }
69- if th.Accent != Dark.Accent {
70- t.Fatalf("accent = %#v, want inherited %#v", th.Accent, Dark.Accent)
69+ if th.Accent != TerminalPaletteSlot(12) {
70+ t.Fatalf("accent = %#v, want terminal palette slot 12", th.Accent)
7171 }
7272 }
7373
7474 func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
7575 home := writeThemeFile(t, "darkonly", `{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`)
76+ detected := Light
77+ detected.Terminal = TerminalProfile{SchemeKnown: true, Light: true}
7678
77- th, _, err := LoadThemeFromHome(home, "darkonly", Light)
79+ th, _, err := LoadThemeFromHome(home, "darkonly", detected)
7880 if err != nil {
7981 t.Fatal(err)
8082 }
@@ -84,8 +86,8 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
8486 if th.SpinnerIntervalMS != 120 {
8587 t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)
8688 }
87- if th.FG != Light.FG {
88- t.Fatalf("fg = %#v, want inherited light fg %#v", th.FG, Light.FG)
89+ if th.FG != TerminalDefault() {
90+ t.Fatalf("fg = %#v, want terminal default", th.FG)
8991 }
9092 }
9193
@@ -102,8 +104,8 @@ func TestLoadThemeIgnoresLegacySpinnerMessages(t *testing.T) {
102104 if len(th.SpinnerFrames) != len(Light.SpinnerFrames) || th.SpinnerIntervalMS != Light.SpinnerIntervalMS {
103105 t.Fatalf("legacy spinner_messages changed spinner appearance: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS)
104106 }
105- if th.FG != Light.FG {
106- t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG)
107+ if th.FG != TerminalDefault() {
108+ t.Fatalf("fg = %#v, want terminal default", th.FG)
107109 }
108110 }
109111
@@ -145,3 +147,33 @@ func TestLoadThemeQuantizesRGBSemanticColorsWithoutTrueColor(t *testing.T) {
145147 t.Fatalf("RGB accent fallback = %q, want xterm-256 index %d", got, want)
146148 }
147149 }
150+
151+func TestLoadThemeRejectsInvalidNestedValues(t *testing.T) {
152+ for _, body := range []string{
153+ `{"colors":{"dark":{"accent":999}}}`,
154+ `{"colors":{"light":{"syntax":{"keyword":"not a style"}}}}`,
155+ `{"spinner_interval_ms":1}`,
156+ `{"syntax_base_style":"not-a-style"}`,
157+ } {
158+ home := writeThemeFile(t, "invalid", body)
159+ if _, err := LoadThemeSource(home, "invalid"); err == nil {
160+ t.Fatalf("LoadThemeSource(%s) succeeded", body)
161+ }
162+ }
163+}
164+
165+func TestResolveThemeCustomUsesReportedScheme(t *testing.T) {
166+ home := writeThemeFile(t, "split", `{"colors":{"dark":{"accent":20},"light":{"accent":21}}}`)
167+ source, err := LoadThemeSource(home, "split")
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ profile := TerminalProfile{SchemeKnown: true, Light: true}
172+ if got := ResolveTheme("split", source, profile).Theme.Accent; got != Color256(21) {
173+ t.Fatalf("light branch accent = %#v, want 21", got)
174+ }
175+ dark := TerminalProfile{SchemeKnown: true, Light: false}
176+ if got := ResolveTheme("split", source, dark).Theme.Accent; got != Color256(20) {
177+ t.Fatalf("dark branch accent = %#v, want 20", got)
178+ }
179+}
@@ -33,8 +33,8 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
33 if th.Accent != Color256(204) {33 if th.Accent != Color256(204) {
34 t.Fatalf("accent = %#v, want 204", th.Accent)34 t.Fatalf("accent = %#v, want 204", th.Accent)
35 }35 }
36- if th.FG != Dark.FG {36+ if th.FG != TerminalDefault() {
37- t.Fatalf("fg = %#v, want inherited %#v", th.FG, Dark.FG)37+ t.Fatalf("fg = %#v, want terminal default", th.FG)
38 }38 }
39 if len(th.SpinnerFrames) == 0 {39 if len(th.SpinnerFrames) == 0 {
40 t.Fatal("spinner frames should be inherited")40 t.Fatal("spinner frames should be inherited")
@@ -66,15 +66,17 @@ func TestLoadThemeAllowsSpinnerAppearanceOverrides(t *testing.T) {
66 if th.SpinnerFrames[1] != "o" || th.SpinnerIntervalMS != 200 {66 if th.SpinnerFrames[1] != "o" || th.SpinnerIntervalMS != 200 {
67 t.Fatalf("spinner appearance overrides not applied: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS)67 t.Fatalf("spinner appearance overrides not applied: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS)
68 }68 }
69- if th.Accent != Dark.Accent {69+ if th.Accent != TerminalPaletteSlot(12) {
70- t.Fatalf("accent = %#v, want inherited %#v", th.Accent, Dark.Accent)70+ t.Fatalf("accent = %#v, want terminal palette slot 12", th.Accent)
71 }71 }
72 }72 }
73 73
74 func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {74 func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
75 home := writeThemeFile(t, "darkonly", `{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`)75 home := writeThemeFile(t, "darkonly", `{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`)
76+ detected := Light
77+ detected.Terminal = TerminalProfile{SchemeKnown: true, Light: true}
76 78
77- th, _, err := LoadThemeFromHome(home, "darkonly", Light)79+ th, _, err := LoadThemeFromHome(home, "darkonly", detected)
78 if err != nil {80 if err != nil {
79 t.Fatal(err)81 t.Fatal(err)
80 }82 }
@@ -84,8 +86,8 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
84 if th.SpinnerIntervalMS != 120 {86 if th.SpinnerIntervalMS != 120 {
85 t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)87 t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)
86 }88 }
87- if th.FG != Light.FG {89+ if th.FG != TerminalDefault() {
88- t.Fatalf("fg = %#v, want inherited light fg %#v", th.FG, Light.FG)90+ t.Fatalf("fg = %#v, want terminal default", th.FG)
89 }91 }
90 }92 }
91 93
@@ -102,8 +104,8 @@ func TestLoadThemeIgnoresLegacySpinnerMessages(t *testing.T) {
102 if len(th.SpinnerFrames) != len(Light.SpinnerFrames) || th.SpinnerIntervalMS != Light.SpinnerIntervalMS {104 if len(th.SpinnerFrames) != len(Light.SpinnerFrames) || th.SpinnerIntervalMS != Light.SpinnerIntervalMS {
103 t.Fatalf("legacy spinner_messages changed spinner appearance: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS)105 t.Fatalf("legacy spinner_messages changed spinner appearance: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS)
104 }106 }
105- if th.FG != Light.FG {107+ if th.FG != TerminalDefault() {
106- t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG)108+ t.Fatalf("fg = %#v, want terminal default", th.FG)
107 }109 }
108 }110 }
109 111
@@ -145,3 +147,33 @@ func TestLoadThemeQuantizesRGBSemanticColorsWithoutTrueColor(t *testing.T) {
145 t.Fatalf("RGB accent fallback = %q, want xterm-256 index %d", got, want)147 t.Fatalf("RGB accent fallback = %q, want xterm-256 index %d", got, want)
146 }148 }
147 }149 }
150+
151+func TestLoadThemeRejectsInvalidNestedValues(t *testing.T) {
152+ for _, body := range []string{
153+ `{"colors":{"dark":{"accent":999}}}`,
154+ `{"colors":{"light":{"syntax":{"keyword":"not a style"}}}}`,
155+ `{"spinner_interval_ms":1}`,
156+ `{"syntax_base_style":"not-a-style"}`,
157+ } {
158+ home := writeThemeFile(t, "invalid", body)
159+ if _, err := LoadThemeSource(home, "invalid"); err == nil {
160+ t.Fatalf("LoadThemeSource(%s) succeeded", body)
161+ }
162+ }
163+}
164+
165+func TestResolveThemeCustomUsesReportedScheme(t *testing.T) {
166+ home := writeThemeFile(t, "split", `{"colors":{"dark":{"accent":20},"light":{"accent":21}}}`)
167+ source, err := LoadThemeSource(home, "split")
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ profile := TerminalProfile{SchemeKnown: true, Light: true}
172+ if got := ResolveTheme("split", source, profile).Theme.Accent; got != Color256(21) {
173+ t.Fatalf("light branch accent = %#v, want 21", got)
174+ }
175+ dark := TerminalProfile{SchemeKnown: true, Light: false}
176+ if got := ResolveTheme("split", source, dark).Theme.Accent; got != Color256(20) {
177+ t.Fatalf("dark branch accent = %#v, want 20", got)
178+ }
179+}
M packages/tui/view.go +3 -1
@@ -1208,8 +1208,10 @@ func toolThemeKey(theme Theme) uint64 {
12081208 for _, c := range theme.Terminal.Palette {
12091209 h = hashThemeColor(h, c)
12101210 }
1211+ h = fnv64aWriteByte(h, byte(theme.Terminal.ColorDepth()))
1212+ h = fnv64aWriteUint64(h, uint64(theme.Terminal.PaletteKnown))
12111213 for _, value := range []bool{
1212- theme.Inherited,
1214+ theme.UseTerminalPalette,
12131215 theme.Terminal.HasForeground,
12141216 theme.Terminal.HasBackground,
12151217 theme.Terminal.TrueColor,
@@ -1208,8 +1208,10 @@ func toolThemeKey(theme Theme) uint64 {
1208 for _, c := range theme.Terminal.Palette {1208 for _, c := range theme.Terminal.Palette {
1209 h = hashThemeColor(h, c)1209 h = hashThemeColor(h, c)
1210 }1210 }
1211+ h = fnv64aWriteByte(h, byte(theme.Terminal.ColorDepth()))
1212+ h = fnv64aWriteUint64(h, uint64(theme.Terminal.PaletteKnown))
1211 for _, value := range []bool{1213 for _, value := range []bool{
1212- theme.Inherited,1214+ theme.UseTerminalPalette,
1213 theme.Terminal.HasForeground,1215 theme.Terminal.HasForeground,
1214 theme.Terminal.HasBackground,1216 theme.Terminal.HasBackground,
1215 theme.Terminal.TrueColor,1217 theme.Terminal.TrueColor,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close