detached a34db93

feat(themes): follow live terminal appearance

commit
a34db93e0880013b1419bc10d59989bee435332d
author
brice · · 2026-08-23 09:45 UTC
parent
edaaa7c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
25 files · +1544 -962
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 +7 -2
@@ -2488,11 +2488,13 @@ 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+ themePreference := tui.ResolveThemePreference(initialCfg.Theme, os.Getenv("ZUT_THEME"))
2492+ theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), themePreference.Effective, 80*time.Millisecond)
24922493 if themeErr != nil {
24932494 fmt.Fprintln(os.Stderr, "theme load:", themeErr)
2494- if initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) {
2495+ if !themePreference.Forced && initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) {
24952496 initialCfg.Theme = ""
2497+ themePreference = tui.ResolveThemePreference("", os.Getenv("ZUT_THEME"))
24962498 _ = SaveConfig(initialCfg)
24972499 }
24982500 }
@@ -2554,6 +2556,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
25542556 TUIStatusPosition: initialCfg.TUIStatusPosition,
25552557 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
25562558 ThemeName: initialCfg.Theme,
2559+ EffectiveThemeName: themePreference.Effective,
2560+ ThemeForced: themePreference.Forced,
2561+ TerminalProfile: theme.Terminal,
25572562 FlatTools: initialCfg.FlatToolRender(),
25582563 CompactUser: initialCfg.CompactUserInput(),
25592564 ExtensionThemes: extMgr.ThemeOptions,
@@ -2488,11 +2488,13 @@ 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+ themePreference := tui.ResolveThemePreference(initialCfg.Theme, os.Getenv("ZUT_THEME"))
2492+ theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), themePreference.Effective, 80*time.Millisecond)
2492 if themeErr != nil {2493 if themeErr != nil {
2493 fmt.Fprintln(os.Stderr, "theme load:", themeErr)2494 fmt.Fprintln(os.Stderr, "theme load:", themeErr)
2494- if initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) {2495+ if !themePreference.Forced && initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) {
2495 initialCfg.Theme = ""2496 initialCfg.Theme = ""
2497+ themePreference = tui.ResolveThemePreference("", os.Getenv("ZUT_THEME"))
2496 _ = SaveConfig(initialCfg)2498 _ = SaveConfig(initialCfg)
2497 }2499 }
2498 }2500 }
@@ -2554,6 +2556,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2554 TUIStatusPosition: initialCfg.TUIStatusPosition,2556 TUIStatusPosition: initialCfg.TUIStatusPosition,
2555 TUIWorkingPosition: initialCfg.TUIWorkingPosition,2557 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
2556 ThemeName: initialCfg.Theme,2558 ThemeName: initialCfg.Theme,
2559+ EffectiveThemeName: themePreference.Effective,
2560+ ThemeForced: themePreference.Forced,
2561+ TerminalProfile: theme.Terminal,
2557 FlatTools: initialCfg.FlatToolRender(),2562 FlatTools: initialCfg.FlatToolRender(),
2558 CompactUser: initialCfg.CompactUserInput(),2563 CompactUser: initialCfg.CompactUserInput(),
2559 ExtensionThemes: extMgr.ThemeOptions,2564 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 +174 -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,45 @@ 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+ inputDone := make(chan struct{})
12301284 go func() {
1231- reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)
1285+ defer close(inputDone)
1286+ source := tui.NewAppearanceSource(term.ReadByte, term.PeekByteTimeout, appearanceParser, func(event tui.InputEvent) {
1287+ select {
1288+ case appearanceEvents <- event:
1289+ case <-ctx.Done():
1290+ }
1291+ })
1292+ reader := tui.NewReader(source.ReadByte)
12321293 for {
12331294 k, err := reader.Read()
12341295 if err != nil {
12351296 return
12361297 }
1237- keys <- k
1298+ select {
1299+ case keys <- k:
1300+ case <-ctx.Done():
1301+ return
1302+ }
1303+ }
1304+ }()
1305+ // Probe before mutating mode 2031. Replies are handled in the main loop so
1306+ // ownership and profile publication stay serialized with rendering.
1307+ _, _ = term.Write([]byte(tui.SeqQueryMode2031 + tui.AppearanceQuery()))
1308+ defer func() {
1309+ // Test terminals and normal EOF join immediately. A physical tty read
1310+ // may be uncancellable on some platforms, so shutdown never waits
1311+ // indefinitely for a kernel read that cannot be interrupted.
1312+ select {
1313+ case <-inputDone:
1314+ case <-time.After(50 * time.Millisecond):
12381315 }
12391316 }()
12401317
@@ -1310,11 +1387,83 @@ func (i *Interactive) Run(ctx context.Context) error {
13101387
13111388 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
13121389 changelog := i.cfg.ChangelogChan // single-shot, see case below
1390+ profileCandidate := i.terminalProfile
1391+ profileDirty := false
1392+ appearanceDeadline := time.Now().Add(500 * time.Millisecond)
1393+ lastAppearancePoll := time.Now()
1394+ modeEnableRequested := false
1395+ notificationsSupported := false
13131396
13141397 for {
13151398 select {
13161399 case <-ctx.Done():
13171400 return ctx.Err()
1401+ case event := <-i.themeWatchEvents:
1402+ if i.activeThemeSource == nil || event.path != i.activeThemeSource.Path {
1403+ break
1404+ }
1405+ switch {
1406+ case event.source != nil:
1407+ i.activeThemeSource = event.source
1408+ i.themeWatchWarning = ""
1409+ i.installResolvedTheme(tui.ResolveTheme(i.cfg.EffectiveThemeName, event.source, i.terminalProfile))
1410+ i.statusOK = "theme reloaded"
1411+ i.statusErr = ""
1412+ case event.deleted:
1413+ i.activeThemeSource = nil
1414+ i.stopThemeWatch()
1415+ i.cfg.ThemeName = ""
1416+ i.cfg.EffectiveThemeName = "auto"
1417+ if i.cfg.SettingsStore != nil {
1418+ _ = i.cfg.SettingsStore.SetTheme("auto")
1419+ }
1420+ i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile))
1421+ i.statusOK = "theme removed; using auto"
1422+ i.statusErr = ""
1423+ case event.err != nil && event.err.Error() != i.themeWatchWarning:
1424+ i.themeWatchWarning = event.err.Error()
1425+ i.statusErr = "theme reload: " + event.err.Error()
1426+ }
1427+ i.invalidate()
1428+ case event := <-appearanceEvents:
1429+ switch {
1430+ case event.Color != nil:
1431+ switch event.Color.Kind {
1432+ case 10:
1433+ profileCandidate.Foreground = event.Color.Color
1434+ profileCandidate.HasForeground = true
1435+ case 11:
1436+ profileCandidate.Background = event.Color.Color
1437+ profileCandidate.HasBackground = true
1438+ case 4:
1439+ profileCandidate.Palette[event.Color.Slot] = event.Color.Color
1440+ profileCandidate.PaletteKnown |= uint16(1) << event.Color.Slot
1441+ }
1442+ profileDirty = true
1443+ case event.Scheme != nil:
1444+ profileCandidate.Light = event.Scheme.Light
1445+ profileCandidate.SchemeKnown = true
1446+ profileDirty = true
1447+ case event.Mode != nil && event.Mode.Mode == 2031:
1448+ switch event.Mode.Status {
1449+ case 1, 3:
1450+ notificationsSupported = true
1451+ appearanceParser.SetNotifications(true)
1452+ if modeEnableRequested {
1453+ mode2031Owned = true
1454+ }
1455+ case 2:
1456+ modeEnableRequested = true
1457+ _, _ = term.Write([]byte(tui.SeqEnableMode2031 + tui.SeqQueryMode2031))
1458+ default:
1459+ appearanceParser.SetNotifications(false)
1460+ }
1461+ }
1462+ // The parser gate closes after a bounded collection window; accepted
1463+ // notifications remain enabled separately once mode 2031 is known.
1464+ if profileDirty {
1465+ i.invalidate()
1466+ }
13181467 case k := <-keys:
13191468 if done := i.handleKey(ctx, k); done {
13201469 return nil
@@ -1403,6 +1552,21 @@ func (i *Interactive) Run(ctx context.Context) error {
14031552 case <-i.dirty:
14041553 requestRedraw()
14051554 case <-tick.C:
1555+ now := time.Now()
1556+ if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) {
1557+ appearanceParser.SetPendingColors(false)
1558+ appearanceDeadline = time.Time{}
1559+ }
1560+ if profileDirty {
1561+ i.applyTerminalProfile(profileCandidate)
1562+ profileDirty = false
1563+ }
1564+ if !notificationsSupported && now.Sub(lastAppearancePoll) >= 2*time.Second {
1565+ appearanceParser.SetPendingColors(true)
1566+ appearanceDeadline = now.Add(500 * time.Millisecond)
1567+ lastAppearancePoll = now
1568+ _, _ = term.Write([]byte("\x1b]11;?\a" + tui.SeqQueryColorScheme))
1569+ }
14061570 // Always drain a pending redraw on the tick. This is the
14071571 // safety net that catches the case where the dirty channel
14081572 // 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,45 @@ 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+ inputDone := make(chan struct{})
1230 go func() {1284 go func() {
1231- reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout)1285+ defer close(inputDone)
1286+ source := tui.NewAppearanceSource(term.ReadByte, term.PeekByteTimeout, appearanceParser, func(event tui.InputEvent) {
1287+ select {
1288+ case appearanceEvents <- event:
1289+ case <-ctx.Done():
1290+ }
1291+ })
1292+ reader := tui.NewReader(source.ReadByte)
1232 for {1293 for {
1233 k, err := reader.Read()1294 k, err := reader.Read()
1234 if err != nil {1295 if err != nil {
1235 return1296 return
1236 }1297 }
1237- keys <- k1298+ select {
1299+ case keys <- k:
1300+ case <-ctx.Done():
1301+ return
1302+ }
1303+ }
1304+ }()
1305+ // Probe before mutating mode 2031. Replies are handled in the main loop so
1306+ // ownership and profile publication stay serialized with rendering.
1307+ _, _ = term.Write([]byte(tui.SeqQueryMode2031 + tui.AppearanceQuery()))
1308+ defer func() {
1309+ // Test terminals and normal EOF join immediately. A physical tty read
1310+ // may be uncancellable on some platforms, so shutdown never waits
1311+ // indefinitely for a kernel read that cannot be interrupted.
1312+ select {
1313+ case <-inputDone:
1314+ case <-time.After(50 * time.Millisecond):
1238 }1315 }
1239 }()1316 }()
1240 1317
@@ -1310,11 +1387,83 @@ func (i *Interactive) Run(ctx context.Context) error {
1310 1387
1311 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select1388 updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select
1312 changelog := i.cfg.ChangelogChan // single-shot, see case below1389 changelog := i.cfg.ChangelogChan // single-shot, see case below
1390+ profileCandidate := i.terminalProfile
1391+ profileDirty := false
1392+ appearanceDeadline := time.Now().Add(500 * time.Millisecond)
1393+ lastAppearancePoll := time.Now()
1394+ modeEnableRequested := false
1395+ notificationsSupported := false
1313 1396
1314 for {1397 for {
1315 select {1398 select {
1316 case <-ctx.Done():1399 case <-ctx.Done():
1317 return ctx.Err()1400 return ctx.Err()
1401+ case event := <-i.themeWatchEvents:
1402+ if i.activeThemeSource == nil || event.path != i.activeThemeSource.Path {
1403+ break
1404+ }
1405+ switch {
1406+ case event.source != nil:
1407+ i.activeThemeSource = event.source
1408+ i.themeWatchWarning = ""
1409+ i.installResolvedTheme(tui.ResolveTheme(i.cfg.EffectiveThemeName, event.source, i.terminalProfile))
1410+ i.statusOK = "theme reloaded"
1411+ i.statusErr = ""
1412+ case event.deleted:
1413+ i.activeThemeSource = nil
1414+ i.stopThemeWatch()
1415+ i.cfg.ThemeName = ""
1416+ i.cfg.EffectiveThemeName = "auto"
1417+ if i.cfg.SettingsStore != nil {
1418+ _ = i.cfg.SettingsStore.SetTheme("auto")
1419+ }
1420+ i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile))
1421+ i.statusOK = "theme removed; using auto"
1422+ i.statusErr = ""
1423+ case event.err != nil && event.err.Error() != i.themeWatchWarning:
1424+ i.themeWatchWarning = event.err.Error()
1425+ i.statusErr = "theme reload: " + event.err.Error()
1426+ }
1427+ i.invalidate()
1428+ case event := <-appearanceEvents:
1429+ switch {
1430+ case event.Color != nil:
1431+ switch event.Color.Kind {
1432+ case 10:
1433+ profileCandidate.Foreground = event.Color.Color
1434+ profileCandidate.HasForeground = true
1435+ case 11:
1436+ profileCandidate.Background = event.Color.Color
1437+ profileCandidate.HasBackground = true
1438+ case 4:
1439+ profileCandidate.Palette[event.Color.Slot] = event.Color.Color
1440+ profileCandidate.PaletteKnown |= uint16(1) << event.Color.Slot
1441+ }
1442+ profileDirty = true
1443+ case event.Scheme != nil:
1444+ profileCandidate.Light = event.Scheme.Light
1445+ profileCandidate.SchemeKnown = true
1446+ profileDirty = true
1447+ case event.Mode != nil && event.Mode.Mode == 2031:
1448+ switch event.Mode.Status {
1449+ case 1, 3:
1450+ notificationsSupported = true
1451+ appearanceParser.SetNotifications(true)
1452+ if modeEnableRequested {
1453+ mode2031Owned = true
1454+ }
1455+ case 2:
1456+ modeEnableRequested = true
1457+ _, _ = term.Write([]byte(tui.SeqEnableMode2031 + tui.SeqQueryMode2031))
1458+ default:
1459+ appearanceParser.SetNotifications(false)
1460+ }
1461+ }
1462+ // The parser gate closes after a bounded collection window; accepted
1463+ // notifications remain enabled separately once mode 2031 is known.
1464+ if profileDirty {
1465+ i.invalidate()
1466+ }
1318 case k := <-keys:1467 case k := <-keys:
1319 if done := i.handleKey(ctx, k); done {1468 if done := i.handleKey(ctx, k); done {
1320 return nil1469 return nil
@@ -1403,6 +1552,21 @@ func (i *Interactive) Run(ctx context.Context) error {
1403 case <-i.dirty:1552 case <-i.dirty:
1404 requestRedraw()1553 requestRedraw()
1405 case <-tick.C:1554 case <-tick.C:
1555+ now := time.Now()
1556+ if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) {
1557+ appearanceParser.SetPendingColors(false)
1558+ appearanceDeadline = time.Time{}
1559+ }
1560+ if profileDirty {
1561+ i.applyTerminalProfile(profileCandidate)
1562+ profileDirty = false
1563+ }
1564+ if !notificationsSupported && now.Sub(lastAppearancePoll) >= 2*time.Second {
1565+ appearanceParser.SetPendingColors(true)
1566+ appearanceDeadline = now.Add(500 * time.Millisecond)
1567+ lastAppearancePoll = now
1568+ _, _ = term.Write([]byte("\x1b]11;?\a" + tui.SeqQueryColorScheme))
1569+ }
1406 // Always drain a pending redraw on the tick. This is the1570 // Always drain a pending redraw on the tick. This is the
1407 // safety net that catches the case where the dirty channel1571 // safety net that catches the case where the dirty channel
1408 // was saturated when the final "turn finished" invalidate1572 // 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 +49 -23
@@ -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)
1190+ i.cfg.EffectiveThemeName = "auto"
1191+ i.activeThemeSource = nil
1192+ i.stopThemeWatch()
1193+ i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile))
11911194 i.mu.Lock()
1192- i.statusErr = "theme missing; reset to default"
1193- i.mu.Unlock()
1194- } else {
1195- 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
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)"
12031206 }
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()
1229+ i.chatCacheValid = false
1230+ i.stableChatCacheValid = false
12081231 i.mu.Unlock()
12091232 i.ed.Prompt = th.AccentBar(th.Accent)
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.activeThemeSource = nil
1192+ i.stopThemeWatch()
1193+ i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile))
1191 i.mu.Lock()1194 i.mu.Lock()
1192- i.statusErr = "theme missing; reset to default"1195+ i.statusErr = "theme missing; reset to auto"
1193- i.mu.Unlock()
1194- } else {
1195- i.mu.Lock()
1196- label := applied
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
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)"
1203 }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()
1229+ i.chatCacheValid = false
1230+ i.stableChatCacheValid = false
1208 i.mu.Unlock()1231 i.mu.Unlock()
1209 i.ed.Prompt = th.AccentBar(th.Accent)1232 i.ed.Prompt = th.AccentBar(th.Accent)
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 +105 -0
@@ -0,0 +1,105 @@
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+ source, err := tui.LoadThemeSource(home, preference)
82+ if err == nil && source != nil {
83+ missingSince = time.Time{}
84+ lastError = ""
85+ if source.Path != accepted.Path || source.Digest == lastDigest {
86+ continue
87+ }
88+ lastDigest = source.Digest
89+ sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, source: source})
90+ continue
91+ }
92+ if err != nil && err.Error() != lastError {
93+ lastError = err.Error()
94+ sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, err: err})
95+ }
96+ }
97+ }
98+}
99+
100+func sendThemeWatchEvent(ctx context.Context, events chan<- themeWatchEvent, event themeWatchEvent) {
101+ select {
102+ case events <- event:
103+ case <-ctx.Done():
104+ }
105+}
@@ -0,0 +1,105 @@
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+ source, err := tui.LoadThemeSource(home, preference)
82+ if err == nil && source != nil {
83+ missingSince = time.Time{}
84+ lastError = ""
85+ if source.Path != accepted.Path || source.Digest == lastDigest {
86+ continue
87+ }
88+ lastDigest = source.Digest
89+ sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, source: source})
90+ continue
91+ }
92+ if err != nil && err.Error() != lastError {
93+ lastError = err.Error()
94+ sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, err: err})
95+ }
96+ }
97+ }
98+}
99+
100+func sendThemeWatchEvent(ctx context.Context, events chan<- themeWatchEvent, event themeWatchEvent) {
101+ select {
102+ case events <- event:
103+ case <-ctx.Done():
104+ }
105+}
A packages/agent/modes/theme_watch_test.go +96 -0
@@ -0,0 +1,96 @@
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, 3)
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 TestWatchThemeSourceTicksDelaysPersistentDeletion(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+ if err := os.Remove(path); err != nil {
73+ t.Fatal(err)
74+ }
75+ ctx, cancel := context.WithCancel(context.Background())
76+ defer cancel()
77+ ticks := make(chan time.Time, 3)
78+ events := make(chan themeWatchEvent, 1)
79+ go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)
80+ ticks <- time.Unix(1, 0)
81+ ticks <- time.Unix(2, 0)
82+ select {
83+ case event := <-events:
84+ t.Fatalf("deletion happened before grace period: %#v", event)
85+ default:
86+ }
87+ ticks <- time.Unix(4, 0)
88+ select {
89+ case event := <-events:
90+ if !event.deleted {
91+ t.Fatalf("persistent deletion event = %#v", event)
92+ }
93+ case <-time.After(time.Second):
94+ t.Fatal("watcher did not publish persistent deletion")
95+ }
96+}
@@ -0,0 +1,96 @@
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, 3)
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 TestWatchThemeSourceTicksDelaysPersistentDeletion(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+ if err := os.Remove(path); err != nil {
73+ t.Fatal(err)
74+ }
75+ ctx, cancel := context.WithCancel(context.Background())
76+ defer cancel()
77+ ticks := make(chan time.Time, 3)
78+ events := make(chan themeWatchEvent, 1)
79+ go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events)
80+ ticks <- time.Unix(1, 0)
81+ ticks <- time.Unix(2, 0)
82+ select {
83+ case event := <-events:
84+ t.Fatalf("deletion happened before grace period: %#v", event)
85+ default:
86+ }
87+ ticks <- time.Unix(4, 0)
88+ select {
89+ case event := <-events:
90+ if !event.deleted {
91+ t.Fatalf("persistent deletion event = %#v", event)
92+ }
93+ case <-time.After(time.Second):
94+ t.Fatal("watcher did not publish persistent deletion")
95+ }
96+}
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 +288 -0
@@ -0,0 +1,288 @@
1+package tui
2+
3+import (
4+ "strconv"
5+ "strings"
6+ "sync"
7+ "time"
8+)
9+
10+// SchemeEvent is a reported terminal color-scheme preference.
11+const (
12+ SeqQueryColorScheme = "\x1b[?996n"
13+ SeqQueryMode2031 = "\x1b[?2031$p"
14+ SeqEnableMode2031 = "\x1b[?2031h"
15+ SeqDisableMode2031 = "\x1b[?2031l"
16+)
17+
18+// AppearanceQuery returns the complete profile query used at startup and on a
19+// replacement generation. The ANSI palette is requested in one OSC 4 batch.
20+func AppearanceQuery() string {
21+ var b strings.Builder
22+ b.WriteString(SeqQueryColorScheme)
23+ b.WriteString("\x1b]10;?\a\x1b]11;?\a\x1b]4;")
24+ for slot := 0; slot < 16; slot++ {
25+ if slot > 0 {
26+ b.WriteByte(';')
27+ }
28+ b.WriteString(strconv.Itoa(slot))
29+ b.WriteString(";?")
30+ }
31+ b.WriteByte('\a')
32+ return b.String()
33+}
34+
35+// SchemeEvent is a reported terminal color-scheme preference.
36+type SchemeEvent struct{ Light bool }
37+
38+// ColorEvent is one default foreground/background or ANSI-palette reply.
39+type ColorEvent struct {
40+ Kind int // 10 foreground, 11 background, 4 palette
41+ Slot int
42+ Color TerminalColor
43+}
44+
45+// ModeReportEvent is a DECRQM response. Status follows the terminal protocol:
46+// 1/3 set, 2 reset, 0/4 unsupported or permanently reset.
47+type ModeReportEvent struct {
48+ Mode int
49+ Status int
50+}
51+
52+// InputEvent is emitted by the appearance-aware input path. Exactly one field
53+// is set. Key bytes that do not form an accepted protocol reply remain keys.
54+type InputEvent struct {
55+ Key *Key
56+ Scheme *SchemeEvent
57+ Color *ColorEvent
58+ Mode *ModeReportEvent
59+}
60+
61+// AppearanceParser controls which terminal replies may be consumed. Keeping
62+// the gate here prevents OSC-looking editor input from disappearing unless zut
63+// actively issued a matching query, while 997 notifications are accepted only
64+// after notification support is known.
65+type AppearanceParser struct {
66+ mu sync.RWMutex
67+ pendingColors bool
68+ notifications bool
69+}
70+
71+func (p *AppearanceParser) SetPendingColors(pending bool) {
72+ p.mu.Lock()
73+ p.pendingColors = pending
74+ p.mu.Unlock()
75+}
76+
77+func (p *AppearanceParser) SetNotifications(enabled bool) {
78+ p.mu.Lock()
79+ p.notifications = enabled
80+ p.mu.Unlock()
81+}
82+
83+func (p *AppearanceParser) acceptsColors() bool {
84+ p.mu.RLock()
85+ defer p.mu.RUnlock()
86+ return p.pendingColors
87+}
88+
89+func (p *AppearanceParser) acceptsNotifications() bool {
90+ p.mu.RLock()
91+ defer p.mu.RUnlock()
92+ return p.notifications
93+}
94+
95+// ParseAppearanceOSC parses one complete BEL/ST-terminated OSC sequence.
96+// Combined OSC 4 responses produce one ColorEvent for every valid pair.
97+func ParseAppearanceOSC(raw []byte) []ColorEvent {
98+ s := strings.TrimSuffix(string(raw), "\a")
99+ s = strings.TrimSuffix(s, "\x1b\\")
100+ if !strings.HasPrefix(s, "\x1b]") {
101+ return nil
102+ }
103+ body := strings.TrimPrefix(s, "\x1b]")
104+ parts := strings.Split(body, ";")
105+ if len(parts) < 2 {
106+ return nil
107+ }
108+ kind, err := strconv.Atoi(parts[0])
109+ if err != nil {
110+ return nil
111+ }
112+ switch kind {
113+ case 10, 11:
114+ color, ok := parseTerminalRGB(strings.Join(parts[1:], ";"))
115+ if !ok {
116+ return nil
117+ }
118+ return []ColorEvent{{Kind: kind, Slot: -1, Color: color}}
119+ case 4:
120+ var events []ColorEvent
121+ for idx := 1; idx+1 < len(parts); idx += 2 {
122+ slot, err := strconv.Atoi(parts[idx])
123+ if err != nil || slot < 0 || slot >= 16 {
124+ continue
125+ }
126+ color, ok := parseTerminalRGB(parts[idx+1])
127+ if ok {
128+ events = append(events, ColorEvent{Kind: 4, Slot: slot, Color: color})
129+ }
130+ }
131+ return events
132+ default:
133+ return nil
134+ }
135+}
136+
137+// ParseAppearanceCSI parses the two runtime CSI response families used by
138+// terminal-owned themes: current scheme (997) and mode ownership (DECRQM).
139+func ParseAppearanceCSI(raw []byte) (scheme *SchemeEvent, mode *ModeReportEvent) {
140+ s := string(raw)
141+ if !strings.HasPrefix(s, "\x1b[") {
142+ return nil, nil
143+ }
144+ body := strings.TrimPrefix(s, "\x1b[")
145+ if strings.HasPrefix(body, "?997;") && strings.HasSuffix(body, "n") {
146+ value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?997;"), "n"))
147+ if err == nil && (value == 1 || value == 2) {
148+ return &SchemeEvent{Light: value == 2}, nil
149+ }
150+ }
151+ if strings.HasPrefix(body, "?2031;") && strings.HasSuffix(body, "$y") {
152+ value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?2031;"), "$y"))
153+ if err == nil {
154+ return nil, &ModeReportEvent{Mode: 2031, Status: value}
155+ }
156+ }
157+ return nil, nil
158+}
159+
160+// AppearanceSource filters accepted terminal appearance replies before they
161+// reach Reader. It deliberately leaves bracketed-paste payload opaque.
162+type AppearanceSource struct {
163+ read func() (byte, error)
164+ peek func(time.Duration) (byte, bool, error)
165+ parser *AppearanceParser
166+ emit func(InputEvent)
167+
168+ pending []byte
169+ inPaste bool
170+ pasteTail []byte
171+}
172+
173+func NewAppearanceSource(read func() (byte, error), peek func(time.Duration) (byte, bool, error), parser *AppearanceParser, emit func(InputEvent)) *AppearanceSource {
174+ return &AppearanceSource{read: read, peek: peek, parser: parser, emit: emit}
175+}
176+
177+func (s *AppearanceSource) ReadByte() (byte, error) {
178+ if len(s.pending) > 0 {
179+ b := s.pending[0]
180+ s.pending = s.pending[1:]
181+ return b, nil
182+ }
183+ for {
184+ b, err := s.read()
185+ if err != nil || b != '\x1b' || s.inPaste || s.peek == nil {
186+ s.trackPaste(b)
187+ return b, err
188+ }
189+ next, ok, err := s.peek(time.Millisecond)
190+ if err != nil || !ok || (next != ']' && next != '[') {
191+ if ok {
192+ s.pending = append(s.pending, next)
193+ }
194+ return b, err
195+ }
196+ raw := []byte{'\x1b', next}
197+ if next == ']' {
198+ sequence, err := s.readOSC(raw)
199+ if err != nil {
200+ return 0, err
201+ }
202+ if events := ParseAppearanceOSC(sequence); len(events) != 0 && s.parser != nil && s.parser.acceptsColors() {
203+ for _, event := range events {
204+ e := event
205+ s.emit(InputEvent{Color: &e})
206+ }
207+ continue
208+ }
209+ s.pending = append(s.pending, sequence[1:]...)
210+ return '\x1b', nil
211+ }
212+ sequence, err := s.readCSI(raw)
213+ if err != nil {
214+ return 0, err
215+ }
216+ if bytesEqual(sequence, []byte("\x1b[200~")) {
217+ s.inPaste = true
218+ }
219+ scheme, mode := ParseAppearanceCSI(sequence)
220+ if scheme != nil && s.parser != nil && (s.parser.acceptsColors() || s.parser.acceptsNotifications()) {
221+ s.emit(InputEvent{Scheme: scheme})
222+ continue
223+ }
224+ if mode != nil {
225+ s.emit(InputEvent{Mode: mode})
226+ continue
227+ }
228+ s.pending = append(s.pending, sequence[1:]...)
229+ return '\x1b', nil
230+ }
231+}
232+
233+func (s *AppearanceSource) readOSC(raw []byte) ([]byte, error) {
234+ const limit = 4096
235+ for len(raw) < limit {
236+ b, err := s.read()
237+ if err != nil {
238+ return raw, err
239+ }
240+ raw = append(raw, b)
241+ if b == '\a' || (len(raw) >= 2 && raw[len(raw)-2] == '\x1b' && b == '\\') {
242+ return raw, nil
243+ }
244+ }
245+ return raw, nil
246+}
247+
248+func (s *AppearanceSource) readCSI(raw []byte) ([]byte, error) {
249+ const limit = 256
250+ for len(raw) < limit {
251+ b, err := s.read()
252+ if err != nil {
253+ return raw, err
254+ }
255+ raw = append(raw, b)
256+ if b >= 0x40 && b <= 0x7e {
257+ return raw, nil
258+ }
259+ }
260+ return raw, nil
261+}
262+
263+func (s *AppearanceSource) trackPaste(b byte) {
264+ if !s.inPaste {
265+ return
266+ }
267+ const end = "\x1b[201~"
268+ s.pasteTail = append(s.pasteTail, b)
269+ if len(s.pasteTail) > len(end) {
270+ s.pasteTail = s.pasteTail[1:]
271+ }
272+ if string(s.pasteTail) == end {
273+ s.inPaste = false
274+ s.pasteTail = nil
275+ }
276+}
277+
278+func bytesEqual(a, b []byte) bool {
279+ if len(a) != len(b) {
280+ return false
281+ }
282+ for i := range a {
283+ if a[i] != b[i] {
284+ return false
285+ }
286+ }
287+ return true
288+}
@@ -0,0 +1,288 @@
1+package tui
2+
3+import (
4+ "strconv"
5+ "strings"
6+ "sync"
7+ "time"
8+)
9+
10+// SchemeEvent is a reported terminal color-scheme preference.
11+const (
12+ SeqQueryColorScheme = "\x1b[?996n"
13+ SeqQueryMode2031 = "\x1b[?2031$p"
14+ SeqEnableMode2031 = "\x1b[?2031h"
15+ SeqDisableMode2031 = "\x1b[?2031l"
16+)
17+
18+// AppearanceQuery returns the complete profile query used at startup and on a
19+// replacement generation. The ANSI palette is requested in one OSC 4 batch.
20+func AppearanceQuery() string {
21+ var b strings.Builder
22+ b.WriteString(SeqQueryColorScheme)
23+ b.WriteString("\x1b]10;?\a\x1b]11;?\a\x1b]4;")
24+ for slot := 0; slot < 16; slot++ {
25+ if slot > 0 {
26+ b.WriteByte(';')
27+ }
28+ b.WriteString(strconv.Itoa(slot))
29+ b.WriteString(";?")
30+ }
31+ b.WriteByte('\a')
32+ return b.String()
33+}
34+
35+// SchemeEvent is a reported terminal color-scheme preference.
36+type SchemeEvent struct{ Light bool }
37+
38+// ColorEvent is one default foreground/background or ANSI-palette reply.
39+type ColorEvent struct {
40+ Kind int // 10 foreground, 11 background, 4 palette
41+ Slot int
42+ Color TerminalColor
43+}
44+
45+// ModeReportEvent is a DECRQM response. Status follows the terminal protocol:
46+// 1/3 set, 2 reset, 0/4 unsupported or permanently reset.
47+type ModeReportEvent struct {
48+ Mode int
49+ Status int
50+}
51+
52+// InputEvent is emitted by the appearance-aware input path. Exactly one field
53+// is set. Key bytes that do not form an accepted protocol reply remain keys.
54+type InputEvent struct {
55+ Key *Key
56+ Scheme *SchemeEvent
57+ Color *ColorEvent
58+ Mode *ModeReportEvent
59+}
60+
61+// AppearanceParser controls which terminal replies may be consumed. Keeping
62+// the gate here prevents OSC-looking editor input from disappearing unless zut
63+// actively issued a matching query, while 997 notifications are accepted only
64+// after notification support is known.
65+type AppearanceParser struct {
66+ mu sync.RWMutex
67+ pendingColors bool
68+ notifications bool
69+}
70+
71+func (p *AppearanceParser) SetPendingColors(pending bool) {
72+ p.mu.Lock()
73+ p.pendingColors = pending
74+ p.mu.Unlock()
75+}
76+
77+func (p *AppearanceParser) SetNotifications(enabled bool) {
78+ p.mu.Lock()
79+ p.notifications = enabled
80+ p.mu.Unlock()
81+}
82+
83+func (p *AppearanceParser) acceptsColors() bool {
84+ p.mu.RLock()
85+ defer p.mu.RUnlock()
86+ return p.pendingColors
87+}
88+
89+func (p *AppearanceParser) acceptsNotifications() bool {
90+ p.mu.RLock()
91+ defer p.mu.RUnlock()
92+ return p.notifications
93+}
94+
95+// ParseAppearanceOSC parses one complete BEL/ST-terminated OSC sequence.
96+// Combined OSC 4 responses produce one ColorEvent for every valid pair.
97+func ParseAppearanceOSC(raw []byte) []ColorEvent {
98+ s := strings.TrimSuffix(string(raw), "\a")
99+ s = strings.TrimSuffix(s, "\x1b\\")
100+ if !strings.HasPrefix(s, "\x1b]") {
101+ return nil
102+ }
103+ body := strings.TrimPrefix(s, "\x1b]")
104+ parts := strings.Split(body, ";")
105+ if len(parts) < 2 {
106+ return nil
107+ }
108+ kind, err := strconv.Atoi(parts[0])
109+ if err != nil {
110+ return nil
111+ }
112+ switch kind {
113+ case 10, 11:
114+ color, ok := parseTerminalRGB(strings.Join(parts[1:], ";"))
115+ if !ok {
116+ return nil
117+ }
118+ return []ColorEvent{{Kind: kind, Slot: -1, Color: color}}
119+ case 4:
120+ var events []ColorEvent
121+ for idx := 1; idx+1 < len(parts); idx += 2 {
122+ slot, err := strconv.Atoi(parts[idx])
123+ if err != nil || slot < 0 || slot >= 16 {
124+ continue
125+ }
126+ color, ok := parseTerminalRGB(parts[idx+1])
127+ if ok {
128+ events = append(events, ColorEvent{Kind: 4, Slot: slot, Color: color})
129+ }
130+ }
131+ return events
132+ default:
133+ return nil
134+ }
135+}
136+
137+// ParseAppearanceCSI parses the two runtime CSI response families used by
138+// terminal-owned themes: current scheme (997) and mode ownership (DECRQM).
139+func ParseAppearanceCSI(raw []byte) (scheme *SchemeEvent, mode *ModeReportEvent) {
140+ s := string(raw)
141+ if !strings.HasPrefix(s, "\x1b[") {
142+ return nil, nil
143+ }
144+ body := strings.TrimPrefix(s, "\x1b[")
145+ if strings.HasPrefix(body, "?997;") && strings.HasSuffix(body, "n") {
146+ value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?997;"), "n"))
147+ if err == nil && (value == 1 || value == 2) {
148+ return &SchemeEvent{Light: value == 2}, nil
149+ }
150+ }
151+ if strings.HasPrefix(body, "?2031;") && strings.HasSuffix(body, "$y") {
152+ value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?2031;"), "$y"))
153+ if err == nil {
154+ return nil, &ModeReportEvent{Mode: 2031, Status: value}
155+ }
156+ }
157+ return nil, nil
158+}
159+
160+// AppearanceSource filters accepted terminal appearance replies before they
161+// reach Reader. It deliberately leaves bracketed-paste payload opaque.
162+type AppearanceSource struct {
163+ read func() (byte, error)
164+ peek func(time.Duration) (byte, bool, error)
165+ parser *AppearanceParser
166+ emit func(InputEvent)
167+
168+ pending []byte
169+ inPaste bool
170+ pasteTail []byte
171+}
172+
173+func NewAppearanceSource(read func() (byte, error), peek func(time.Duration) (byte, bool, error), parser *AppearanceParser, emit func(InputEvent)) *AppearanceSource {
174+ return &AppearanceSource{read: read, peek: peek, parser: parser, emit: emit}
175+}
176+
177+func (s *AppearanceSource) ReadByte() (byte, error) {
178+ if len(s.pending) > 0 {
179+ b := s.pending[0]
180+ s.pending = s.pending[1:]
181+ return b, nil
182+ }
183+ for {
184+ b, err := s.read()
185+ if err != nil || b != '\x1b' || s.inPaste || s.peek == nil {
186+ s.trackPaste(b)
187+ return b, err
188+ }
189+ next, ok, err := s.peek(time.Millisecond)
190+ if err != nil || !ok || (next != ']' && next != '[') {
191+ if ok {
192+ s.pending = append(s.pending, next)
193+ }
194+ return b, err
195+ }
196+ raw := []byte{'\x1b', next}
197+ if next == ']' {
198+ sequence, err := s.readOSC(raw)
199+ if err != nil {
200+ return 0, err
201+ }
202+ if events := ParseAppearanceOSC(sequence); len(events) != 0 && s.parser != nil && s.parser.acceptsColors() {
203+ for _, event := range events {
204+ e := event
205+ s.emit(InputEvent{Color: &e})
206+ }
207+ continue
208+ }
209+ s.pending = append(s.pending, sequence[1:]...)
210+ return '\x1b', nil
211+ }
212+ sequence, err := s.readCSI(raw)
213+ if err != nil {
214+ return 0, err
215+ }
216+ if bytesEqual(sequence, []byte("\x1b[200~")) {
217+ s.inPaste = true
218+ }
219+ scheme, mode := ParseAppearanceCSI(sequence)
220+ if scheme != nil && s.parser != nil && (s.parser.acceptsColors() || s.parser.acceptsNotifications()) {
221+ s.emit(InputEvent{Scheme: scheme})
222+ continue
223+ }
224+ if mode != nil {
225+ s.emit(InputEvent{Mode: mode})
226+ continue
227+ }
228+ s.pending = append(s.pending, sequence[1:]...)
229+ return '\x1b', nil
230+ }
231+}
232+
233+func (s *AppearanceSource) readOSC(raw []byte) ([]byte, error) {
234+ const limit = 4096
235+ for len(raw) < limit {
236+ b, err := s.read()
237+ if err != nil {
238+ return raw, err
239+ }
240+ raw = append(raw, b)
241+ if b == '\a' || (len(raw) >= 2 && raw[len(raw)-2] == '\x1b' && b == '\\') {
242+ return raw, nil
243+ }
244+ }
245+ return raw, nil
246+}
247+
248+func (s *AppearanceSource) readCSI(raw []byte) ([]byte, error) {
249+ const limit = 256
250+ for len(raw) < limit {
251+ b, err := s.read()
252+ if err != nil {
253+ return raw, err
254+ }
255+ raw = append(raw, b)
256+ if b >= 0x40 && b <= 0x7e {
257+ return raw, nil
258+ }
259+ }
260+ return raw, nil
261+}
262+
263+func (s *AppearanceSource) trackPaste(b byte) {
264+ if !s.inPaste {
265+ return
266+ }
267+ const end = "\x1b[201~"
268+ s.pasteTail = append(s.pasteTail, b)
269+ if len(s.pasteTail) > len(end) {
270+ s.pasteTail = s.pasteTail[1:]
271+ }
272+ if string(s.pasteTail) == end {
273+ s.inPaste = false
274+ s.pasteTail = nil
275+ }
276+}
277+
278+func bytesEqual(a, b []byte) bool {
279+ if len(a) != len(b) {
280+ return false
281+ }
282+ for i := range a {
283+ if a[i] != b[i] {
284+ return false
285+ }
286+ }
287+ return true
288+}
A packages/tui/appearance_protocol_test.go +95 -0
@@ -0,0 +1,95 @@
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{ data []byte }
34+
35+func (s *appearanceBytes) read() (byte, error) {
36+ if len(s.data) == 0 {
37+ return 0, io.EOF
38+ }
39+ b := s.data[0]
40+ s.data = s.data[1:]
41+ return b, nil
42+}
43+
44+func (s *appearanceBytes) peek(time.Duration) (byte, bool, error) {
45+ if len(s.data) == 0 {
46+ return 0, false, nil
47+ }
48+ b := s.data[0]
49+ s.data = s.data[1:]
50+ return b, true, nil
51+}
52+
53+func TestAppearanceSourceConsumesOnlyPendingReplies(t *testing.T) {
54+ input := &appearanceBytes{data: []byte("a\x1b]11;#123456\ab")}
55+ parser := &AppearanceParser{}
56+ parser.SetPendingColors(true)
57+ var events []InputEvent
58+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
59+ var got []byte
60+ for {
61+ b, err := source.ReadByte()
62+ if err == io.EOF {
63+ break
64+ }
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ got = append(got, b)
69+ }
70+ if string(got) != "ab" {
71+ t.Fatalf("remaining bytes = %q, want ab", got)
72+ }
73+ if len(events) != 1 || events[0].Color == nil || events[0].Color.Kind != 11 {
74+ t.Fatalf("events = %#v", events)
75+ }
76+}
77+
78+func TestAppearanceSourceLeavesPasteOpaque(t *testing.T) {
79+ input := &appearanceBytes{data: []byte("\x1b[200~x\x1b]11;#123456\ay\x1b[201~")}
80+ parser := &AppearanceParser{}
81+ parser.SetPendingColors(true)
82+ var events []InputEvent
83+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
84+ reader := NewReader(source.ReadByte)
85+ key, err := reader.Read()
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ if key.Kind != KeyPaste || key.Paste != "x\x1b]11;#123456\ay" {
90+ t.Fatalf("paste = %#v", key)
91+ }
92+ if len(events) != 0 {
93+ t.Fatalf("appearance events leaked from paste: %#v", events)
94+ }
95+}
@@ -0,0 +1,95 @@
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{ data []byte }
34+
35+func (s *appearanceBytes) read() (byte, error) {
36+ if len(s.data) == 0 {
37+ return 0, io.EOF
38+ }
39+ b := s.data[0]
40+ s.data = s.data[1:]
41+ return b, nil
42+}
43+
44+func (s *appearanceBytes) peek(time.Duration) (byte, bool, error) {
45+ if len(s.data) == 0 {
46+ return 0, false, nil
47+ }
48+ b := s.data[0]
49+ s.data = s.data[1:]
50+ return b, true, nil
51+}
52+
53+func TestAppearanceSourceConsumesOnlyPendingReplies(t *testing.T) {
54+ input := &appearanceBytes{data: []byte("a\x1b]11;#123456\ab")}
55+ parser := &AppearanceParser{}
56+ parser.SetPendingColors(true)
57+ var events []InputEvent
58+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
59+ var got []byte
60+ for {
61+ b, err := source.ReadByte()
62+ if err == io.EOF {
63+ break
64+ }
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ got = append(got, b)
69+ }
70+ if string(got) != "ab" {
71+ t.Fatalf("remaining bytes = %q, want ab", got)
72+ }
73+ if len(events) != 1 || events[0].Color == nil || events[0].Color.Kind != 11 {
74+ t.Fatalf("events = %#v", events)
75+ }
76+}
77+
78+func TestAppearanceSourceLeavesPasteOpaque(t *testing.T) {
79+ input := &appearanceBytes{data: []byte("\x1b[200~x\x1b]11;#123456\ay\x1b[201~")}
80+ parser := &AppearanceParser{}
81+ parser.SetPendingColors(true)
82+ var events []InputEvent
83+ source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) })
84+ reader := NewReader(source.ReadByte)
85+ key, err := reader.Read()
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ if key.Kind != KeyPaste || key.Paste != "x\x1b]11;#123456\ay" {
90+ t.Fatalf("paste = %#v", key)
91+ }
92+ if len(events) != 0 {
93+ t.Fatalf("appearance events leaked from paste: %#v", events)
94+ }
95+}
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 +54 -13
@@ -2,6 +2,7 @@ package tui
22
33 import (
44 "bytes"
5+ "fmt"
56 "path/filepath"
67 "strings"
78 "sync"
@@ -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,46 @@ 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+ out.WriteString(prefix)
86+ out.WriteString(token.Value)
87+ out.WriteString(reset)
88+ }
89+ return strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
90+}
91+
92+func terminalTokenStyle(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {
93+ switch {
94+ case typ.InCategory(chroma.Comment):
95+ return th.Muted, false, true
96+ case typ == chroma.LiteralStringEscape || typ == chroma.Error:
97+ return th.Error, false, false
98+ case typ.InSubCategory(chroma.LiteralString):
99+ return th.Tool, false, false
100+ case typ.InSubCategory(chroma.LiteralNumber):
101+ return th.Warning, false, false
102+ case typ.InCategory(chroma.Keyword):
103+ return th.Accent, true, false
104+ case typ.InCategory(chroma.Name):
105+ return TerminalPaletteSlot(14), false, false
106+ default:
107+ return TerminalDefault(), false, false
108+ }
109+}
110+
66111 // highlightResultCache is a simple LRU-ish cache: when it exceeds its
67112 // capacity we drop half of the entries (the oldest, based on insertion
68113 // order). That's good enough since tool_result text doesn't change once
@@ -242,12 +287,8 @@ func (th Theme) chromaStyle() *chroma.Style {
242287
243288 func (th Theme) syntaxKey() string {
244289 syn := th.Syntax
245- outputMode := "256"
246- if th.Terminal.TrueColor {
247- outputMode = "truecolor"
248- }
249290 return strings.Join([]string{
250- outputMode,
291+ fmt.Sprintf("%v:%t:%#v", th.Terminal.ColorDepth(), th.UseTerminalPalette, th.Terminal),
251292 th.SyntaxBaseStyle,
252293 syn.Keyword,
253294 syn.KeywordConstant,
@@ -2,6 +2,7 @@ package tui
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "fmt"
5 "path/filepath"6 "path/filepath"
6 "strings"7 "strings"
7 "sync"8 "sync"
@@ -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,46 @@ 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+ out.WriteString(prefix)
86+ out.WriteString(token.Value)
87+ out.WriteString(reset)
88+ }
89+ return strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
90+}
91+
92+func terminalTokenStyle(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) {
93+ switch {
94+ case typ.InCategory(chroma.Comment):
95+ return th.Muted, false, true
96+ case typ == chroma.LiteralStringEscape || typ == chroma.Error:
97+ return th.Error, false, false
98+ case typ.InSubCategory(chroma.LiteralString):
99+ return th.Tool, false, false
100+ case typ.InSubCategory(chroma.LiteralNumber):
101+ return th.Warning, false, false
102+ case typ.InCategory(chroma.Keyword):
103+ return th.Accent, true, false
104+ case typ.InCategory(chroma.Name):
105+ return TerminalPaletteSlot(14), false, false
106+ default:
107+ return TerminalDefault(), false, false
108+ }
109+}
110+
66 // highlightResultCache is a simple LRU-ish cache: when it exceeds its111 // highlightResultCache is a simple LRU-ish cache: when it exceeds its
67 // capacity we drop half of the entries (the oldest, based on insertion112 // 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 once113 // order). That's good enough since tool_result text doesn't change once
@@ -242,12 +287,8 @@ func (th Theme) chromaStyle() *chroma.Style {
242 287
243 func (th Theme) syntaxKey() string {288 func (th Theme) syntaxKey() string {
244 syn := th.Syntax289 syn := th.Syntax
245- outputMode := "256"
246- if th.Terminal.TrueColor {
247- outputMode = "truecolor"
248- }
249 return strings.Join([]string{290 return strings.Join([]string{
250- outputMode,291+ fmt.Sprintf("%v:%t:%#v", th.Terminal.ColorDepth(), th.UseTerminalPalette, th.Terminal),
251 th.SyntaxBaseStyle,292 th.SyntaxBaseStyle,
252 syn.Keyword,293 syn.Keyword,
253 syn.KeywordConstant,294 syn.KeywordConstant,
A packages/tui/highlight_terminal_test.go +29 -0
@@ -0,0 +1,29 @@
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+}
@@ -0,0 +1,29 @@
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+}
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 +47 -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,81 @@ 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) {
20398 var buf bytes.Buffer
20499 r := NewRenderer(&buf)
205100 r.Resize(20, 2)
206- r.SetTheme(inheritedTestTheme(true))
207- selected := r.theme.SelectionStyle() + "selected" + reset
208- r.Draw([]string{"", selected}, -1, -1)
209101 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())
102+ r.SetTheme(TerminalTheme(terminalTestProfile(ColorDepthTrueColor)))
103+ r.Invalidate()
104+ if strings.Contains(buf.String(), SeqClearScrollback) {
105+ t.Fatalf("theme invalidation cleared scrollback: %q", buf.String())
214106 }
215107 }
@@ -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,81 @@ 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) {
203 var buf bytes.Buffer98 var buf bytes.Buffer
204 r := NewRenderer(&buf)99 r := NewRenderer(&buf)
205 r.Resize(20, 2)100 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()101 buf.Reset()
210-102+ r.SetTheme(TerminalTheme(terminalTestProfile(ColorDepthTrueColor)))
211- r.Draw([]string{"", "plain"}, -1, -1)103+ r.Invalidate()
212- if !strings.Contains(buf.String(), MoveTo(1, 1)) {104+ if strings.Contains(buf.String(), SeqClearScrollback) {
213- t.Fatalf("selection repaint did not include unchanged rows: %q", buf.String())105+ t.Fatalf("theme invalidation cleared scrollback: %q", buf.String())
214 }106 }
215 }107 }
M packages/tui/theme_loader.go +267 -133
@@ -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,96 +180,76 @@ 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+ SyntaxBaseStyle: "",
211+ Syntax: terminalSyntaxTheme(),
212+ }
213+ if profile.HasForeground && profile.HasBackground {
214+ t.Muted = t.DimColor(profile.Foreground, 45)
210215 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)
216+ t.UserBubbleBG = blendTerminalColors(profile.Background, profile.Foreground, 12)
217+ t.SelectionBG = blendTerminalColors(profile.Background, profile.Foreground, 25)
215218 }
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.
219+ // Auto must leave both the terminal background and cursor color owned by
220+ // the terminal. Custom themes can intentionally override Background.
223221 t.Background = nil
224222 return t
225223 }
226224
225+func terminalSyntaxTheme() SyntaxTheme {
226+ return SyntaxTheme{
227+ Keyword: "ansi:12 bold",
228+ KeywordConstant: "ansi:12",
229+ KeywordDeclaration: "ansi:12",
230+ KeywordNamespace: "ansi:12",
231+ KeywordReserved: "ansi:12 bold",
232+ KeywordType: "ansi:14",
233+ NameBuiltin: "ansi:14",
234+ NameFunction: "ansi:10",
235+ NameClass: "ansi:10 bold",
236+ NameDecorator: "ansi:13",
237+ LiteralString: "ansi:10",
238+ LiteralStringEscape: "ansi:9",
239+ LiteralNumber: "ansi:11",
240+ Comment: "ansi:8 italic",
241+ CommentPreproc: "ansi:13",
242+ Operator: "default",
243+ Punctuation: "default",
244+ Text: "default",
245+ }
246+}
247+
227248 func withTerminalProfile(base, detected Theme) Theme {
228249 base.Terminal = detected.Terminal
229250 return base
230251 }
231252
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-
261253 func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
262254 fromRGB, _ := rgbForTerminalColor(from)
263255 toRGB, _ := rgbForTerminalColor(to)
@@ -269,67 +261,150 @@ func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
269261 )
270262 }
271263
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)
264+const maxThemeFileSize = 1 << 20
265+
266+// ThemeSource is an immutable, fully validated custom theme revision. Runtime
267+// profile changes resolve this value without touching the filesystem again.
268+type ThemeSource struct {
269+ Name string
270+ Path string
271+ Digest [sha256.Size]byte
272+ File ThemeFile
279273 }
280274
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
275+// ThemeResolution is the pure result of resolving a preference against one
276+// terminal snapshot and, for custom preferences, one accepted source file.
277+type ThemeResolution struct {
278+ Theme Theme
279+ Name string
280+}
281+
282+// ThemePreference separates the persisted setting from the process-only
283+// ZUT_THEME override. Only dark/light force a running session.
284+type ThemePreference struct {
285+ Persisted string
286+ Effective string
287+ Forced bool
288+}
289+
290+func ResolveThemePreference(persisted, env string) ThemePreference {
291+ preference := ThemePreference{Persisted: strings.TrimSpace(persisted), Effective: strings.TrimSpace(persisted)}
292+ switch strings.ToLower(strings.TrimSpace(env)) {
293+ case "dark", "light":
294+ preference.Effective = strings.ToLower(strings.TrimSpace(env))
295+ preference.Forced = true
296+ case "", "auto":
297+ // Persisted setting remains effective.
298+ }
299+ if preference.Effective == "" {
300+ preference.Effective = "auto"
301+ }
302+ return preference
303+}
304+
305+// LoadThemeSource resolves and validates a custom source. Built-in selections
306+// have no source and return nil, nil. The file limit prevents a polling reload
307+// from allocating an unbounded partial write.
308+func LoadThemeSource(zutHome, preference string) (*ThemeSource, error) {
309+ path, err := resolveThemePath(zutHome, preference)
310+ if err != nil || path == "" || strings.HasPrefix(path, "builtin:") {
311+ return nil, err
285312 }
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
313+ info, err := os.Stat(path)
314+ if err != nil {
315+ return nil, fmt.Errorf("theme %s: %w", path, err)
316+ }
317+ if info.Size() > maxThemeFileSize {
318+ return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize)
293319 }
294320 b, err := os.ReadFile(path)
295321 if err != nil {
296- return detected, "", err
322+ return nil, fmt.Errorf("read theme %s: %w", path, err)
297323 }
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)
324+ if len(b) > maxThemeFileSize {
325+ return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize)
326+ }
327+ var file ThemeFile
328+ if err := json.Unmarshal(b, &file); err != nil {
329+ return nil, fmt.Errorf("parse theme %s: %w", path, err)
330+ }
331+ if file.Name == "" {
332+ file.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
333+ }
334+ return &ThemeSource{Name: file.Name, Path: path, Digest: sha256.Sum256(b), File: file}, nil
335+}
336+
337+// ResolveTheme performs no I/O. Custom themes always overlay TerminalTheme,
338+// so omitted roles continue to follow the controlling terminal.
339+func ResolveTheme(preference string, source *ThemeSource, profile TerminalProfile) ThemeResolution {
340+ preference = strings.ToLower(strings.TrimSpace(preference))
341+ switch preference {
342+ case "", "auto", "default", "system":
343+ return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"}
344+ case "dark":
345+ return ThemeResolution{Theme: withTerminalProfile(Dark, Theme{Terminal: profile}), Name: "dark"}
346+ case "light":
347+ return ThemeResolution{Theme: withTerminalProfile(Light, Theme{Terminal: profile}), Name: "light"}
348+ }
349+ if source == nil {
350+ return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"}
351+ }
352+ base := TerminalTheme(profile)
353+ base = applyThemeOverrides(base, source.File.Overrides)
354+ base = applyThemeOverrides(base, source.File.Colors.Base)
355+ if profileIsLight(profile) {
356+ if source.File.Colors.HasLight {
357+ base = applyThemeOverrides(base, source.File.Colors.Light)
358+ } else if source.File.Colors.HasDark {
359+ base = applyThemeOverrides(base, source.File.Colors.Dark)
318360 }
361+ } else if source.File.Colors.HasDark {
362+ base = applyThemeOverrides(base, source.File.Colors.Dark)
363+ } else if source.File.Colors.HasLight {
364+ base = applyThemeOverrides(base, source.File.Colors.Light)
365+ }
366+ return ThemeResolution{Theme: base, Name: source.Name}
367+}
368+
369+func profileIsLight(profile TerminalProfile) bool {
370+ if profile.SchemeKnown {
371+ return profile.Light
372+ }
373+ if profile.HasBackground {
374+ r, g, b := profile.Background.R, profile.Background.G, profile.Background.B
375+ return 0.2126*float64(r)+0.7152*float64(g)+0.0722*float64(b) >= 127.5
376+ }
377+ return false
378+}
379+
380+// DetectThemeWithCustom performs the initial bounded profile query then uses
381+// the same loader and pure resolver as runtime selection.
382+func DetectThemeWithCustom(zutHome, preferred string, timeout time.Duration) (Theme, string, error) {
383+ detected := DetectThemeFromBackground(timeout)
384+ return LoadThemeFromHome(zutHome, preferred, detected)
385+}
386+
387+// LoadThemeFromHome is retained as the startup convenience wrapper. New
388+// runtime code should keep the ThemeSource and call ResolveTheme directly.
389+func LoadThemeFromHome(zutHome, preferred string, detected Theme) (Theme, string, error) {
390+ profile := detected.Terminal
391+ source, err := LoadThemeSource(zutHome, preferred)
392+ if err != nil {
393+ return TerminalTheme(profile), "", err
319394 }
320- return base, tf.Name, nil
395+ returned := ResolveTheme(preferred, source, profile)
396+ return returned.Theme, returned.Name, nil
321397 }
322398
323399 // AvailableThemes returns built-in and user-installed themes suitable
324400 // for a settings picker. Invalid JSON files are skipped.
325401 func AvailableThemes(zutHome string) []ThemeOption {
326402 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},
403+ {Value: "auto", Label: "auto", Description: "follow terminal colors and appearance", Builtin: true},
329404 {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true},
330405 {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},
331406 }
332- seen := map[string]bool{"auto": true, "inherited": true, "dark": true, "light": true}
407+ seen := map[string]bool{"auto": true, "dark": true, "light": true}
333408 paths, _ := themeFilesIn(filepath.Join(zutHome, "themes"))
334409 sort.Strings(paths)
335410 for _, path := range paths {
@@ -399,8 +474,6 @@ func resolveThemePath(zutHome, preferred string) (string, error) {
399474 switch strings.ToLower(preferred) {
400475 case "", "auto", "default", "system":
401476 return "", nil
402- case "inherited", "terminal", "terminal-inherited":
403- return "builtin:inherited", nil
404477 case "dark":
405478 return "builtin:dark", nil
406479 case "light":
@@ -569,11 +642,72 @@ func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme {
569642 return s
570643 }
571644
572-func IsLightTheme(th Theme) bool {
573- if th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG {
574- return true
645+func validateExplicitTerminalColor(color TerminalColor) error {
646+ switch color.Mode {
647+ case terminalColor256:
648+ if color.Index < 0 || color.Index > 255 {
649+ return fmt.Errorf("xterm-256 index %d is outside 0..255", color.Index)
650+ }
651+ case terminalColorANSI:
652+ if _, ok := ansiSGRToXtermIndex(color.Index); !ok {
653+ return fmt.Errorf("ANSI SGR %d is not a palette color", color.Index)
654+ }
655+ case terminalColorRGB:
656+ if color.R < 0 || color.R > 255 || color.G < 0 || color.G > 255 || color.B < 0 || color.B > 255 {
657+ return fmt.Errorf("RGB color (%d,%d,%d) is outside 0..255", color.R, color.G, color.B)
658+ }
659+ default:
660+ return errors.New("theme colors must be explicit xterm-256, ANSI, or RGB values")
661+ }
662+ return nil
663+}
664+
665+func validateThemeFile(file ThemeFile) error {
666+ for _, overrides := range []ThemeOverrides{file.Overrides, file.Colors.Base, file.Colors.Dark, file.Colors.Light} {
667+ if err := validateThemeOverrides(overrides); err != nil {
668+ return err
669+ }
670+ }
671+ return nil
672+}
673+
674+func validateThemeOverrides(o ThemeOverrides) error {
675+ 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} {
676+ if color != nil {
677+ if err := validateExplicitTerminalColor(color.TerminalColor); err != nil {
678+ return err
679+ }
680+ }
681+ }
682+ if len(o.SpinnerFrames) > 64 {
683+ return errors.New("spinner_frames has more than 64 entries")
684+ }
685+ for _, frame := range o.SpinnerFrames {
686+ if frame == "" || len([]rune(frame)) > 16 {
687+ return fmt.Errorf("invalid spinner frame %q", frame)
688+ }
689+ }
690+ if o.SpinnerIntervalMS != nil && (*o.SpinnerIntervalMS < 10 || *o.SpinnerIntervalMS > 10000) {
691+ return fmt.Errorf("spinner_interval_ms %d is outside 10..10000", *o.SpinnerIntervalMS)
692+ }
693+ if o.SyntaxBaseStyle != nil && *o.SyntaxBaseStyle != "" {
694+ if _, ok := styles.Registry[*o.SyntaxBaseStyle]; !ok {
695+ return fmt.Errorf("unknown syntax_base_style %q", *o.SyntaxBaseStyle)
696+ }
697+ }
698+ 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} {
699+ if entry == nil {
700+ continue
701+ }
702+ if _, err := chroma.ParseStyleEntry(*entry); err != nil {
703+ return fmt.Errorf("invalid syntax style %q: %w", *entry, err)
704+ }
575705 }
576- return th.Inherited && th.Terminal.SchemeKnown && th.Terminal.Light
706+ return nil
707+}
708+
709+func IsLightTheme(th Theme) bool {
710+ return th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG
577711 }
578712
579713 func isLightTheme(th Theme) bool { return IsLightTheme(th) }
@@ -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,96 +180,76 @@ 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+ SyntaxBaseStyle: "",
199- ansiBrightBlue, ansiBlue, ansiBrightCyan, ansiCyan, ansiBrightMagenta, ansiMagenta)211+ Syntax: terminalSyntaxTheme(),
200- t.Assistant = t.Accent212+ }
201- t.Tool = terminalPaletteColor(t.Terminal, t.Tool, ansiBrightGreen, ansiGreen)213+ if profile.HasForeground && profile.HasBackground {
202- t.Error = terminalPaletteColor(t.Terminal, t.Error, ansiBrightRed, ansiRed)214+ t.Muted = t.DimColor(profile.Foreground, 45)
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)
210 t.ToolOut = t.Muted215 t.ToolOut = t.Muted
211- background := t.Terminal.Background216+ t.UserBubbleBG = blendTerminalColors(profile.Background, profile.Foreground, 12)
212- foreground := t.Terminal.Foreground217+ t.SelectionBG = blendTerminalColors(profile.Background, profile.Foreground, 25)
213- t.UserBubbleBG = blendTerminalColors(background, foreground, 12)
214- t.SelectionBG = blendTerminalColors(background, foreground, 25)
215 }218 }
216-219+ // Auto must leave both the terminal background and cursor color owned by
217- t.UserBubbleFG = t.FG220+ // 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 = nil221 t.Background = nil
224 return t222 return t
225 }223 }
226 224
225+func terminalSyntaxTheme() SyntaxTheme {
226+ return SyntaxTheme{
227+ Keyword: "ansi:12 bold",
228+ KeywordConstant: "ansi:12",
229+ KeywordDeclaration: "ansi:12",
230+ KeywordNamespace: "ansi:12",
231+ KeywordReserved: "ansi:12 bold",
232+ KeywordType: "ansi:14",
233+ NameBuiltin: "ansi:14",
234+ NameFunction: "ansi:10",
235+ NameClass: "ansi:10 bold",
236+ NameDecorator: "ansi:13",
237+ LiteralString: "ansi:10",
238+ LiteralStringEscape: "ansi:9",
239+ LiteralNumber: "ansi:11",
240+ Comment: "ansi:8 italic",
241+ CommentPreproc: "ansi:13",
242+ Operator: "default",
243+ Punctuation: "default",
244+ Text: "default",
245+ }
246+}
247+
227 func withTerminalProfile(base, detected Theme) Theme {248 func withTerminalProfile(base, detected Theme) Theme {
228 base.Terminal = detected.Terminal249 base.Terminal = detected.Terminal
229 return base250 return base
230 }251 }
231 252
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 {253 func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
262 fromRGB, _ := rgbForTerminalColor(from)254 fromRGB, _ := rgbForTerminalColor(from)
263 toRGB, _ := rgbForTerminalColor(to)255 toRGB, _ := rgbForTerminalColor(to)
@@ -269,67 +261,150 @@ func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
269 )261 )
270 }262 }
271 263
272-// LoadThemeFromHome applies a custom theme from $ZUT_HOME/themes/*.json264+const maxThemeFileSize = 1 << 20
273-// to detected. Empty/auto/default keeps the built-in detected theme.265+
274-// If preferred is set, it may be a theme name, a basename without266+// ThemeSource is an immutable, fully validated custom theme revision. Runtime
275-// .json, or an absolute/relative path.267+// profile changes resolve this value without touching the filesystem again.
276-func DetectThemeWithCustom(zutHome, preferred string, timeout time.Duration) (Theme, string, error) {268+type ThemeSource struct {
277- detected := DetectThemeFromBackground(timeout)269+ Name string
278- return LoadThemeFromHome(zutHome, preferred, detected)270+ Path string
271+ Digest [sha256.Size]byte
272+ File ThemeFile
279 }273 }
280 274
281-func LoadThemeFromHome(zutHome, preferred string, detected Theme) (Theme, string, error) {275+// ThemeResolution is the pure result of resolving a preference against one
282- path, err := resolveThemePath(zutHome, preferred)276+// terminal snapshot and, for custom preferences, one accepted source file.
283- if err != nil || path == "" {277+type ThemeResolution struct {
284- return detected, "", err278+ Theme Theme
279+ Name string
280+}
281+
282+// ThemePreference separates the persisted setting from the process-only
283+// ZUT_THEME override. Only dark/light force a running session.
284+type ThemePreference struct {
285+ Persisted string
286+ Effective string
287+ Forced bool
288+}
289+
290+func ResolveThemePreference(persisted, env string) ThemePreference {
291+ preference := ThemePreference{Persisted: strings.TrimSpace(persisted), Effective: strings.TrimSpace(persisted)}
292+ switch strings.ToLower(strings.TrimSpace(env)) {
293+ case "dark", "light":
294+ preference.Effective = strings.ToLower(strings.TrimSpace(env))
295+ preference.Forced = true
296+ case "", "auto":
297+ // Persisted setting remains effective.
298+ }
299+ if preference.Effective == "" {
300+ preference.Effective = "auto"
301+ }
302+ return preference
303+}
304+
305+// LoadThemeSource resolves and validates a custom source. Built-in selections
306+// have no source and return nil, nil. The file limit prevents a polling reload
307+// from allocating an unbounded partial write.
308+func LoadThemeSource(zutHome, preference string) (*ThemeSource, error) {
309+ path, err := resolveThemePath(zutHome, preference)
310+ if err != nil || path == "" || strings.HasPrefix(path, "builtin:") {
311+ return nil, err
285 }312 }
286- switch path {313+ info, err := os.Stat(path)
287- case "builtin:inherited":314+ if err != nil {
288- return InheritedTheme(detected), "inherited", nil315+ return nil, fmt.Errorf("theme %s: %w", path, err)
289- case "builtin:dark":316+ }
290- return withTerminalProfile(Dark, detected), "dark", nil317+ if info.Size() > maxThemeFileSize {
291- case "builtin:light":318+ return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize)
292- return withTerminalProfile(Light, detected), "light", nil
293 }319 }
294 b, err := os.ReadFile(path)320 b, err := os.ReadFile(path)
295 if err != nil {321 if err != nil {
296- return detected, "", err322+ return nil, fmt.Errorf("read theme %s: %w", path, err)
297 }323 }
298- var tf ThemeFile324+ if len(b) > maxThemeFileSize {
299- if err := json.Unmarshal(b, &tf); err != nil {325+ return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize)
300- return detected, "", fmt.Errorf("parse theme %s: %w", path, err)326+ }
301- }327+ var file ThemeFile
302- if tf.Name == "" {328+ if err := json.Unmarshal(b, &file); err != nil {
303- tf.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))329+ return nil, fmt.Errorf("parse theme %s: %w", path, err)
304- }330+ }
305- base := applyThemeOverrides(detected, tf.Overrides)331+ if file.Name == "" {
306- base = applyThemeOverrides(base, tf.Colors.Base)332+ file.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
307- if isLightTheme(detected) {333+ }
308- if tf.Colors.HasLight {334+ return &ThemeSource{Name: file.Name, Path: path, Digest: sha256.Sum256(b), File: file}, nil
309- base = applyThemeOverrides(base, tf.Colors.Light)335+}
310- } else if tf.Colors.HasDark {336+
311- base = applyThemeOverrides(base, tf.Colors.Dark)337+// ResolveTheme performs no I/O. Custom themes always overlay TerminalTheme,
312- }338+// so omitted roles continue to follow the controlling terminal.
313- } else {339+func ResolveTheme(preference string, source *ThemeSource, profile TerminalProfile) ThemeResolution {
314- if tf.Colors.HasDark {340+ preference = strings.ToLower(strings.TrimSpace(preference))
315- base = applyThemeOverrides(base, tf.Colors.Dark)341+ switch preference {
316- } else if tf.Colors.HasLight {342+ case "", "auto", "default", "system":
317- base = applyThemeOverrides(base, tf.Colors.Light)343+ return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"}
344+ case "dark":
345+ return ThemeResolution{Theme: withTerminalProfile(Dark, Theme{Terminal: profile}), Name: "dark"}
346+ case "light":
347+ return ThemeResolution{Theme: withTerminalProfile(Light, Theme{Terminal: profile}), Name: "light"}
348+ }
349+ if source == nil {
350+ return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"}
351+ }
352+ base := TerminalTheme(profile)
353+ base = applyThemeOverrides(base, source.File.Overrides)
354+ base = applyThemeOverrides(base, source.File.Colors.Base)
355+ if profileIsLight(profile) {
356+ if source.File.Colors.HasLight {
357+ base = applyThemeOverrides(base, source.File.Colors.Light)
358+ } else if source.File.Colors.HasDark {
359+ base = applyThemeOverrides(base, source.File.Colors.Dark)
318 }360 }
361+ } else if source.File.Colors.HasDark {
362+ base = applyThemeOverrides(base, source.File.Colors.Dark)
363+ } else if source.File.Colors.HasLight {
364+ base = applyThemeOverrides(base, source.File.Colors.Light)
365+ }
366+ return ThemeResolution{Theme: base, Name: source.Name}
367+}
368+
369+func profileIsLight(profile TerminalProfile) bool {
370+ if profile.SchemeKnown {
371+ return profile.Light
372+ }
373+ if profile.HasBackground {
374+ r, g, b := profile.Background.R, profile.Background.G, profile.Background.B
375+ return 0.2126*float64(r)+0.7152*float64(g)+0.0722*float64(b) >= 127.5
376+ }
377+ return false
378+}
379+
380+// DetectThemeWithCustom performs the initial bounded profile query then uses
381+// the same loader and pure resolver as runtime selection.
382+func DetectThemeWithCustom(zutHome, preferred string, timeout time.Duration) (Theme, string, error) {
383+ detected := DetectThemeFromBackground(timeout)
384+ return LoadThemeFromHome(zutHome, preferred, detected)
385+}
386+
387+// LoadThemeFromHome is retained as the startup convenience wrapper. New
388+// runtime code should keep the ThemeSource and call ResolveTheme directly.
389+func LoadThemeFromHome(zutHome, preferred string, detected Theme) (Theme, string, error) {
390+ profile := detected.Terminal
391+ source, err := LoadThemeSource(zutHome, preferred)
392+ if err != nil {
393+ return TerminalTheme(profile), "", err
319 }394 }
320- return base, tf.Name, nil395+ returned := ResolveTheme(preferred, source, profile)
396+ return returned.Theme, returned.Name, nil
321 }397 }
322 398
323 // AvailableThemes returns built-in and user-installed themes suitable399 // AvailableThemes returns built-in and user-installed themes suitable
324 // for a settings picker. Invalid JSON files are skipped.400 // for a settings picker. Invalid JSON files are skipped.
325 func AvailableThemes(zutHome string) []ThemeOption {401 func AvailableThemes(zutHome string) []ThemeOption {
326 out := []ThemeOption{402 out := []ThemeOption{
327- {Value: "auto", Label: "auto", Description: "detect terminal background and use zut defaults", Builtin: true},403+ {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},404 {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true},
330 {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},405 {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},
331 }406 }
332- seen := map[string]bool{"auto": true, "inherited": true, "dark": true, "light": true}407+ seen := map[string]bool{"auto": true, "dark": true, "light": true}
333 paths, _ := themeFilesIn(filepath.Join(zutHome, "themes"))408 paths, _ := themeFilesIn(filepath.Join(zutHome, "themes"))
334 sort.Strings(paths)409 sort.Strings(paths)
335 for _, path := range paths {410 for _, path := range paths {
@@ -399,8 +474,6 @@ func resolveThemePath(zutHome, preferred string) (string, error) {
399 switch strings.ToLower(preferred) {474 switch strings.ToLower(preferred) {
400 case "", "auto", "default", "system":475 case "", "auto", "default", "system":
401 return "", nil476 return "", nil
402- case "inherited", "terminal", "terminal-inherited":
403- return "builtin:inherited", nil
404 case "dark":477 case "dark":
405 return "builtin:dark", nil478 return "builtin:dark", nil
406 case "light":479 case "light":
@@ -569,11 +642,72 @@ func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme {
569 return s642 return s
570 }643 }
571 644
572-func IsLightTheme(th Theme) bool {645+func validateExplicitTerminalColor(color TerminalColor) error {
573- if th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG {646+ switch color.Mode {
574- return true647+ case terminalColor256:
648+ if color.Index < 0 || color.Index > 255 {
649+ return fmt.Errorf("xterm-256 index %d is outside 0..255", color.Index)
650+ }
651+ case terminalColorANSI:
652+ if _, ok := ansiSGRToXtermIndex(color.Index); !ok {
653+ return fmt.Errorf("ANSI SGR %d is not a palette color", color.Index)
654+ }
655+ case terminalColorRGB:
656+ if color.R < 0 || color.R > 255 || color.G < 0 || color.G > 255 || color.B < 0 || color.B > 255 {
657+ return fmt.Errorf("RGB color (%d,%d,%d) is outside 0..255", color.R, color.G, color.B)
658+ }
659+ default:
660+ return errors.New("theme colors must be explicit xterm-256, ANSI, or RGB values")
661+ }
662+ return nil
663+}
664+
665+func validateThemeFile(file ThemeFile) error {
666+ for _, overrides := range []ThemeOverrides{file.Overrides, file.Colors.Base, file.Colors.Dark, file.Colors.Light} {
667+ if err := validateThemeOverrides(overrides); err != nil {
668+ return err
669+ }
670+ }
671+ return nil
672+}
673+
674+func validateThemeOverrides(o ThemeOverrides) error {
675+ 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} {
676+ if color != nil {
677+ if err := validateExplicitTerminalColor(color.TerminalColor); err != nil {
678+ return err
679+ }
680+ }
681+ }
682+ if len(o.SpinnerFrames) > 64 {
683+ return errors.New("spinner_frames has more than 64 entries")
684+ }
685+ for _, frame := range o.SpinnerFrames {
686+ if frame == "" || len([]rune(frame)) > 16 {
687+ return fmt.Errorf("invalid spinner frame %q", frame)
688+ }
689+ }
690+ if o.SpinnerIntervalMS != nil && (*o.SpinnerIntervalMS < 10 || *o.SpinnerIntervalMS > 10000) {
691+ return fmt.Errorf("spinner_interval_ms %d is outside 10..10000", *o.SpinnerIntervalMS)
692+ }
693+ if o.SyntaxBaseStyle != nil && *o.SyntaxBaseStyle != "" {
694+ if _, ok := styles.Registry[*o.SyntaxBaseStyle]; !ok {
695+ return fmt.Errorf("unknown syntax_base_style %q", *o.SyntaxBaseStyle)
696+ }
697+ }
698+ 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} {
699+ if entry == nil {
700+ continue
701+ }
702+ if _, err := chroma.ParseStyleEntry(*entry); err != nil {
703+ return fmt.Errorf("invalid syntax style %q: %w", *entry, err)
704+ }
575 }705 }
576- return th.Inherited && th.Terminal.SchemeKnown && th.Terminal.Light706+ return nil
707+}
708+
709+func IsLightTheme(th Theme) bool {
710+ return th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG
577 }711 }
578 712
579 func isLightTheme(th Theme) bool { return IsLightTheme(th) }713 func isLightTheme(th Theme) bool { return IsLightTheme(th) }
M packages/tui/theme_loader_test.go +34 -8
@@ -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,8 +66,8 @@ 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
@@ -84,8 +84,8 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
8484 if th.SpinnerIntervalMS != 120 {
8585 t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)
8686 }
87- if th.FG != Light.FG {
88- t.Fatalf("fg = %#v, want inherited light fg %#v", th.FG, Light.FG)
87+ if th.FG != TerminalDefault() {
88+ t.Fatalf("fg = %#v, want terminal default", th.FG)
8989 }
9090 }
9191
@@ -102,8 +102,8 @@ func TestLoadThemeIgnoresLegacySpinnerMessages(t *testing.T) {
102102 if len(th.SpinnerFrames) != len(Light.SpinnerFrames) || th.SpinnerIntervalMS != Light.SpinnerIntervalMS {
103103 t.Fatalf("legacy spinner_messages changed spinner appearance: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS)
104104 }
105- if th.FG != Light.FG {
106- t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG)
105+ if th.FG != TerminalDefault() {
106+ t.Fatalf("fg = %#v, want terminal default", th.FG)
107107 }
108108 }
109109
@@ -145,3 +145,29 @@ func TestLoadThemeQuantizesRGBSemanticColorsWithoutTrueColor(t *testing.T) {
145145 t.Fatalf("RGB accent fallback = %q, want xterm-256 index %d", got, want)
146146 }
147147 }
148+
149+func TestLoadThemeRejectsInvalidNestedValues(t *testing.T) {
150+ for _, body := range []string{
151+ `{"colors":{"dark":{"accent":999}}}`,
152+ `{"colors":{"light":{"syntax":{"keyword":"not a style"}}}}`,
153+ `{"spinner_interval_ms":1}`,
154+ `{"syntax_base_style":"not-a-style"}`,
155+ } {
156+ home := writeThemeFile(t, "invalid", body)
157+ if _, err := LoadThemeSource(home, "invalid"); err == nil {
158+ t.Fatalf("LoadThemeSource(%s) succeeded", body)
159+ }
160+ }
161+}
162+
163+func TestResolveThemeCustomUsesReportedScheme(t *testing.T) {
164+ home := writeThemeFile(t, "split", `{"colors":{"dark":{"accent":20},"light":{"accent":21}}}`)
165+ source, err := LoadThemeSource(home, "split")
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+ profile := TerminalProfile{SchemeKnown: true, Light: true}
170+ if got := ResolveTheme("split", source, profile).Theme.Accent; got != Color256(21) {
171+ t.Fatalf("light branch accent = %#v, want 21", got)
172+ }
173+}
@@ -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,8 +66,8 @@ 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
@@ -84,8 +84,8 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
84 if th.SpinnerIntervalMS != 120 {84 if th.SpinnerIntervalMS != 120 {
85 t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)85 t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)
86 }86 }
87- if th.FG != Light.FG {87+ if th.FG != TerminalDefault() {
88- t.Fatalf("fg = %#v, want inherited light fg %#v", th.FG, Light.FG)88+ t.Fatalf("fg = %#v, want terminal default", th.FG)
89 }89 }
90 }90 }
91 91
@@ -102,8 +102,8 @@ func TestLoadThemeIgnoresLegacySpinnerMessages(t *testing.T) {
102 if len(th.SpinnerFrames) != len(Light.SpinnerFrames) || th.SpinnerIntervalMS != Light.SpinnerIntervalMS {102 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)103 t.Fatalf("legacy spinner_messages changed spinner appearance: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS)
104 }104 }
105- if th.FG != Light.FG {105+ if th.FG != TerminalDefault() {
106- t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG)106+ t.Fatalf("fg = %#v, want terminal default", th.FG)
107 }107 }
108 }108 }
109 109
@@ -145,3 +145,29 @@ func TestLoadThemeQuantizesRGBSemanticColorsWithoutTrueColor(t *testing.T) {
145 t.Fatalf("RGB accent fallback = %q, want xterm-256 index %d", got, want)145 t.Fatalf("RGB accent fallback = %q, want xterm-256 index %d", got, want)
146 }146 }
147 }147 }
148+
149+func TestLoadThemeRejectsInvalidNestedValues(t *testing.T) {
150+ for _, body := range []string{
151+ `{"colors":{"dark":{"accent":999}}}`,
152+ `{"colors":{"light":{"syntax":{"keyword":"not a style"}}}}`,
153+ `{"spinner_interval_ms":1}`,
154+ `{"syntax_base_style":"not-a-style"}`,
155+ } {
156+ home := writeThemeFile(t, "invalid", body)
157+ if _, err := LoadThemeSource(home, "invalid"); err == nil {
158+ t.Fatalf("LoadThemeSource(%s) succeeded", body)
159+ }
160+ }
161+}
162+
163+func TestResolveThemeCustomUsesReportedScheme(t *testing.T) {
164+ home := writeThemeFile(t, "split", `{"colors":{"dark":{"accent":20},"light":{"accent":21}}}`)
165+ source, err := LoadThemeSource(home, "split")
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+ profile := TerminalProfile{SchemeKnown: true, Light: true}
170+ if got := ResolveTheme("split", source, profile).Theme.Accent; got != Color256(21) {
171+ t.Fatalf("light branch accent = %#v, want 21", got)
172+ }
173+}
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