Merge pull request #156 from bnema/feat/terminal-owned-live-themes
feat(themes): follow live terminal appearance
25 files · +1802 -967
M README.md +1 -1
| @@ -450,7 +450,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 450 | 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 | 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 | 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 | 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 | 456 | ### `/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 @@ | |||
| 1 | 1 | # zut themes | |
| 2 | 2 | ||
| 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. | |
| 8 | 5 | ||
| 9 | - | ## Where themes live | |
| 6 | + | ## Built-in choices | |
| 10 | 7 | ||
| 11 | - | User themes are discovered from: | |
| 8 | + | Open `/settings` and select **color theme**. | |
| 12 | 9 | ||
| 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. | |
| 16 | 17 | ||
| 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`. | |
| 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 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 | |
| 33 | 27 | ||
| 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). | |
| 36 | 34 | ||
| 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. | |
| 44 | 39 | ||
| 45 | - | ## Theme proposals | |
| 40 | + | ## Theme files and reload | |
| 46 | 41 | ||
| 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 | + | ``` | |
| 50 | 47 | ||
| 51 | - | Theme files bundled with extensions are discovered in-place from loaded | |
| 52 | - | extension directories: | |
| 48 | + | Extension-owned themes are loaded in place from: | |
| 53 | 49 | ||
| 54 | 50 | ```text | |
| 55 | 51 | $ZUT_HOME/extensions/<extension>/theme.json | |
| @@ -58,35 +54,25 @@ $ZUT_HOME/extensions/<extension>/themes/theme.json | |||
| 58 | 54 | <project>/.zut/extensions/<extension>/themes/theme.json | |
| 59 | 55 | ``` | |
| 60 | 56 | ||
| 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. | |
| 66 | 62 | ||
| 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 | |
| 79 | 64 | ||
| 80 | 65 | ```json | |
| 81 | 66 | { | |
| 82 | 67 | "name": "pink-accent", | |
| 83 | - | "colors": { | |
| 84 | - | "accent": 204 | |
| 85 | - | } | |
| 68 | + | "colors": { "accent": 204 } | |
| 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 | 77 | ```json | |
| 92 | 78 | { | |
| @@ -98,111 +84,17 @@ One color per mode: | |||
| 98 | 84 | } | |
| 99 | 85 | ``` | |
| 100 | 86 | ||
| 101 | - | Spinner-only: | |
| 102 | - | ||
| 103 | 87 | ```json | |
| 104 | 88 | { | |
| 105 | - | "name": "custom-spinner", | |
| 106 | - | "description": "Only changes the busy spinner.", | |
| 89 | + | "name": "spinner-only", | |
| 107 | 90 | "spinner_frames": ["◢", "◣", "◤", "◥"], | |
| 108 | 91 | "spinner_interval_ms": 120 | |
| 109 | 92 | } | |
| 110 | 93 | ``` | |
| 111 | 94 | ||
| 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 | |
| 115 | 96 | ||
| 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: | |
| 206 | 98 | ||
| 207 | 99 | ```json | |
| 208 | 100 | 254 | |
| @@ -212,73 +104,31 @@ Every color field accepts the following JSON forms: | |||
| 212 | 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, 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. | |
| 257 | 112 | ||
| 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. | |
| 259 | 118 | ||
| 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. | |
| 262 | 121 | ||
| 263 | - | Supported syntax override keys: | |
| 122 | + | ## Syntax | |
| 264 | 123 | ||
| 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: | |
| 273 | 125 | ||
| 274 | 126 | ```json | |
| 275 | 127 | { | |
| 276 | 128 | "colors": { | |
| 277 | 129 | "dark": { | |
| 278 | - | "syntax_base_style": "monokai", | |
| 279 | 130 | "syntax": { | |
| 280 | - | "keyword": "#f05b8d", | |
| 281 | - | "name_function": "#b675f1", | |
| 131 | + | "keyword": "#f05b8d bold", | |
| 282 | 132 | "literal_string": "#58c760", | |
| 283 | 133 | "comment": "#a1a1a1 italic" | |
| 284 | 134 | } | |
| @@ -287,36 +137,7 @@ Example: | |||
| 287 | 137 | } | |
| 288 | 138 | ``` | |
| 289 | 139 | ||
| 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 themes | 1 | # zut themes | ||
| 2 | 2 | ||||
| 3 | - | zut themes are JSON files that override any subset of the built-in | 3 | + | 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 one | 4 | + | 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 live | 6 | + | ## Built-in choices |
| 10 | 7 | ||||
| 11 | - | User themes are discovered from: | 8 | + | Open `/settings` and select **color theme**. |
| 12 | 9 | ||||
| 13 | - | ```text | 10 | + | - **auto** (default) follows the terminal default foreground and background, |
| 14 | - | $ZUT_HOME/themes/*.json | 11 | + | 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 are | 18 | + | `inherited` is no longer a theme choice. A persisted `inherited` value resets |
| 18 | - | saved in `$ZUT_HOME/config.json` and apply immediately. If the selected | 19 | + | 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 or | 26 | + | ## 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 the | 28 | + | 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 use | 35 | + | 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 terminals | 36 | + | does not arrive, zut remains usable with terminal default SGR colors and ANSI |
| 39 | - | that advertise truecolor, zut emits RGB values directly and expands indexed | 37 | + | slots. Existing scrollback is never recolored or cleared by a theme change; |
| 40 | - | values through the xterm palette; otherwise RGB values fall back to the | 38 | + | 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 proposals | 40 | + | ## Theme files and reload |
| 46 | 41 | ||||
| 47 | - | - **shades of gray (truecolor)** — a neutral grayscale palette for the UI and | 42 | + | User themes live under: |
| 48 | - | syntax highlighting, with an xterm-256 fallback when direct color is | 43 | + | |
| 49 | - | unavailable. | 44 | + | ```text |
| 45 | + | $ZUT_HOME/themes/*.json | |||
| 46 | + | ``` | |||
| 50 | 47 | ||||
| 51 | - | Theme files bundled with extensions are discovered in-place from loaded | 48 | + | Extension-owned themes are loaded in place from: |
| 52 | - | extension directories: | |||
| 53 | 49 | ||||
| 54 | ```text | 50 | ```text | ||
| 55 | $ZUT_HOME/extensions/<extension>/theme.json | 51 | $ZUT_HOME/extensions/<extension>/theme.json | ||
| @@ -58,35 +54,25 @@ $ZUT_HOME/extensions/<extension>/themes/theme.json | |||||
| 58 | <project>/.zut/extensions/<extension>/themes/theme.json | 54 | <project>/.zut/extensions/<extension>/themes/theme.json | ||
| 59 | ``` | 55 | ``` | ||
| 60 | 56 | ||||
| 61 | - | zut does **not** copy extension themes into `$ZUT_HOME/themes`; extension | 57 | + | 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 shows | 58 | + | 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 themes | 61 | + | `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 | ```json | 65 | ```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 | ```json | 77 | ```json | ||
| 92 | { | 78 | { | ||
| @@ -98,111 +84,17 @@ One color per mode: | |||||
| 98 | } | 84 | } | ||
| 99 | ``` | 85 | ``` | ||
| 100 | 86 | ||||
| 101 | - | Spinner-only: | |||
| 102 | - | ||||
| 103 | ```json | 87 | ```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": 120 | 91 | "spinner_interval_ms": 120 | ||
| 109 | } | 92 | } | ||
| 110 | ``` | 93 | ``` | ||
| 111 | 94 | ||||
| 112 | - | Dark-only themes still work on light terminals. If `colors.light` is | 95 | + | ## 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 | - | ```json | 97 | + | 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 | ```json | 99 | ```json | ||
| 208 | 254 | 100 | 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, with | 107 | + | 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 to | 109 | + | to the active terminal capability. `auto`-generated colors are distinct: |
| 218 | - | xterm-256 otherwise. The built-in inherited theme additionally resolves | 110 | + | they retain terminal-default or terminal-palette-slot provenance and therefore |
| 219 | - | semantic accents through the terminal's ANSI palette and derives muted and | 111 | + | 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 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. | |||
| 259 | 118 | ||||
| 260 | - | Syntax highlighting uses Chroma style entries. Values may include | 119 | + | `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 | - | ```text | 124 | + | 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 | ```json | 126 | ```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 extensions | 140 | + | 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 a | 143 | + | the xterm color cube. |
| 294 | - | valid theme file. | |||
| 295 | - | ||||
| 296 | - | ```text | |||
| 297 | - | $ZUT_HOME/extensions/my-theme-extension/ | |||
| 298 | - | ├── extension.json | |||
| 299 | - | └── theme.json | |||
| 300 | - | ``` | |||
| 301 | - | ||||
| 302 | - | `extension.json`: | |||
| 303 | - | ||||
| 304 | - | ```json | |||
| 305 | - | { | |||
| 306 | - | "name": "my-theme-extension", | |||
| 307 | - | "version": "1.0.0", | |||
| 308 | - | "description": "Ships a zut color theme", | |||
| 309 | - | "enabled": true | |||
| 310 | - | } | |||
| 311 | - | ``` | |||
| 312 | - | ||||
| 313 | - | No `exec` is needed when `theme.json` or `themes/theme.json` exists. | |||
| 314 | - | If `exec` is present, zut treats it as a normal extension too. | |||
| 315 | - | ||||
| 316 | - | ## Validate | |||
| 317 | - | ||||
| 318 | - | zut theme files are plain JSON, not JSONC. Validate before installing: | |||
| 319 | - | ||||
| 320 | - | ```bash | |||
| 321 | - | python3 -m json.tool theme.json >/dev/null | |||
| 322 | - | ``` | |||
M packages/agent/cli.go +18 -4
| @@ -2488,12 +2488,23 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2488 | 2488 | for idx, s := range initialCfg.QuickModelShortcuts { | |
| 2489 | 2489 | quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model} | |
| 2490 | 2490 | } | |
| 2491 | - | theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), initialCfg.Theme, 80*time.Millisecond) | |
| 2491 | + | themeEnv := strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME"))) | |
| 2492 | + | if themeEnv != "" && themeEnv != "auto" && themeEnv != "dark" && themeEnv != "light" { | |
| 2493 | + | fmt.Fprintf(os.Stderr, "theme override: unsupported ZUT_THEME=%q; ignoring it\n", themeEnv) | |
| 2494 | + | themeEnv = "" | |
| 2495 | + | } | |
| 2496 | + | themePreference := tui.ResolveThemePreference(initialCfg.Theme, themeEnv) | |
| 2497 | + | theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), themePreference.Effective, 80*time.Millisecond) | |
| 2492 | 2498 | if themeErr != nil { | |
| 2493 | 2499 | fmt.Fprintln(os.Stderr, "theme load:", themeErr) | |
| 2494 | - | if initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) { | |
| 2495 | - | initialCfg.Theme = "" | |
| 2496 | - | _ = SaveConfig(initialCfg) | |
| 2500 | + | if !themePreference.Forced { | |
| 2501 | + | themePreference = tui.ResolveThemePreference("", themeEnv) | |
| 2502 | + | if initialCfg.Theme != "" { | |
| 2503 | + | initialCfg.Theme = "" | |
| 2504 | + | if err := SaveConfig(initialCfg); err != nil { | |
| 2505 | + | fmt.Fprintln(os.Stderr, "theme reset:", err) | |
| 2506 | + | } | |
| 2507 | + | } | |
| 2497 | 2508 | } | |
| 2498 | 2509 | } | |
| 2499 | 2510 | ||
| @@ -2554,6 +2565,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2554 | 2565 | TUIStatusPosition: initialCfg.TUIStatusPosition, | |
| 2555 | 2566 | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | |
| 2556 | 2567 | ThemeName: initialCfg.Theme, | |
| 2568 | + | EffectiveThemeName: themePreference.Effective, | |
| 2569 | + | ThemeForced: themePreference.Forced, | |
| 2570 | + | TerminalProfile: theme.Terminal, | |
| 2557 | 2571 | FlatTools: initialCfg.FlatToolRender(), | |
| 2558 | 2572 | CompactUser: initialCfg.CompactUserInput(), | |
| 2559 | 2573 | ExtensionThemes: extMgr.ThemeOptions, | |
| @@ -2488,12 +2488,23 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2488 | for idx, s := range initialCfg.QuickModelShortcuts { | 2488 | for idx, s := range initialCfg.QuickModelShortcuts { | ||
| 2489 | quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model} | 2489 | quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model} | ||
| 2490 | } | 2490 | } | ||
| 2491 | - | theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), initialCfg.Theme, 80*time.Millisecond) | 2491 | + | themeEnv := strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME"))) |
| 2492 | + | if themeEnv != "" && themeEnv != "auto" && themeEnv != "dark" && themeEnv != "light" { | |||
| 2493 | + | fmt.Fprintf(os.Stderr, "theme override: unsupported ZUT_THEME=%q; ignoring it\n", themeEnv) | |||
| 2494 | + | themeEnv = "" | |||
| 2495 | + | } | |||
| 2496 | + | themePreference := tui.ResolveThemePreference(initialCfg.Theme, themeEnv) | |||
| 2497 | + | theme, _, themeErr := tui.DetectThemeWithCustom(ZutHome(), themePreference.Effective, 80*time.Millisecond) | |||
| 2492 | if themeErr != nil { | 2498 | if themeErr != nil { | ||
| 2493 | fmt.Fprintln(os.Stderr, "theme load:", themeErr) | 2499 | fmt.Fprintln(os.Stderr, "theme load:", themeErr) | ||
| 2494 | - | if initialCfg.Theme != "" && !tui.ThemeExists(ZutHome(), initialCfg.Theme) { | 2500 | + | if !themePreference.Forced { |
| 2495 | - | initialCfg.Theme = "" | 2501 | + | themePreference = tui.ResolveThemePreference("", themeEnv) |
| 2496 | - | _ = SaveConfig(initialCfg) | 2502 | + | if initialCfg.Theme != "" { |
| 2503 | + | initialCfg.Theme = "" | |||
| 2504 | + | if err := SaveConfig(initialCfg); err != nil { | |||
| 2505 | + | fmt.Fprintln(os.Stderr, "theme reset:", err) | |||
| 2506 | + | } | |||
| 2507 | + | } | |||
| 2497 | } | 2508 | } | ||
| 2498 | } | 2509 | } | ||
| 2499 | 2510 | ||||
| @@ -2554,6 +2565,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2554 | TUIStatusPosition: initialCfg.TUIStatusPosition, | 2565 | TUIStatusPosition: initialCfg.TUIStatusPosition, | ||
| 2555 | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | 2566 | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | ||
| 2556 | ThemeName: initialCfg.Theme, | 2567 | ThemeName: initialCfg.Theme, | ||
| 2568 | + | EffectiveThemeName: themePreference.Effective, | |||
| 2569 | + | ThemeForced: themePreference.Forced, | |||
| 2570 | + | TerminalProfile: theme.Terminal, | |||
| 2557 | FlatTools: initialCfg.FlatToolRender(), | 2571 | FlatTools: initialCfg.FlatToolRender(), | ||
| 2558 | CompactUser: initialCfg.CompactUserInput(), | 2572 | CompactUser: initialCfg.CompactUserInput(), | ||
| 2559 | ExtensionThemes: extMgr.ThemeOptions, | 2573 | ExtensionThemes: extMgr.ThemeOptions, | ||
M packages/agent/modes/btw_dialog.go +19 -0
| @@ -147,6 +147,25 @@ func (d *btwDialog) ToggleToolExpansion() { | |||
| 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 | 169 | // Open enters the side chat. agent supplies the live transcript and | |
| 151 | 170 | // system prompt, plus the underlying provider client to use for the | |
| 152 | 171 | // 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 and | 169 | // Open enters the side chat. agent supplies the live transcript and | ||
| 151 | // system prompt, plus the underlying provider client to use for the | 170 | // system prompt, plus the underlying provider client to use for the | ||
| 152 | // one-off completion. seed is an optional first question that gets | 171 | // one-off completion. seed is an optional first question that gets | ||
M packages/agent/modes/interactive.go +191 -10
| @@ -135,6 +135,16 @@ type InteractiveConfig struct { | |||
| 135 | 135 | ||
| 136 | 136 | // ThemeName mirrors the persisted config theme value. Empty means auto. | |
| 137 | 137 | 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 | 149 | // FlatTools renders tool calls without the bordered panel (a quiet | |
| 140 | 150 | // header line plus indented, frameless output). Mirrors the | |
| @@ -627,8 +637,14 @@ type scheduledFollowUp struct { | |||
| 627 | 637 | type Interactive struct { | |
| 628 | 638 | cfg InteractiveConfig | |
| 629 | 639 | 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 | |
| 632 | 648 | ||
| 633 | 649 | mu sync.Mutex | |
| 634 | 650 | agent *core.Agent | |
| @@ -966,8 +982,34 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 966 | 982 | startupExtensionNames = append(startupExtensionNames, cfg.StartupExtensionNames...) | |
| 967 | 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 | 1008 | i := &Interactive{ | |
| 970 | - | cfg: cfg, | |
| 1009 | + | cfg: cfg, | |
| 1010 | + | terminalProfile: profile, | |
| 1011 | + | activeThemeSource: source, | |
| 1012 | + | themeWatchEvents: make(chan themeWatchEvent, 8), | |
| 971 | 1013 | view: &tui.View{ | |
| 972 | 1014 | Theme: cfg.Theme, | |
| 973 | 1015 | ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled), | |
| @@ -1112,6 +1154,8 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1112 | 1154 | i.mu.Lock() | |
| 1113 | 1155 | i.runCtx = ctx | |
| 1114 | 1156 | i.mu.Unlock() | |
| 1157 | + | i.restartThemeWatch() | |
| 1158 | + | defer i.stopThemeWatch() | |
| 1115 | 1159 | term := i.cfg.Terminal | |
| 1116 | 1160 | restore, err := term.EnterRaw() | |
| 1117 | 1161 | if err != nil { | |
| @@ -1147,7 +1191,14 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1147 | 1191 | // Erase the live frame and place the cursor deterministically before any | |
| 1148 | 1192 | // exit summary or shell prompt is written. Do not erase scrollback: users | |
| 1149 | 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 | 1202 | i.applyInputCursorColor() | |
| 1152 | 1203 | ||
| 1153 | 1204 | // Streaming pacer: drains buffered text deltas at a steady rate | |
| @@ -1222,19 +1273,46 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1222 | 1273 | i.dialog.Open(i.cfg.ZutHome) | |
| 1223 | 1274 | } | |
| 1224 | 1275 | ||
| 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. | |
| 1229 | 1279 | keys := make(chan tui.Key, 256) | |
| 1280 | + | appearanceEvents := make(chan tui.InputEvent, 256) | |
| 1281 | + | appearanceParser := &tui.AppearanceParser{} | |
| 1282 | + | appearanceParser.SetPendingColors(true) | |
| 1283 | + | appearanceParser.SetPendingScheme(true) | |
| 1284 | + | inputDone := make(chan struct{}) | |
| 1230 | 1285 | go func() { | |
| 1231 | - | reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout) | |
| 1286 | + | defer close(inputDone) | |
| 1287 | + | source := tui.NewAppearanceSource(term.ReadByte, term.PeekByteTimeout, appearanceParser, func(event tui.InputEvent) { | |
| 1288 | + | select { | |
| 1289 | + | case appearanceEvents <- event: | |
| 1290 | + | case <-ctx.Done(): | |
| 1291 | + | } | |
| 1292 | + | }) | |
| 1293 | + | reader := tui.NewReader(source.ReadByte) | |
| 1232 | 1294 | for { | |
| 1233 | 1295 | k, err := reader.Read() | |
| 1234 | 1296 | if err != nil { | |
| 1235 | 1297 | return | |
| 1236 | 1298 | } | |
| 1237 | - | keys <- k | |
| 1299 | + | select { | |
| 1300 | + | case keys <- k: | |
| 1301 | + | case <-ctx.Done(): | |
| 1302 | + | return | |
| 1303 | + | } | |
| 1304 | + | } | |
| 1305 | + | }() | |
| 1306 | + | // Probe before mutating mode 2031. Replies are handled in the main loop so | |
| 1307 | + | // ownership and profile publication stay serialized with rendering. | |
| 1308 | + | _, _ = term.Write([]byte(tui.SeqQueryMode2031 + tui.AppearanceQuery())) | |
| 1309 | + | defer func() { | |
| 1310 | + | // Test terminals and normal EOF join immediately. A physical tty read | |
| 1311 | + | // may be uncancellable on some platforms, so shutdown never waits | |
| 1312 | + | // indefinitely for a kernel read that cannot be interrupted. | |
| 1313 | + | select { | |
| 1314 | + | case <-inputDone: | |
| 1315 | + | case <-time.After(50 * time.Millisecond): | |
| 1238 | 1316 | } | |
| 1239 | 1317 | }() | |
| 1240 | 1318 | ||
| @@ -1310,11 +1388,97 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1310 | 1388 | ||
| 1311 | 1389 | updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select | |
| 1312 | 1390 | changelog := i.cfg.ChangelogChan // single-shot, see case below | |
| 1391 | + | profileCandidate := i.terminalProfile | |
| 1392 | + | profileDirty := false | |
| 1393 | + | appearanceDeadline := time.Now().Add(500 * time.Millisecond) | |
| 1394 | + | lastAppearancePoll := time.Now() | |
| 1395 | + | modeEnableRequested := false | |
| 1396 | + | notificationsSupported := false | |
| 1313 | 1397 | ||
| 1314 | 1398 | for { | |
| 1315 | 1399 | select { | |
| 1316 | 1400 | case <-ctx.Done(): | |
| 1317 | 1401 | return ctx.Err() | |
| 1402 | + | case event := <-i.themeWatchEvents: | |
| 1403 | + | if i.activeThemeSource == nil || event.path != i.activeThemeSource.Path { | |
| 1404 | + | break | |
| 1405 | + | } | |
| 1406 | + | switch { | |
| 1407 | + | case event.source != nil: | |
| 1408 | + | i.activeThemeSource = event.source | |
| 1409 | + | i.themeWatchWarning = "" | |
| 1410 | + | i.installResolvedTheme(tui.ResolveTheme(i.cfg.EffectiveThemeName, event.source, i.terminalProfile)) | |
| 1411 | + | i.mu.Lock() | |
| 1412 | + | i.statusOK = "theme reloaded" | |
| 1413 | + | i.statusErr = "" | |
| 1414 | + | i.mu.Unlock() | |
| 1415 | + | case event.deleted: | |
| 1416 | + | i.activeThemeSource = nil | |
| 1417 | + | i.stopThemeWatch() | |
| 1418 | + | i.cfg.ThemeName = "" | |
| 1419 | + | i.cfg.EffectiveThemeName = "auto" | |
| 1420 | + | if i.cfg.SettingsStore != nil { | |
| 1421 | + | _ = i.cfg.SettingsStore.SetTheme("auto") | |
| 1422 | + | } | |
| 1423 | + | i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile)) | |
| 1424 | + | i.mu.Lock() | |
| 1425 | + | i.statusOK = "theme removed; using auto" | |
| 1426 | + | i.statusErr = "" | |
| 1427 | + | i.mu.Unlock() | |
| 1428 | + | case event.err != nil && event.err.Error() != i.themeWatchWarning: | |
| 1429 | + | i.themeWatchWarning = event.err.Error() | |
| 1430 | + | i.mu.Lock() | |
| 1431 | + | i.statusErr = "theme reload: " + event.err.Error() | |
| 1432 | + | i.mu.Unlock() | |
| 1433 | + | } | |
| 1434 | + | i.invalidate() | |
| 1435 | + | case event := <-appearanceEvents: | |
| 1436 | + | switch { | |
| 1437 | + | case event.Color != nil: | |
| 1438 | + | switch event.Color.Kind { | |
| 1439 | + | case 10: | |
| 1440 | + | profileCandidate.Foreground = event.Color.Color | |
| 1441 | + | profileCandidate.HasForeground = true | |
| 1442 | + | case 11: | |
| 1443 | + | profileCandidate.Background = event.Color.Color | |
| 1444 | + | profileCandidate.HasBackground = true | |
| 1445 | + | case 4: | |
| 1446 | + | profileCandidate.Palette[event.Color.Slot] = event.Color.Color | |
| 1447 | + | profileCandidate.PaletteKnown |= uint16(1) << event.Color.Slot | |
| 1448 | + | } | |
| 1449 | + | profileDirty = true | |
| 1450 | + | case event.Scheme != nil: | |
| 1451 | + | profileCandidate.Light = event.Scheme.Light | |
| 1452 | + | profileCandidate.SchemeKnown = true | |
| 1453 | + | profileDirty = true | |
| 1454 | + | if notificationsSupported && !event.Scheme.Solicited { | |
| 1455 | + | // A scheme notification means the terminal defaults and palette | |
| 1456 | + | // may have changed too. Refresh the complete profile atomically. | |
| 1457 | + | appearanceParser.SetPendingColors(true) | |
| 1458 | + | appearanceParser.SetPendingScheme(true) | |
| 1459 | + | appearanceDeadline = time.Now().Add(500 * time.Millisecond) | |
| 1460 | + | _, _ = term.Write([]byte(tui.AppearanceQuery())) | |
| 1461 | + | } | |
| 1462 | + | case event.Mode != nil && event.Mode.Mode == 2031: | |
| 1463 | + | switch event.Mode.Status { | |
| 1464 | + | case 1, 3: | |
| 1465 | + | notificationsSupported = true | |
| 1466 | + | appearanceParser.SetNotifications(true) | |
| 1467 | + | if modeEnableRequested { | |
| 1468 | + | mode2031Owned = true | |
| 1469 | + | } | |
| 1470 | + | case 2: | |
| 1471 | + | modeEnableRequested = true | |
| 1472 | + | _, _ = term.Write([]byte(tui.SeqEnableMode2031 + tui.SeqQueryMode2031)) | |
| 1473 | + | default: | |
| 1474 | + | appearanceParser.SetNotifications(false) | |
| 1475 | + | } | |
| 1476 | + | } | |
| 1477 | + | // The parser gate closes after a bounded collection window; accepted | |
| 1478 | + | // notifications remain enabled separately once mode 2031 is known. | |
| 1479 | + | if profileDirty { | |
| 1480 | + | i.invalidate() | |
| 1481 | + | } | |
| 1318 | 1482 | case k := <-keys: | |
| 1319 | 1483 | if done := i.handleKey(ctx, k); done { | |
| 1320 | 1484 | return nil | |
| @@ -1403,6 +1567,23 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 1403 | 1567 | case <-i.dirty: | |
| 1404 | 1568 | requestRedraw() | |
| 1405 | 1569 | case <-tick.C: | |
| 1570 | + | now := time.Now() | |
| 1571 | + | if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) { | |
| 1572 | + | appearanceParser.SetPendingColors(false) | |
| 1573 | + | appearanceParser.SetPendingScheme(false) | |
| 1574 | + | appearanceDeadline = time.Time{} | |
| 1575 | + | } | |
| 1576 | + | if profileDirty { | |
| 1577 | + | i.applyTerminalProfile(profileCandidate) | |
| 1578 | + | profileDirty = false | |
| 1579 | + | } | |
| 1580 | + | if !notificationsSupported && now.Sub(lastAppearancePoll) >= 2*time.Second { | |
| 1581 | + | appearanceParser.SetPendingColors(true) | |
| 1582 | + | appearanceParser.SetPendingScheme(true) | |
| 1583 | + | appearanceDeadline = now.Add(500 * time.Millisecond) | |
| 1584 | + | lastAppearancePoll = now | |
| 1585 | + | _, _ = term.Write([]byte("\x1b]11;?\a" + tui.SeqQueryColorScheme)) | |
| 1586 | + | } | |
| 1406 | 1587 | // Always drain a pending redraw on the tick. This is the | |
| 1407 | 1588 | // safety net that catches the case where the dirty channel | |
| 1408 | 1589 | // 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 string | 137 | 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 quiet | 149 | // FlatTools renders tool calls without the bordered panel (a quiet | ||
| 140 | // header line plus indented, frameless output). Mirrors the | 150 | // 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 InteractiveConfig | 638 | cfg InteractiveConfig | ||
| 629 | view *tui.View | 639 | view *tui.View | ||
| 630 | - | ed *tui.Editor | 640 | + | |
| 631 | - | rend *tui.Renderer | 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 | |||
| 632 | 648 | ||||
| 633 | mu sync.Mutex | 649 | mu sync.Mutex | ||
| 634 | agent *core.Agent | 650 | 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 = ctx | 1155 | i.runCtx = ctx | ||
| 1114 | i.mu.Unlock() | 1156 | i.mu.Unlock() | ||
| 1157 | + | i.restartThemeWatch() | |||
| 1158 | + | defer i.stopThemeWatch() | |||
| 1115 | term := i.cfg.Terminal | 1159 | 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 any | 1191 | // Erase the live frame and place the cursor deterministically before any | ||
| 1148 | // exit summary or shell prompt is written. Do not erase scrollback: users | 1192 | // 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 rate | 1204 | // Streaming pacer: drains buffered text deltas at a steady rate | ||
| @@ -1222,19 +1273,46 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1222 | i.dialog.Open(i.cfg.ZutHome) | 1273 | i.dialog.Open(i.cfg.ZutHome) | ||
| 1223 | } | 1274 | } | ||
| 1224 | 1275 | ||||
| 1225 | - | // Input goroutine. Buffered generously so a drag-drop that the | 1276 | + | // The appearance-aware source is the only stdin reader. It consumes only |
| 1226 | - | // terminal delivers as a burst of single-character key events | 1277 | + | // replies for queries currently in flight, keeps bracketed-paste payload |
| 1227 | - | // (no bracketed paste) can be drained in one main-loop pass | 1278 | + | // opaque, and sends every other byte through the established key parser. |
| 1228 | - | // instead of triggering a redraw per character. | |||
| 1229 | keys := make(chan tui.Key, 256) | 1279 | keys := make(chan tui.Key, 256) | ||
| 1280 | + | appearanceEvents := make(chan tui.InputEvent, 256) | |||
| 1281 | + | appearanceParser := &tui.AppearanceParser{} | |||
| 1282 | + | appearanceParser.SetPendingColors(true) | |||
| 1283 | + | appearanceParser.SetPendingScheme(true) | |||
| 1284 | + | inputDone := make(chan struct{}) | |||
| 1230 | go func() { | 1285 | go func() { | ||
| 1231 | - | reader := tui.NewReaderWithPeek(term.ReadByte, term.PeekByteTimeout) | 1286 | + | defer close(inputDone) |
| 1287 | + | source := tui.NewAppearanceSource(term.ReadByte, term.PeekByteTimeout, appearanceParser, func(event tui.InputEvent) { | |||
| 1288 | + | select { | |||
| 1289 | + | case appearanceEvents <- event: | |||
| 1290 | + | case <-ctx.Done(): | |||
| 1291 | + | } | |||
| 1292 | + | }) | |||
| 1293 | + | reader := tui.NewReader(source.ReadByte) | |||
| 1232 | for { | 1294 | for { | ||
| 1233 | k, err := reader.Read() | 1295 | k, err := reader.Read() | ||
| 1234 | if err != nil { | 1296 | if err != nil { | ||
| 1235 | return | 1297 | return | ||
| 1236 | } | 1298 | } | ||
| 1237 | - | keys <- k | 1299 | + | select { |
| 1300 | + | case keys <- k: | |||
| 1301 | + | case <-ctx.Done(): | |||
| 1302 | + | return | |||
| 1303 | + | } | |||
| 1304 | + | } | |||
| 1305 | + | }() | |||
| 1306 | + | // Probe before mutating mode 2031. Replies are handled in the main loop so | |||
| 1307 | + | // ownership and profile publication stay serialized with rendering. | |||
| 1308 | + | _, _ = term.Write([]byte(tui.SeqQueryMode2031 + tui.AppearanceQuery())) | |||
| 1309 | + | defer func() { | |||
| 1310 | + | // Test terminals and normal EOF join immediately. A physical tty read | |||
| 1311 | + | // may be uncancellable on some platforms, so shutdown never waits | |||
| 1312 | + | // indefinitely for a kernel read that cannot be interrupted. | |||
| 1313 | + | select { | |||
| 1314 | + | case <-inputDone: | |||
| 1315 | + | case <-time.After(50 * time.Millisecond): | |||
| 1238 | } | 1316 | } | ||
| 1239 | }() | 1317 | }() | ||
| 1240 | 1318 | ||||
| @@ -1310,11 +1388,97 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1310 | 1388 | ||||
| 1311 | updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select | 1389 | updates := i.cfg.UpdateInfoChan // nil-safe; nil channel blocks forever in select | ||
| 1312 | changelog := i.cfg.ChangelogChan // single-shot, see case below | 1390 | changelog := i.cfg.ChangelogChan // single-shot, see case below | ||
| 1391 | + | profileCandidate := i.terminalProfile | |||
| 1392 | + | profileDirty := false | |||
| 1393 | + | appearanceDeadline := time.Now().Add(500 * time.Millisecond) | |||
| 1394 | + | lastAppearancePoll := time.Now() | |||
| 1395 | + | modeEnableRequested := false | |||
| 1396 | + | notificationsSupported := false | |||
| 1313 | 1397 | ||||
| 1314 | for { | 1398 | for { | ||
| 1315 | select { | 1399 | select { | ||
| 1316 | case <-ctx.Done(): | 1400 | case <-ctx.Done(): | ||
| 1317 | return ctx.Err() | 1401 | return ctx.Err() | ||
| 1402 | + | case event := <-i.themeWatchEvents: | |||
| 1403 | + | if i.activeThemeSource == nil || event.path != i.activeThemeSource.Path { | |||
| 1404 | + | break | |||
| 1405 | + | } | |||
| 1406 | + | switch { | |||
| 1407 | + | case event.source != nil: | |||
| 1408 | + | i.activeThemeSource = event.source | |||
| 1409 | + | i.themeWatchWarning = "" | |||
| 1410 | + | i.installResolvedTheme(tui.ResolveTheme(i.cfg.EffectiveThemeName, event.source, i.terminalProfile)) | |||
| 1411 | + | i.mu.Lock() | |||
| 1412 | + | i.statusOK = "theme reloaded" | |||
| 1413 | + | i.statusErr = "" | |||
| 1414 | + | i.mu.Unlock() | |||
| 1415 | + | case event.deleted: | |||
| 1416 | + | i.activeThemeSource = nil | |||
| 1417 | + | i.stopThemeWatch() | |||
| 1418 | + | i.cfg.ThemeName = "" | |||
| 1419 | + | i.cfg.EffectiveThemeName = "auto" | |||
| 1420 | + | if i.cfg.SettingsStore != nil { | |||
| 1421 | + | _ = i.cfg.SettingsStore.SetTheme("auto") | |||
| 1422 | + | } | |||
| 1423 | + | i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile)) | |||
| 1424 | + | i.mu.Lock() | |||
| 1425 | + | i.statusOK = "theme removed; using auto" | |||
| 1426 | + | i.statusErr = "" | |||
| 1427 | + | i.mu.Unlock() | |||
| 1428 | + | case event.err != nil && event.err.Error() != i.themeWatchWarning: | |||
| 1429 | + | i.themeWatchWarning = event.err.Error() | |||
| 1430 | + | i.mu.Lock() | |||
| 1431 | + | i.statusErr = "theme reload: " + event.err.Error() | |||
| 1432 | + | i.mu.Unlock() | |||
| 1433 | + | } | |||
| 1434 | + | i.invalidate() | |||
| 1435 | + | case event := <-appearanceEvents: | |||
| 1436 | + | switch { | |||
| 1437 | + | case event.Color != nil: | |||
| 1438 | + | switch event.Color.Kind { | |||
| 1439 | + | case 10: | |||
| 1440 | + | profileCandidate.Foreground = event.Color.Color | |||
| 1441 | + | profileCandidate.HasForeground = true | |||
| 1442 | + | case 11: | |||
| 1443 | + | profileCandidate.Background = event.Color.Color | |||
| 1444 | + | profileCandidate.HasBackground = true | |||
| 1445 | + | case 4: | |||
| 1446 | + | profileCandidate.Palette[event.Color.Slot] = event.Color.Color | |||
| 1447 | + | profileCandidate.PaletteKnown |= uint16(1) << event.Color.Slot | |||
| 1448 | + | } | |||
| 1449 | + | profileDirty = true | |||
| 1450 | + | case event.Scheme != nil: | |||
| 1451 | + | profileCandidate.Light = event.Scheme.Light | |||
| 1452 | + | profileCandidate.SchemeKnown = true | |||
| 1453 | + | profileDirty = true | |||
| 1454 | + | if notificationsSupported && !event.Scheme.Solicited { | |||
| 1455 | + | // A scheme notification means the terminal defaults and palette | |||
| 1456 | + | // may have changed too. Refresh the complete profile atomically. | |||
| 1457 | + | appearanceParser.SetPendingColors(true) | |||
| 1458 | + | appearanceParser.SetPendingScheme(true) | |||
| 1459 | + | appearanceDeadline = time.Now().Add(500 * time.Millisecond) | |||
| 1460 | + | _, _ = term.Write([]byte(tui.AppearanceQuery())) | |||
| 1461 | + | } | |||
| 1462 | + | case event.Mode != nil && event.Mode.Mode == 2031: | |||
| 1463 | + | switch event.Mode.Status { | |||
| 1464 | + | case 1, 3: | |||
| 1465 | + | notificationsSupported = true | |||
| 1466 | + | appearanceParser.SetNotifications(true) | |||
| 1467 | + | if modeEnableRequested { | |||
| 1468 | + | mode2031Owned = true | |||
| 1469 | + | } | |||
| 1470 | + | case 2: | |||
| 1471 | + | modeEnableRequested = true | |||
| 1472 | + | _, _ = term.Write([]byte(tui.SeqEnableMode2031 + tui.SeqQueryMode2031)) | |||
| 1473 | + | default: | |||
| 1474 | + | appearanceParser.SetNotifications(false) | |||
| 1475 | + | } | |||
| 1476 | + | } | |||
| 1477 | + | // The parser gate closes after a bounded collection window; accepted | |||
| 1478 | + | // notifications remain enabled separately once mode 2031 is known. | |||
| 1479 | + | if profileDirty { | |||
| 1480 | + | i.invalidate() | |||
| 1481 | + | } | |||
| 1318 | case k := <-keys: | 1482 | case k := <-keys: | ||
| 1319 | if done := i.handleKey(ctx, k); done { | 1483 | if done := i.handleKey(ctx, k); done { | ||
| 1320 | return nil | 1484 | return nil | ||
| @@ -1403,6 +1567,23 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 1403 | case <-i.dirty: | 1567 | case <-i.dirty: | ||
| 1404 | requestRedraw() | 1568 | requestRedraw() | ||
| 1405 | case <-tick.C: | 1569 | case <-tick.C: | ||
| 1570 | + | now := time.Now() | |||
| 1571 | + | if !appearanceDeadline.IsZero() && !now.Before(appearanceDeadline) { | |||
| 1572 | + | appearanceParser.SetPendingColors(false) | |||
| 1573 | + | appearanceParser.SetPendingScheme(false) | |||
| 1574 | + | appearanceDeadline = time.Time{} | |||
| 1575 | + | } | |||
| 1576 | + | if profileDirty { | |||
| 1577 | + | i.applyTerminalProfile(profileCandidate) | |||
| 1578 | + | profileDirty = false | |||
| 1579 | + | } | |||
| 1580 | + | if !notificationsSupported && now.Sub(lastAppearancePoll) >= 2*time.Second { | |||
| 1581 | + | appearanceParser.SetPendingColors(true) | |||
| 1582 | + | appearanceParser.SetPendingScheme(true) | |||
| 1583 | + | appearanceDeadline = now.Add(500 * time.Millisecond) | |||
| 1584 | + | lastAppearancePoll = now | |||
| 1585 | + | _, _ = term.Write([]byte("\x1b]11;?\a" + tui.SeqQueryColorScheme)) | |||
| 1586 | + | } | |||
| 1406 | // Always drain a pending redraw on the tick. This is the | 1587 | // Always drain a pending redraw on the tick. This is the | ||
| 1407 | // safety net that catches the case where the dirty channel | 1588 | // safety net that catches the case where the dirty channel | ||
| 1408 | // was saturated when the final "turn finished" invalidate | 1589 | // 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 { | |||
| 54 | 54 | if s.stopped { | |
| 55 | 55 | return false | |
| 56 | 56 | } | |
| 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 | |
| 58 | 61 | s.theme = &theme | |
| 59 | 62 | select { | |
| 60 | 63 | 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 false | 55 | return false | ||
| 56 | } | 56 | } | ||
| 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 | |||
| 58 | s.theme = &theme | 61 | s.theme = &theme | ||
| 59 | select { | 62 | select { | ||
| 60 | case s.wake <- struct{}{}: | 63 | case s.wake <- struct{}{}: | ||
M packages/agent/modes/interactive_settings.go +50 -24
| @@ -42,6 +42,10 @@ func (i *Interactive) applyInputCursorColor() { | |||
| 42 | 42 | if i == nil || i.cfg.Terminal == nil { | |
| 43 | 43 | 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 | 49 | color := tui.Color256(15) | |
| 46 | 50 | if i.cursorDimmed { | |
| 47 | 51 | color = i.cfg.Theme.DimColor(color, modalBackdropDimPercent) | |
| @@ -383,7 +387,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 383 | 387 | { | |
| 384 | 388 | key: "theme", | |
| 385 | 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 | 391 | options: themeOptions, | |
| 388 | 392 | choice: themeChoice, | |
| 389 | 393 | }, | |
| @@ -1167,50 +1171,72 @@ func (i *Interactive) applyThemeSetting(name string) { | |||
| 1167 | 1171 | if name == "auto" { | |
| 1168 | 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 | 1180 | func (i *Interactive) applyThemeNow(name string) { | |
| 1173 | 1181 | if name == "" { | |
| 1174 | 1182 | name = "auto" | |
| 1175 | 1183 | } | |
| 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) | |
| 1185 | 1185 | if err != nil { | |
| 1186 | 1186 | if i.cfg.SettingsStore != nil { | |
| 1187 | 1187 | _ = i.cfg.SettingsStore.SetTheme("auto") | |
| 1188 | 1188 | } | |
| 1189 | 1189 | i.cfg.ThemeName = "" | |
| 1190 | - | th, _, _ = tui.LoadThemeFromHome(i.cfg.ZutHome, "auto", detected) | |
| 1191 | - | i.mu.Lock() | |
| 1192 | - | i.statusErr = "theme missing; reset to default" | |
| 1193 | - | i.mu.Unlock() | |
| 1194 | - | } else { | |
| 1190 | + | i.cfg.EffectiveThemeName = "auto" | |
| 1191 | + | i.activeThemeSource = nil | |
| 1192 | + | i.stopThemeWatch() | |
| 1193 | + | i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile)) | |
| 1195 | 1194 | 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" | |
| 1202 | 1196 | i.mu.Unlock() | |
| 1197 | + | return | |
| 1203 | 1198 | } | |
| 1199 | + | i.activeThemeSource = source | |
| 1200 | + | i.restartThemeWatch() | |
| 1201 | + | i.installResolvedTheme(tui.ResolveTheme(name, source, i.terminalProfile)) | |
| 1202 | + | i.mu.Lock() | |
| 1203 | + | i.statusOK = "theme " + i.cfg.EffectiveThemeName | |
| 1204 | + | if i.cfg.ThemeForced { | |
| 1205 | + | i.statusOK += " (forced by ZUT_THEME)" | |
| 1206 | + | } | |
| 1207 | + | i.statusErr = "" | |
| 1208 | + | i.mu.Unlock() | |
| 1209 | + | } | |
| 1210 | + | ||
| 1211 | + | // installResolvedTheme is the one main-loop-owned installation boundary for | |
| 1212 | + | // settings, terminal profile, and accepted theme-file revisions. | |
| 1213 | + | // applyTerminalProfile records detected appearance independently from the | |
| 1214 | + | // currently applied theme. Forced and custom selections are re-resolved from | |
| 1215 | + | // their immutable accepted source; no terminal event performs filesystem I/O. | |
| 1216 | + | func (i *Interactive) applyTerminalProfile(profile tui.TerminalProfile) { | |
| 1217 | + | i.terminalProfile = profile | |
| 1218 | + | i.cfg.TerminalProfile = profile | |
| 1219 | + | resolution := tui.ResolveTheme(i.cfg.EffectiveThemeName, i.activeThemeSource, profile) | |
| 1220 | + | i.installResolvedTheme(resolution) | |
| 1221 | + | } | |
| 1222 | + | ||
| 1223 | + | func (i *Interactive) installResolvedTheme(resolution tui.ThemeResolution) { | |
| 1224 | + | th := resolution.Theme | |
| 1204 | 1225 | i.mu.Lock() | |
| 1205 | 1226 | i.cfg.Theme = th | |
| 1206 | 1227 | i.view.Theme = th | |
| 1207 | 1228 | i.view.InvalidateRenderCache() | |
| 1208 | - | i.mu.Unlock() | |
| 1229 | + | i.chatCacheValid = false | |
| 1230 | + | i.stableChatCacheValid = false | |
| 1209 | 1231 | i.ed.Prompt = th.AccentBar(th.Accent) | |
| 1232 | + | i.mu.Unlock() | |
| 1233 | + | i.btwDialog.setTheme(th) | |
| 1234 | + | if i.residentChildSession != nil { | |
| 1235 | + | i.residentChildSession.setTheme(th) | |
| 1236 | + | } | |
| 1210 | 1237 | i.applyInputCursorColor() | |
| 1211 | 1238 | i.spin.Configure(th) | |
| 1212 | 1239 | i.requestRendererTheme(th) | |
| 1213 | - | i.requestRendererClear() | |
| 1214 | 1240 | i.invalidate() | |
| 1215 | 1241 | } | |
| 1216 | 1242 | 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 | return | 43 | 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.Dark | 1184 | + | source, err := tui.LoadThemeSource(i.cfg.ZutHome, name) |
| 1177 | - | if tui.IsLightTheme(i.cfg.Theme) { | |||
| 1178 | - | detected = tui.Light | |||
| 1179 | - | } | |||
| 1180 | - | // Keep the startup terminal snapshot available when a user switches | |||
| 1181 | - | // themes live; inherited mode must not issue OSC queries while the TUI | |||
| 1182 | - | // already owns raw stdin. | |||
| 1183 | - | detected.Terminal = i.cfg.Theme.Terminal | |||
| 1184 | - | th, applied, err := tui.LoadThemeFromHome(i.cfg.ZutHome, name, detected) | |||
| 1185 | if err != nil { | 1185 | if err != nil { | ||
| 1186 | if i.cfg.SettingsStore != nil { | 1186 | if i.cfg.SettingsStore != nil { | ||
| 1187 | _ = i.cfg.SettingsStore.SetTheme("auto") | 1187 | _ = i.cfg.SettingsStore.SetTheme("auto") | ||
| 1188 | } | 1188 | } | ||
| 1189 | i.cfg.ThemeName = "" | 1189 | i.cfg.ThemeName = "" | ||
| 1190 | - | th, _, _ = tui.LoadThemeFromHome(i.cfg.ZutHome, "auto", detected) | 1190 | + | i.cfg.EffectiveThemeName = "auto" |
| 1191 | - | i.mu.Lock() | 1191 | + | i.activeThemeSource = nil |
| 1192 | - | i.statusErr = "theme missing; reset to default" | 1192 | + | i.stopThemeWatch() |
| 1193 | - | i.mu.Unlock() | 1193 | + | i.installResolvedTheme(tui.ResolveTheme("auto", nil, i.terminalProfile)) |
| 1194 | - | } else { | |||
| 1195 | i.mu.Lock() | 1194 | i.mu.Lock() | ||
| 1196 | - | label := applied | 1195 | + | i.statusErr = "theme missing; reset to auto" |
| 1197 | - | if label == "" { | |||
| 1198 | - | label = "auto" | |||
| 1199 | - | } | |||
| 1200 | - | i.statusOK = "theme " + label | |||
| 1201 | - | i.statusErr = "" | |||
| 1202 | i.mu.Unlock() | 1196 | i.mu.Unlock() | ||
| 1197 | + | return | |||
| 1203 | } | 1198 | } | ||
| 1199 | + | i.activeThemeSource = source | |||
| 1200 | + | i.restartThemeWatch() | |||
| 1201 | + | i.installResolvedTheme(tui.ResolveTheme(name, source, i.terminalProfile)) | |||
| 1202 | + | i.mu.Lock() | |||
| 1203 | + | i.statusOK = "theme " + i.cfg.EffectiveThemeName | |||
| 1204 | + | if i.cfg.ThemeForced { | |||
| 1205 | + | i.statusOK += " (forced by ZUT_THEME)" | |||
| 1206 | + | } | |||
| 1207 | + | i.statusErr = "" | |||
| 1208 | + | i.mu.Unlock() | |||
| 1209 | + | } | |||
| 1210 | + | ||||
| 1211 | + | // installResolvedTheme is the one main-loop-owned installation boundary for | |||
| 1212 | + | // settings, terminal profile, and accepted theme-file revisions. | |||
| 1213 | + | // applyTerminalProfile records detected appearance independently from the | |||
| 1214 | + | // currently applied theme. Forced and custom selections are re-resolved from | |||
| 1215 | + | // their immutable accepted source; no terminal event performs filesystem I/O. | |||
| 1216 | + | func (i *Interactive) applyTerminalProfile(profile tui.TerminalProfile) { | |||
| 1217 | + | i.terminalProfile = profile | |||
| 1218 | + | i.cfg.TerminalProfile = profile | |||
| 1219 | + | resolution := tui.ResolveTheme(i.cfg.EffectiveThemeName, i.activeThemeSource, profile) | |||
| 1220 | + | i.installResolvedTheme(resolution) | |||
| 1221 | + | } | |||
| 1222 | + | ||||
| 1223 | + | func (i *Interactive) installResolvedTheme(resolution tui.ThemeResolution) { | |||
| 1224 | + | th := resolution.Theme | |||
| 1204 | i.mu.Lock() | 1225 | i.mu.Lock() | ||
| 1205 | i.cfg.Theme = th | 1226 | i.cfg.Theme = th | ||
| 1206 | i.view.Theme = th | 1227 | i.view.Theme = th | ||
| 1207 | i.view.InvalidateRenderCache() | 1228 | i.view.InvalidateRenderCache() | ||
| 1208 | - | i.mu.Unlock() | 1229 | + | i.chatCacheValid = false |
| 1230 | + | i.stableChatCacheValid = false | |||
| 1209 | i.ed.Prompt = th.AccentBar(th.Accent) | 1231 | i.ed.Prompt = th.AccentBar(th.Accent) | ||
| 1232 | + | i.mu.Unlock() | |||
| 1233 | + | i.btwDialog.setTheme(th) | |||
| 1234 | + | if i.residentChildSession != nil { | |||
| 1235 | + | i.residentChildSession.setTheme(th) | |||
| 1236 | + | } | |||
| 1210 | i.applyInputCursorColor() | 1237 | i.applyInputCursorColor() | ||
| 1211 | i.spin.Configure(th) | 1238 | i.spin.Configure(th) | ||
| 1212 | i.requestRendererTheme(th) | 1239 | i.requestRendererTheme(th) | ||
| 1213 | - | i.requestRendererClear() | |||
| 1214 | i.invalidate() | 1240 | i.invalidate() | ||
| 1215 | } | 1241 | } | ||
| 1216 | func (i *Interactive) applyReasoningSetting(level string) { | 1242 | func (i *Interactive) applyReasoningSetting(level string) { | ||
M packages/agent/modes/subagent_session.go +10 -0
| @@ -55,6 +55,16 @@ func (s *residentChildSession) Close() { | |||
| 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 | 68 | func (s *residentChildSession) LoadRecent(limit int) error { | |
| 59 | 69 | if s == nil || s.manager == nil { | |
| 60 | 70 | 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 ( | |||
| 6 | 6 | "github.com/bnema/zut/packages/tui" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | func TestSettingsDialogOffersInheritedTheme(t *testing.T) { | |
| 9 | + | func TestSettingsDialogDoesNotOfferInheritedTheme(t *testing.T) { | |
| 10 | 10 | interactive := NewInteractive(InteractiveConfig{}) | |
| 11 | 11 | interactive.openSettingsDialog() | |
| 12 | 12 | ||
| @@ -16,37 +16,42 @@ func TestSettingsDialogOffersInheritedTheme(t *testing.T) { | |||
| 16 | 16 | } | |
| 17 | 17 | for _, option := range item.options { | |
| 18 | 18 | 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") | |
| 23 | 20 | } | |
| 24 | 21 | } | |
| 25 | - | t.Fatal("theme picker did not include inherited") | |
| 22 | + | return | |
| 26 | 23 | } | |
| 27 | 24 | t.Fatal("settings dialog did not include theme picker") | |
| 28 | 25 | } | |
| 29 | 26 | ||
| 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{ | |
| 33 | 29 | Foreground: tui.ColorRGB(220, 220, 220), | |
| 34 | 30 | Background: tui.ColorRGB(10, 10, 10), | |
| 35 | 31 | HasForeground: true, | |
| 36 | 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 | 36 | interactive.rend = nil | |
| 41 | - | interactive.applyThemeNow("inherited") | |
| 37 | + | interactive.applyThemeNow("auto") | |
| 42 | 38 | ||
| 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") | |
| 45 | 41 | } | |
| 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") | |
| 48 | 44 | } | |
| 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) | |
| 51 | 56 | } | |
| 52 | 57 | } | |
| @@ -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.Dark | 28 | + | 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 = nil | 36 | interactive.rend = nil | ||
| 41 | - | interactive.applyThemeNow("inherited") | 37 | + | interactive.applyThemeNow("auto") |
| 42 | 38 | ||||
| 43 | - | if !interactive.cfg.Theme.Inherited { | 39 | + | if interactive.cfg.Theme.FG != tui.TerminalDefault() { |
| 44 | - | t.Fatal("applying inherited theme did not switch the live theme") | 40 | + | t.Fatal("applying auto did not switch to terminal defaults") |
| 45 | } | 41 | } | ||
| 46 | - | if !interactive.cfg.Theme.Terminal.TrueColor { | 42 | + | if interactive.cfg.Theme.Terminal != profile { |
| 47 | - | t.Fatal("live inherited color mode = 256, want truecolor") | 43 | + | t.Fatal("live auto theme lost the captured terminal profile") |
| 48 | } | 44 | } | ||
| 49 | - | if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground { | 45 | + | } |
| 50 | - | t.Fatal("live inherited theme lost the captured terminal profile") | 46 | + | |
| 47 | + | func TestEnvironmentForcedThemeDoesNotChangeOnSettingsSelection(t *testing.T) { | |||
| 48 | + | interactive := NewInteractive(InteractiveConfig{ThemeForced: true, EffectiveThemeName: "dark", ThemeName: "auto"}) | |||
| 49 | + | interactive.rend = nil | |||
| 50 | + | interactive.applyThemeSetting("light") | |||
| 51 | + | if interactive.cfg.ThemeName != "light" { | |||
| 52 | + | t.Fatalf("persisted preference = %q, want light", interactive.cfg.ThemeName) | |||
| 53 | + | } | |||
| 54 | + | if interactive.cfg.EffectiveThemeName != "dark" { | |||
| 55 | + | t.Fatalf("effective preference = %q, want dark", interactive.cfg.EffectiveThemeName) | |||
| 51 | } | 56 | } | ||
| 52 | } | 57 | } | ||
A packages/agent/modes/theme_watch.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zut/packages/tui" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | themeWatchInterval = 500 * time.Millisecond | |
| 14 | + | themeMissingGrace = 2 * time.Second | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type themeWatchEvent struct { | |
| 18 | + | path string | |
| 19 | + | source *tui.ThemeSource | |
| 20 | + | err error | |
| 21 | + | deleted bool | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (i *Interactive) restartThemeWatch() { | |
| 25 | + | if i.themeWatchCancel != nil { | |
| 26 | + | i.themeWatchCancel() | |
| 27 | + | i.themeWatchCancel = nil | |
| 28 | + | } | |
| 29 | + | if i.runCtx == nil || i.activeThemeSource == nil { | |
| 30 | + | return | |
| 31 | + | } | |
| 32 | + | ctx, cancel := context.WithCancel(i.runCtx) | |
| 33 | + | i.themeWatchCancel = cancel | |
| 34 | + | interval := i.cfg.ThemeWatchInterval | |
| 35 | + | if interval <= 0 { | |
| 36 | + | interval = themeWatchInterval | |
| 37 | + | } | |
| 38 | + | go watchThemeSource(ctx, i.cfg.ZutHome, i.cfg.EffectiveThemeName, i.activeThemeSource, interval, i.themeWatchEvents) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (i *Interactive) stopThemeWatch() { | |
| 42 | + | if i.themeWatchCancel != nil { | |
| 43 | + | i.themeWatchCancel() | |
| 44 | + | i.themeWatchCancel = nil | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func watchThemeSource(ctx context.Context, home, preference string, accepted *tui.ThemeSource, interval time.Duration, events chan<- themeWatchEvent) { | |
| 49 | + | if accepted == nil || accepted.Path == "" { | |
| 50 | + | return | |
| 51 | + | } | |
| 52 | + | ticker := time.NewTicker(interval) | |
| 53 | + | defer ticker.Stop() | |
| 54 | + | watchThemeSourceTicks(ctx, home, preference, accepted, ticker.C, events) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // watchThemeSourceTicks is the test seam for polling: tests advance explicit | |
| 58 | + | // timestamps rather than synchronizing with sleeps. | |
| 59 | + | func watchThemeSourceTicks(ctx context.Context, home, preference string, accepted *tui.ThemeSource, ticks <-chan time.Time, events chan<- themeWatchEvent) { | |
| 60 | + | if accepted == nil || accepted.Path == "" { | |
| 61 | + | return | |
| 62 | + | } | |
| 63 | + | lastDigest := accepted.Digest | |
| 64 | + | var missingSince time.Time | |
| 65 | + | lastError := "" | |
| 66 | + | for { | |
| 67 | + | select { | |
| 68 | + | case <-ctx.Done(): | |
| 69 | + | return | |
| 70 | + | case now := <-ticks: | |
| 71 | + | if _, statErr := os.Stat(accepted.Path); errors.Is(statErr, os.ErrNotExist) { | |
| 72 | + | if missingSince.IsZero() { | |
| 73 | + | missingSince = now | |
| 74 | + | } | |
| 75 | + | if now.Sub(missingSince) >= themeMissingGrace { | |
| 76 | + | sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, deleted: true}) | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | continue | |
| 80 | + | } | |
| 81 | + | // A present-but-invalid revision remains watchable and must restart | |
| 82 | + | // the deletion grace period just like a valid replacement does. | |
| 83 | + | missingSince = time.Time{} | |
| 84 | + | source, err := tui.LoadThemeSource(home, preference) | |
| 85 | + | if err == nil && source != nil { | |
| 86 | + | lastError = "" | |
| 87 | + | if source.Path != accepted.Path || source.Digest == lastDigest { | |
| 88 | + | continue | |
| 89 | + | } | |
| 90 | + | lastDigest = source.Digest | |
| 91 | + | sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, source: source}) | |
| 92 | + | continue | |
| 93 | + | } | |
| 94 | + | if err != nil && err.Error() != lastError { | |
| 95 | + | lastError = err.Error() | |
| 96 | + | sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, err: err}) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func sendThemeWatchEvent(ctx context.Context, events chan<- themeWatchEvent, event themeWatchEvent) { | |
| 103 | + | select { | |
| 104 | + | case events <- event: | |
| 105 | + | case <-ctx.Done(): | |
| 106 | + | } | |
| 107 | + | } | |
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "os" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zut/packages/tui" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | themeWatchInterval = 500 * time.Millisecond | |||
| 14 | + | themeMissingGrace = 2 * time.Second | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | type themeWatchEvent struct { | |||
| 18 | + | path string | |||
| 19 | + | source *tui.ThemeSource | |||
| 20 | + | err error | |||
| 21 | + | deleted bool | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (i *Interactive) restartThemeWatch() { | |||
| 25 | + | if i.themeWatchCancel != nil { | |||
| 26 | + | i.themeWatchCancel() | |||
| 27 | + | i.themeWatchCancel = nil | |||
| 28 | + | } | |||
| 29 | + | if i.runCtx == nil || i.activeThemeSource == nil { | |||
| 30 | + | return | |||
| 31 | + | } | |||
| 32 | + | ctx, cancel := context.WithCancel(i.runCtx) | |||
| 33 | + | i.themeWatchCancel = cancel | |||
| 34 | + | interval := i.cfg.ThemeWatchInterval | |||
| 35 | + | if interval <= 0 { | |||
| 36 | + | interval = themeWatchInterval | |||
| 37 | + | } | |||
| 38 | + | go watchThemeSource(ctx, i.cfg.ZutHome, i.cfg.EffectiveThemeName, i.activeThemeSource, interval, i.themeWatchEvents) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (i *Interactive) stopThemeWatch() { | |||
| 42 | + | if i.themeWatchCancel != nil { | |||
| 43 | + | i.themeWatchCancel() | |||
| 44 | + | i.themeWatchCancel = nil | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func watchThemeSource(ctx context.Context, home, preference string, accepted *tui.ThemeSource, interval time.Duration, events chan<- themeWatchEvent) { | |||
| 49 | + | if accepted == nil || accepted.Path == "" { | |||
| 50 | + | return | |||
| 51 | + | } | |||
| 52 | + | ticker := time.NewTicker(interval) | |||
| 53 | + | defer ticker.Stop() | |||
| 54 | + | watchThemeSourceTicks(ctx, home, preference, accepted, ticker.C, events) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // watchThemeSourceTicks is the test seam for polling: tests advance explicit | |||
| 58 | + | // timestamps rather than synchronizing with sleeps. | |||
| 59 | + | func watchThemeSourceTicks(ctx context.Context, home, preference string, accepted *tui.ThemeSource, ticks <-chan time.Time, events chan<- themeWatchEvent) { | |||
| 60 | + | if accepted == nil || accepted.Path == "" { | |||
| 61 | + | return | |||
| 62 | + | } | |||
| 63 | + | lastDigest := accepted.Digest | |||
| 64 | + | var missingSince time.Time | |||
| 65 | + | lastError := "" | |||
| 66 | + | for { | |||
| 67 | + | select { | |||
| 68 | + | case <-ctx.Done(): | |||
| 69 | + | return | |||
| 70 | + | case now := <-ticks: | |||
| 71 | + | if _, statErr := os.Stat(accepted.Path); errors.Is(statErr, os.ErrNotExist) { | |||
| 72 | + | if missingSince.IsZero() { | |||
| 73 | + | missingSince = now | |||
| 74 | + | } | |||
| 75 | + | if now.Sub(missingSince) >= themeMissingGrace { | |||
| 76 | + | sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, deleted: true}) | |||
| 77 | + | return | |||
| 78 | + | } | |||
| 79 | + | continue | |||
| 80 | + | } | |||
| 81 | + | // A present-but-invalid revision remains watchable and must restart | |||
| 82 | + | // the deletion grace period just like a valid replacement does. | |||
| 83 | + | missingSince = time.Time{} | |||
| 84 | + | source, err := tui.LoadThemeSource(home, preference) | |||
| 85 | + | if err == nil && source != nil { | |||
| 86 | + | lastError = "" | |||
| 87 | + | if source.Path != accepted.Path || source.Digest == lastDigest { | |||
| 88 | + | continue | |||
| 89 | + | } | |||
| 90 | + | lastDigest = source.Digest | |||
| 91 | + | sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, source: source}) | |||
| 92 | + | continue | |||
| 93 | + | } | |||
| 94 | + | if err != nil && err.Error() != lastError { | |||
| 95 | + | lastError = err.Error() | |||
| 96 | + | sendThemeWatchEvent(ctx, events, themeWatchEvent{path: accepted.Path, err: err}) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func sendThemeWatchEvent(ctx context.Context, events chan<- themeWatchEvent, event themeWatchEvent) { | |||
| 103 | + | select { | |||
| 104 | + | case events <- event: | |||
| 105 | + | case <-ctx.Done(): | |||
| 106 | + | } | |||
| 107 | + | } | |||
A packages/agent/modes/theme_watch_test.go +114 -0
| @@ -0,0 +1,114 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zut/packages/tui" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func writeWatchedTheme(t *testing.T, home, body string) string { | |
| 14 | + | t.Helper() | |
| 15 | + | path := filepath.Join(home, "themes", "live.json") | |
| 16 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| 17 | + | t.Fatal(err) | |
| 18 | + | } | |
| 19 | + | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |
| 20 | + | t.Fatal(err) | |
| 21 | + | } | |
| 22 | + | return path | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) { | |
| 26 | + | home := t.TempDir() | |
| 27 | + | path := writeWatchedTheme(t, home, `{"colors":{"accent":20}}`) | |
| 28 | + | accepted, err := tui.LoadThemeSource(home, "live") | |
| 29 | + | if err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 33 | + | defer cancel() | |
| 34 | + | ticks := make(chan time.Time) | |
| 35 | + | events := make(chan themeWatchEvent, 3) | |
| 36 | + | go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events) | |
| 37 | + | ||
| 38 | + | if err := os.WriteFile(path, []byte(`{"colors":{"accent":21}}`), 0o644); err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | ticks <- time.Unix(1, 0) | |
| 42 | + | select { | |
| 43 | + | case event := <-events: | |
| 44 | + | if event.source == nil || event.source.File.Colors.Base.Accent == nil || event.source.File.Colors.Base.Accent.Index != 21 { | |
| 45 | + | t.Fatalf("valid revision event = %#v", event) | |
| 46 | + | } | |
| 47 | + | case <-time.After(time.Second): | |
| 48 | + | t.Fatal("watcher did not publish valid revision") | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil { | |
| 52 | + | t.Fatal(err) | |
| 53 | + | } | |
| 54 | + | ticks <- time.Unix(2, 0) | |
| 55 | + | select { | |
| 56 | + | case event := <-events: | |
| 57 | + | if event.err == nil || event.source != nil { | |
| 58 | + | t.Fatalf("invalid revision event = %#v", event) | |
| 59 | + | } | |
| 60 | + | case <-time.After(time.Second): | |
| 61 | + | t.Fatal("watcher did not report invalid revision") | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestWatchThemeSourceTicksRestartsDeletionGraceAfterInvalidReappearance(t *testing.T) { | |
| 66 | + | home := t.TempDir() | |
| 67 | + | path := writeWatchedTheme(t, home, `{}`) | |
| 68 | + | accepted, err := tui.LoadThemeSource(home, "live") | |
| 69 | + | if err != nil { | |
| 70 | + | t.Fatal(err) | |
| 71 | + | } | |
| 72 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 73 | + | defer cancel() | |
| 74 | + | ticks := make(chan time.Time) | |
| 75 | + | events := make(chan themeWatchEvent, 1) | |
| 76 | + | go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events) | |
| 77 | + | ||
| 78 | + | if err := os.Remove(path); err != nil { | |
| 79 | + | t.Fatal(err) | |
| 80 | + | } | |
| 81 | + | ticks <- time.Unix(1, 0) | |
| 82 | + | if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | ticks <- time.Unix(2, 0) | |
| 86 | + | select { | |
| 87 | + | case event := <-events: | |
| 88 | + | if event.err == nil { | |
| 89 | + | t.Fatalf("invalid reappearance event = %#v", event) | |
| 90 | + | } | |
| 91 | + | case <-time.After(time.Second): | |
| 92 | + | t.Fatal("watcher did not report invalid reappearance") | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | if err := os.Remove(path); err != nil { | |
| 96 | + | t.Fatal(err) | |
| 97 | + | } | |
| 98 | + | ticks <- time.Unix(3, 0) | |
| 99 | + | ticks <- time.Unix(4, 0) | |
| 100 | + | select { | |
| 101 | + | case event := <-events: | |
| 102 | + | t.Fatalf("deletion happened before restarted grace period: %#v", event) | |
| 103 | + | default: | |
| 104 | + | } | |
| 105 | + | ticks <- time.Unix(6, 0) | |
| 106 | + | select { | |
| 107 | + | case event := <-events: | |
| 108 | + | if !event.deleted { | |
| 109 | + | t.Fatalf("persistent deletion event = %#v", event) | |
| 110 | + | } | |
| 111 | + | case <-time.After(time.Second): | |
| 112 | + | t.Fatal("watcher did not publish persistent deletion") | |
| 113 | + | } | |
| 114 | + | } | |
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zut/packages/tui" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func writeWatchedTheme(t *testing.T, home, body string) string { | |||
| 14 | + | t.Helper() | |||
| 15 | + | path := filepath.Join(home, "themes", "live.json") | |||
| 16 | + | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |||
| 17 | + | t.Fatal(err) | |||
| 18 | + | } | |||
| 19 | + | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |||
| 20 | + | t.Fatal(err) | |||
| 21 | + | } | |||
| 22 | + | return path | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestWatchThemeSourceTicksPublishesOnlyValidRevisions(t *testing.T) { | |||
| 26 | + | home := t.TempDir() | |||
| 27 | + | path := writeWatchedTheme(t, home, `{"colors":{"accent":20}}`) | |||
| 28 | + | accepted, err := tui.LoadThemeSource(home, "live") | |||
| 29 | + | if err != nil { | |||
| 30 | + | t.Fatal(err) | |||
| 31 | + | } | |||
| 32 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 33 | + | defer cancel() | |||
| 34 | + | ticks := make(chan time.Time) | |||
| 35 | + | events := make(chan themeWatchEvent, 3) | |||
| 36 | + | go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events) | |||
| 37 | + | ||||
| 38 | + | if err := os.WriteFile(path, []byte(`{"colors":{"accent":21}}`), 0o644); err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | ticks <- time.Unix(1, 0) | |||
| 42 | + | select { | |||
| 43 | + | case event := <-events: | |||
| 44 | + | if event.source == nil || event.source.File.Colors.Base.Accent == nil || event.source.File.Colors.Base.Accent.Index != 21 { | |||
| 45 | + | t.Fatalf("valid revision event = %#v", event) | |||
| 46 | + | } | |||
| 47 | + | case <-time.After(time.Second): | |||
| 48 | + | t.Fatal("watcher did not publish valid revision") | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil { | |||
| 52 | + | t.Fatal(err) | |||
| 53 | + | } | |||
| 54 | + | ticks <- time.Unix(2, 0) | |||
| 55 | + | select { | |||
| 56 | + | case event := <-events: | |||
| 57 | + | if event.err == nil || event.source != nil { | |||
| 58 | + | t.Fatalf("invalid revision event = %#v", event) | |||
| 59 | + | } | |||
| 60 | + | case <-time.After(time.Second): | |||
| 61 | + | t.Fatal("watcher did not report invalid revision") | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestWatchThemeSourceTicksRestartsDeletionGraceAfterInvalidReappearance(t *testing.T) { | |||
| 66 | + | home := t.TempDir() | |||
| 67 | + | path := writeWatchedTheme(t, home, `{}`) | |||
| 68 | + | accepted, err := tui.LoadThemeSource(home, "live") | |||
| 69 | + | if err != nil { | |||
| 70 | + | t.Fatal(err) | |||
| 71 | + | } | |||
| 72 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 73 | + | defer cancel() | |||
| 74 | + | ticks := make(chan time.Time) | |||
| 75 | + | events := make(chan themeWatchEvent, 1) | |||
| 76 | + | go watchThemeSourceTicks(ctx, home, "live", accepted, ticks, events) | |||
| 77 | + | ||||
| 78 | + | if err := os.Remove(path); err != nil { | |||
| 79 | + | t.Fatal(err) | |||
| 80 | + | } | |||
| 81 | + | ticks <- time.Unix(1, 0) | |||
| 82 | + | if err := os.WriteFile(path, []byte(`{"colors":{"accent":999}}`), 0o644); err != nil { | |||
| 83 | + | t.Fatal(err) | |||
| 84 | + | } | |||
| 85 | + | ticks <- time.Unix(2, 0) | |||
| 86 | + | select { | |||
| 87 | + | case event := <-events: | |||
| 88 | + | if event.err == nil { | |||
| 89 | + | t.Fatalf("invalid reappearance event = %#v", event) | |||
| 90 | + | } | |||
| 91 | + | case <-time.After(time.Second): | |||
| 92 | + | t.Fatal("watcher did not report invalid reappearance") | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | if err := os.Remove(path); err != nil { | |||
| 96 | + | t.Fatal(err) | |||
| 97 | + | } | |||
| 98 | + | ticks <- time.Unix(3, 0) | |||
| 99 | + | ticks <- time.Unix(4, 0) | |||
| 100 | + | select { | |||
| 101 | + | case event := <-events: | |||
| 102 | + | t.Fatalf("deletion happened before restarted grace period: %#v", event) | |||
| 103 | + | default: | |||
| 104 | + | } | |||
| 105 | + | ticks <- time.Unix(6, 0) | |||
| 106 | + | select { | |||
| 107 | + | case event := <-events: | |||
| 108 | + | if !event.deleted { | |||
| 109 | + | t.Fatalf("persistent deletion event = %#v", event) | |||
| 110 | + | } | |||
| 111 | + | case <-time.After(time.Second): | |||
| 112 | + | t.Fatal("watcher did not publish persistent deletion") | |||
| 113 | + | } | |||
| 114 | + | } | |||
M packages/agent/settings_store.go +1 -1
| @@ -231,7 +231,7 @@ func (configSettingsStore) SetTheme(name string) error { | |||
| 231 | 231 | if err != nil { | |
| 232 | 232 | return err | |
| 233 | 233 | } | |
| 234 | - | if name == "auto" { | |
| 234 | + | if name == "auto" || name == "inherited" { | |
| 235 | 235 | name = "" | |
| 236 | 236 | } | |
| 237 | 237 | cfg.Theme = name | |
| @@ -231,7 +231,7 @@ func (configSettingsStore) SetTheme(name string) error { | |||||
| 231 | if err != nil { | 231 | if err != nil { | ||
| 232 | return err | 232 | return err | ||
| 233 | } | 233 | } | ||
| 234 | - | if name == "auto" { | 234 | + | if name == "auto" || name == "inherited" { |
| 235 | name = "" | 235 | name = "" | ||
| 236 | } | 236 | } | ||
| 237 | cfg.Theme = name | 237 | cfg.Theme = name | ||
M packages/agent/settings_store_test.go +3 -3
| @@ -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 | 206 | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 207 | 207 | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |
| 208 | 208 | t.Fatal(err) | |
| @@ -215,8 +215,8 @@ func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) { | |||
| 215 | 215 | if err != nil { | |
| 216 | 216 | t.Fatal(err) | |
| 217 | 217 | } | |
| 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) | |
| 220 | 220 | } | |
| 221 | 221 | } | |
| 222 | 222 | ||
| @@ -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 | |||
| 5 | 5 | ||
| 6 | 6 | # Writing zut themes | |
| 7 | 7 | ||
| 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. | |
| 11 | 11 | ||
| 12 | - | ## What a zut theme is | |
| 12 | + | ## Theme model | |
| 13 | 13 | ||
| 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. | |
| 19 | 18 | ||
| 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: | |
| 28 | 20 | ||
| 29 | 21 | ```text | |
| 30 | 22 | $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 | 27 | ```text | |
| 36 | 28 | $ZUT_HOME/extensions/<extension>/theme.json | |
| @@ -39,36 +31,19 @@ $ZUT_HOME/extensions/<extension>/themes/theme.json | |||
| 39 | 31 | <project>/.zut/extensions/<extension>/themes/theme.json | |
| 40 | 32 | ``` | |
| 41 | 33 | ||
| 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. | |
| 45 | 37 | ||
| 46 | 38 | ## 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 | 40 | ```json | |
| 62 | 41 | { | |
| 63 | 42 | "name": "pink-accent", | |
| 64 | - | "colors": { | |
| 65 | - | "accent": 204 | |
| 66 | - | } | |
| 43 | + | "colors": { "accent": 204 } | |
| 67 | 44 | } | |
| 68 | 45 | ``` | |
| 69 | 46 | ||
| 70 | - | ### Change one color per mode | |
| 71 | - | ||
| 72 | 47 | ```json | |
| 73 | 48 | { | |
| 74 | 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 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. | |
| 116 | 59 | ||
| 117 | - | All fields below are optional. | |
| 60 | + | ## Valid values | |
| 118 | 61 | ||
| 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`. | |
| 174 | 66 | ||
| 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: | |
| 208 | 68 | ||
| 209 | 69 | ```json | |
| 210 | 70 | 254 | |
| @@ -214,115 +74,19 @@ Every color field accepts the following JSON forms: | |||
| 214 | 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, 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. | |
| 235 | 81 | ||
| 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"`. | |
| 301 | 85 | ||
| 302 | 86 | ## Theme-only extensions | |
| 303 | 87 | ||
| 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. | |
| 326 | 90 | ||
| 327 | 91 | ```json | |
| 328 | 92 | { | |
| @@ -332,20 +96,3 @@ $ZUT_HOME/extensions/my-theme-extension/ | |||
| 332 | 96 | "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. | |
| @@ -5,32 +5,24 @@ description: Help the user create, install, or package zut themes, including the | |||||
| 5 | 5 | ||||
| 6 | # Writing zut themes | 6 | # 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 skill | 9 | + | 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 is | 12 | + | ## Theme model |
| 13 | 13 | ||||
| 14 | - | A zut theme is a JSON file that overrides any subset of zut's built-in | 14 | + | `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 spinner | 16 | + | so missing fields remain terminal-owned. `dark` and `light` are fixed explicit |
| 17 | - | settings, only syntax colors, or only metadata. Missing values always | 17 | + | 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 | ```text | 21 | ```text | ||
| 30 | $ZUT_HOME/themes/*.json | 22 | $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 | ```text | 27 | ```text | ||
| 36 | $ZUT_HOME/extensions/<extension>/theme.json | 28 | $ZUT_HOME/extensions/<extension>/theme.json | ||
| @@ -39,36 +31,19 @@ $ZUT_HOME/extensions/<extension>/themes/theme.json | |||||
| 39 | <project>/.zut/extensions/<extension>/themes/theme.json | 31 | <project>/.zut/extensions/<extension>/themes/theme.json | ||
| 40 | ``` | 32 | ``` | ||
| 41 | 33 | ||||
| 42 | - | zut does **not** copy extension themes into `$ZUT_HOME/themes`; extension | 34 | + | An active custom file reloads during interactive sessions. Always produce |
| 43 | - | owned themes stay in the extension directory. The settings picker stores | 35 | + | 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 examples | 38 | ## 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 | ```json | 40 | ```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 | ```json | 47 | ```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 theme | 57 | + | 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 | - | ```json | 62 | + | 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 directly | 67 | + | 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 | ```json | 69 | ```json | ||
| 210 | 254 | 70 | 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, with | 77 | + | 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 to | 79 | + | 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 | - | ```json | 84 | + | 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 extensions | 86 | ## 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 a | 89 | + | 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 | ```json | 91 | ```json | ||
| 328 | { | 92 | { | ||
| @@ -332,20 +96,3 @@ $ZUT_HOME/extensions/my-theme-extension/ | |||||
| 332 | "enabled": true | 96 | "enabled": true | ||
| 333 | } | 97 | } | ||
| 334 | ``` | 98 | ``` | ||
| 335 | - | ||||
| 336 | - | No `exec` is needed when `theme.json` or `themes/theme.json` exists. | |||
| 337 | - | If `exec` is present, zut treats it as a normal extension too. | |||
| 338 | - | ||||
| 339 | - | In `/settings → color theme`, extension themes show source info in | |||
| 340 | - | the description, e.g. `from extension my-theme-extension — ...`. | |||
| 341 | - | ||||
| 342 | - | ## Recommendations | |||
| 343 | - | ||||
| 344 | - | - Prefer short descriptions; settings wraps them, but concise is | |||
| 345 | - | easier to scan. | |||
| 346 | - | - Keep spinner frames single-width when possible. | |||
| 347 | - | - For theme packages, use a unique extension name so the source label | |||
| 348 | - | is clear. | |||
| 349 | - | - For user-editable themes, keep comments out of JSON; JSON comments | |||
| 350 | - | are not supported. | |||
| 351 | - | - Validate with `python3 -m json.tool theme.json` before installing. | |||
A packages/tui/appearance_protocol.go +307 -0
| @@ -0,0 +1,307 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "strconv" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Appearance query and DEC mode 2031 control sequences. | |
| 12 | + | const ( | |
| 13 | + | SeqQueryColorScheme = "\x1b[?996n" | |
| 14 | + | SeqQueryMode2031 = "\x1b[?2031$p" | |
| 15 | + | SeqEnableMode2031 = "\x1b[?2031h" | |
| 16 | + | SeqDisableMode2031 = "\x1b[?2031l" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // AppearanceQuery returns the complete profile query used at startup and on a | |
| 20 | + | // replacement generation. The ANSI palette is requested in one OSC 4 batch. | |
| 21 | + | func AppearanceQuery() string { | |
| 22 | + | var b strings.Builder | |
| 23 | + | b.WriteString(SeqQueryColorScheme) | |
| 24 | + | b.WriteString("\x1b]10;?\a\x1b]11;?\a\x1b]4;") | |
| 25 | + | for slot := 0; slot < 16; slot++ { | |
| 26 | + | if slot > 0 { | |
| 27 | + | b.WriteByte(';') | |
| 28 | + | } | |
| 29 | + | b.WriteString(strconv.Itoa(slot)) | |
| 30 | + | b.WriteString(";?") | |
| 31 | + | } | |
| 32 | + | b.WriteByte('\a') | |
| 33 | + | return b.String() | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // SchemeEvent is a reported terminal color-scheme preference. Solicited is | |
| 37 | + | // true only for the reply to a query initiated by zut. | |
| 38 | + | type SchemeEvent struct { | |
| 39 | + | Light bool | |
| 40 | + | Solicited bool | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // ColorEvent is one default foreground/background or ANSI-palette reply. | |
| 44 | + | type ColorEvent struct { | |
| 45 | + | Kind int // 10 foreground, 11 background, 4 palette | |
| 46 | + | Slot int | |
| 47 | + | Color TerminalColor | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // ModeReportEvent is a DECRQM response. Status follows the terminal protocol: | |
| 51 | + | // 1/3 set, 2 reset, 0/4 unsupported or permanently reset. | |
| 52 | + | type ModeReportEvent struct { | |
| 53 | + | Mode int | |
| 54 | + | Status int | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // InputEvent is emitted by the appearance-aware input path. Exactly one field | |
| 58 | + | // is set. Key bytes that do not form an accepted protocol reply remain keys. | |
| 59 | + | type InputEvent struct { | |
| 60 | + | Key *Key | |
| 61 | + | Scheme *SchemeEvent | |
| 62 | + | Color *ColorEvent | |
| 63 | + | Mode *ModeReportEvent | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // AppearanceParser controls which terminal replies may be consumed. Keeping | |
| 67 | + | // the gate here prevents OSC-looking editor input from disappearing unless zut | |
| 68 | + | // actively issued a matching query, while 997 notifications are accepted only | |
| 69 | + | // after notification support is known. | |
| 70 | + | type AppearanceParser struct { | |
| 71 | + | mu sync.RWMutex | |
| 72 | + | pendingColors bool | |
| 73 | + | pendingScheme bool | |
| 74 | + | notifications bool | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (p *AppearanceParser) SetPendingColors(pending bool) { | |
| 78 | + | p.mu.Lock() | |
| 79 | + | p.pendingColors = pending | |
| 80 | + | p.mu.Unlock() | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (p *AppearanceParser) SetNotifications(enabled bool) { | |
| 84 | + | p.mu.Lock() | |
| 85 | + | p.notifications = enabled | |
| 86 | + | p.mu.Unlock() | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // SetPendingScheme permits one solicited 997 response and marks it so the | |
| 90 | + | // event loop does not mistake it for a new terminal notification. | |
| 91 | + | func (p *AppearanceParser) SetPendingScheme(pending bool) { | |
| 92 | + | p.mu.Lock() | |
| 93 | + | p.pendingScheme = pending | |
| 94 | + | p.mu.Unlock() | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (p *AppearanceParser) acceptsColors() bool { | |
| 98 | + | p.mu.RLock() | |
| 99 | + | defer p.mu.RUnlock() | |
| 100 | + | return p.pendingColors | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (p *AppearanceParser) acceptsNotifications() bool { | |
| 104 | + | p.mu.RLock() | |
| 105 | + | defer p.mu.RUnlock() | |
| 106 | + | return p.notifications | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (p *AppearanceParser) consumeScheme() (solicited bool, accepted bool) { | |
| 110 | + | p.mu.Lock() | |
| 111 | + | defer p.mu.Unlock() | |
| 112 | + | if p.pendingScheme { | |
| 113 | + | p.pendingScheme = false | |
| 114 | + | return true, true | |
| 115 | + | } | |
| 116 | + | return false, p.notifications | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // ParseAppearanceOSC parses one complete BEL/ST-terminated OSC sequence. | |
| 120 | + | // Combined OSC 4 responses produce one ColorEvent for every valid pair. | |
| 121 | + | func ParseAppearanceOSC(raw []byte) []ColorEvent { | |
| 122 | + | s := strings.TrimSuffix(string(raw), "\a") | |
| 123 | + | s = strings.TrimSuffix(s, "\x1b\\") | |
| 124 | + | if !strings.HasPrefix(s, "\x1b]") { | |
| 125 | + | return nil | |
| 126 | + | } | |
| 127 | + | body := strings.TrimPrefix(s, "\x1b]") | |
| 128 | + | parts := strings.Split(body, ";") | |
| 129 | + | if len(parts) < 2 { | |
| 130 | + | return nil | |
| 131 | + | } | |
| 132 | + | kind, err := strconv.Atoi(parts[0]) | |
| 133 | + | if err != nil { | |
| 134 | + | return nil | |
| 135 | + | } | |
| 136 | + | switch kind { | |
| 137 | + | case 10, 11: | |
| 138 | + | color, ok := parseTerminalRGB(strings.Join(parts[1:], ";")) | |
| 139 | + | if !ok { | |
| 140 | + | return nil | |
| 141 | + | } | |
| 142 | + | return []ColorEvent{{Kind: kind, Slot: -1, Color: color}} | |
| 143 | + | case 4: | |
| 144 | + | var events []ColorEvent | |
| 145 | + | for idx := 1; idx+1 < len(parts); idx += 2 { | |
| 146 | + | slot, err := strconv.Atoi(parts[idx]) | |
| 147 | + | if err != nil || slot < 0 || slot >= 16 { | |
| 148 | + | continue | |
| 149 | + | } | |
| 150 | + | color, ok := parseTerminalRGB(parts[idx+1]) | |
| 151 | + | if ok { | |
| 152 | + | events = append(events, ColorEvent{Kind: 4, Slot: slot, Color: color}) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | return events | |
| 156 | + | default: | |
| 157 | + | return nil | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // ParseAppearanceCSI parses the two runtime CSI response families used by | |
| 162 | + | // terminal-owned themes: current scheme (997) and mode ownership (DECRQM). | |
| 163 | + | func ParseAppearanceCSI(raw []byte) (scheme *SchemeEvent, mode *ModeReportEvent) { | |
| 164 | + | s := string(raw) | |
| 165 | + | if !strings.HasPrefix(s, "\x1b[") { | |
| 166 | + | return nil, nil | |
| 167 | + | } | |
| 168 | + | body := strings.TrimPrefix(s, "\x1b[") | |
| 169 | + | if strings.HasPrefix(body, "?997;") && strings.HasSuffix(body, "n") { | |
| 170 | + | value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?997;"), "n")) | |
| 171 | + | if err == nil && (value == 1 || value == 2) { | |
| 172 | + | return &SchemeEvent{Light: value == 2}, nil | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | if strings.HasPrefix(body, "?2031;") && strings.HasSuffix(body, "$y") { | |
| 176 | + | value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?2031;"), "$y")) | |
| 177 | + | if err == nil { | |
| 178 | + | return nil, &ModeReportEvent{Mode: 2031, Status: value} | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | return nil, nil | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // AppearanceSource filters accepted terminal appearance replies before they | |
| 185 | + | // reach Reader. It deliberately leaves bracketed-paste payload opaque. | |
| 186 | + | type AppearanceSource struct { | |
| 187 | + | read func() (byte, error) | |
| 188 | + | peek func(time.Duration) (byte, bool, error) | |
| 189 | + | parser *AppearanceParser | |
| 190 | + | emit func(InputEvent) | |
| 191 | + | ||
| 192 | + | pending []byte | |
| 193 | + | inPaste bool | |
| 194 | + | pasteTail []byte | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func NewAppearanceSource(read func() (byte, error), peek func(time.Duration) (byte, bool, error), parser *AppearanceParser, emit func(InputEvent)) *AppearanceSource { | |
| 198 | + | return &AppearanceSource{read: read, peek: peek, parser: parser, emit: emit} | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func (s *AppearanceSource) ReadByte() (byte, error) { | |
| 202 | + | if len(s.pending) > 0 { | |
| 203 | + | b := s.pending[0] | |
| 204 | + | s.pending = s.pending[1:] | |
| 205 | + | return b, nil | |
| 206 | + | } | |
| 207 | + | for { | |
| 208 | + | b, err := s.read() | |
| 209 | + | if err != nil || b != '\x1b' || s.inPaste || s.peek == nil { | |
| 210 | + | s.trackPaste(b) | |
| 211 | + | return b, err | |
| 212 | + | } | |
| 213 | + | lookahead := time.Millisecond | |
| 214 | + | if s.parser != nil && (s.parser.acceptsColors() || s.parser.acceptsNotifications()) { | |
| 215 | + | lookahead = 50 * time.Millisecond | |
| 216 | + | } | |
| 217 | + | next, ok, err := s.peek(lookahead) | |
| 218 | + | if err != nil || !ok || (next != ']' && next != '[') { | |
| 219 | + | if ok { | |
| 220 | + | s.pending = append(s.pending, next) | |
| 221 | + | } | |
| 222 | + | return b, err | |
| 223 | + | } | |
| 224 | + | raw := []byte{'\x1b', next} | |
| 225 | + | if next == ']' { | |
| 226 | + | sequence, err := s.readOSC(raw) | |
| 227 | + | if err != nil { | |
| 228 | + | return 0, err | |
| 229 | + | } | |
| 230 | + | if events := ParseAppearanceOSC(sequence); len(events) != 0 && s.parser != nil && s.parser.acceptsColors() { | |
| 231 | + | for _, event := range events { | |
| 232 | + | e := event | |
| 233 | + | s.emit(InputEvent{Color: &e}) | |
| 234 | + | } | |
| 235 | + | continue | |
| 236 | + | } | |
| 237 | + | s.pending = append(s.pending, sequence[1:]...) | |
| 238 | + | return '\x1b', nil | |
| 239 | + | } | |
| 240 | + | sequence, err := s.readCSI(raw) | |
| 241 | + | if err != nil { | |
| 242 | + | return 0, err | |
| 243 | + | } | |
| 244 | + | if bytes.Equal(sequence, []byte("\x1b[200~")) { | |
| 245 | + | s.inPaste = true | |
| 246 | + | } | |
| 247 | + | scheme, mode := ParseAppearanceCSI(sequence) | |
| 248 | + | if scheme != nil && s.parser != nil { | |
| 249 | + | if solicited, accepted := s.parser.consumeScheme(); accepted { | |
| 250 | + | scheme.Solicited = solicited | |
| 251 | + | s.emit(InputEvent{Scheme: scheme}) | |
| 252 | + | continue | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | if mode != nil { | |
| 256 | + | s.emit(InputEvent{Mode: mode}) | |
| 257 | + | continue | |
| 258 | + | } | |
| 259 | + | s.pending = append(s.pending, sequence[1:]...) | |
| 260 | + | return '\x1b', nil | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func (s *AppearanceSource) readOSC(raw []byte) ([]byte, error) { | |
| 265 | + | const limit = 4096 | |
| 266 | + | for len(raw) < limit { | |
| 267 | + | b, err := s.read() | |
| 268 | + | if err != nil { | |
| 269 | + | return raw, err | |
| 270 | + | } | |
| 271 | + | raw = append(raw, b) | |
| 272 | + | if b == '\a' || (len(raw) >= 2 && raw[len(raw)-2] == '\x1b' && b == '\\') { | |
| 273 | + | return raw, nil | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | return raw, nil | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func (s *AppearanceSource) readCSI(raw []byte) ([]byte, error) { | |
| 280 | + | const limit = 256 | |
| 281 | + | for len(raw) < limit { | |
| 282 | + | b, err := s.read() | |
| 283 | + | if err != nil { | |
| 284 | + | return raw, err | |
| 285 | + | } | |
| 286 | + | raw = append(raw, b) | |
| 287 | + | if b >= 0x40 && b <= 0x7e { | |
| 288 | + | return raw, nil | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | return raw, nil | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func (s *AppearanceSource) trackPaste(b byte) { | |
| 295 | + | if !s.inPaste { | |
| 296 | + | return | |
| 297 | + | } | |
| 298 | + | const end = "\x1b[201~" | |
| 299 | + | s.pasteTail = append(s.pasteTail, b) | |
| 300 | + | if len(s.pasteTail) > len(end) { | |
| 301 | + | s.pasteTail = s.pasteTail[1:] | |
| 302 | + | } | |
| 303 | + | if string(s.pasteTail) == end { | |
| 304 | + | s.inPaste = false | |
| 305 | + | s.pasteTail = nil | |
| 306 | + | } | |
| 307 | + | } | |
| @@ -0,0 +1,307 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "strconv" | |||
| 6 | + | "strings" | |||
| 7 | + | "sync" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Appearance query and DEC mode 2031 control sequences. | |||
| 12 | + | const ( | |||
| 13 | + | SeqQueryColorScheme = "\x1b[?996n" | |||
| 14 | + | SeqQueryMode2031 = "\x1b[?2031$p" | |||
| 15 | + | SeqEnableMode2031 = "\x1b[?2031h" | |||
| 16 | + | SeqDisableMode2031 = "\x1b[?2031l" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // AppearanceQuery returns the complete profile query used at startup and on a | |||
| 20 | + | // replacement generation. The ANSI palette is requested in one OSC 4 batch. | |||
| 21 | + | func AppearanceQuery() string { | |||
| 22 | + | var b strings.Builder | |||
| 23 | + | b.WriteString(SeqQueryColorScheme) | |||
| 24 | + | b.WriteString("\x1b]10;?\a\x1b]11;?\a\x1b]4;") | |||
| 25 | + | for slot := 0; slot < 16; slot++ { | |||
| 26 | + | if slot > 0 { | |||
| 27 | + | b.WriteByte(';') | |||
| 28 | + | } | |||
| 29 | + | b.WriteString(strconv.Itoa(slot)) | |||
| 30 | + | b.WriteString(";?") | |||
| 31 | + | } | |||
| 32 | + | b.WriteByte('\a') | |||
| 33 | + | return b.String() | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // SchemeEvent is a reported terminal color-scheme preference. Solicited is | |||
| 37 | + | // true only for the reply to a query initiated by zut. | |||
| 38 | + | type SchemeEvent struct { | |||
| 39 | + | Light bool | |||
| 40 | + | Solicited bool | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // ColorEvent is one default foreground/background or ANSI-palette reply. | |||
| 44 | + | type ColorEvent struct { | |||
| 45 | + | Kind int // 10 foreground, 11 background, 4 palette | |||
| 46 | + | Slot int | |||
| 47 | + | Color TerminalColor | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // ModeReportEvent is a DECRQM response. Status follows the terminal protocol: | |||
| 51 | + | // 1/3 set, 2 reset, 0/4 unsupported or permanently reset. | |||
| 52 | + | type ModeReportEvent struct { | |||
| 53 | + | Mode int | |||
| 54 | + | Status int | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // InputEvent is emitted by the appearance-aware input path. Exactly one field | |||
| 58 | + | // is set. Key bytes that do not form an accepted protocol reply remain keys. | |||
| 59 | + | type InputEvent struct { | |||
| 60 | + | Key *Key | |||
| 61 | + | Scheme *SchemeEvent | |||
| 62 | + | Color *ColorEvent | |||
| 63 | + | Mode *ModeReportEvent | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // AppearanceParser controls which terminal replies may be consumed. Keeping | |||
| 67 | + | // the gate here prevents OSC-looking editor input from disappearing unless zut | |||
| 68 | + | // actively issued a matching query, while 997 notifications are accepted only | |||
| 69 | + | // after notification support is known. | |||
| 70 | + | type AppearanceParser struct { | |||
| 71 | + | mu sync.RWMutex | |||
| 72 | + | pendingColors bool | |||
| 73 | + | pendingScheme bool | |||
| 74 | + | notifications bool | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (p *AppearanceParser) SetPendingColors(pending bool) { | |||
| 78 | + | p.mu.Lock() | |||
| 79 | + | p.pendingColors = pending | |||
| 80 | + | p.mu.Unlock() | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (p *AppearanceParser) SetNotifications(enabled bool) { | |||
| 84 | + | p.mu.Lock() | |||
| 85 | + | p.notifications = enabled | |||
| 86 | + | p.mu.Unlock() | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // SetPendingScheme permits one solicited 997 response and marks it so the | |||
| 90 | + | // event loop does not mistake it for a new terminal notification. | |||
| 91 | + | func (p *AppearanceParser) SetPendingScheme(pending bool) { | |||
| 92 | + | p.mu.Lock() | |||
| 93 | + | p.pendingScheme = pending | |||
| 94 | + | p.mu.Unlock() | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (p *AppearanceParser) acceptsColors() bool { | |||
| 98 | + | p.mu.RLock() | |||
| 99 | + | defer p.mu.RUnlock() | |||
| 100 | + | return p.pendingColors | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func (p *AppearanceParser) acceptsNotifications() bool { | |||
| 104 | + | p.mu.RLock() | |||
| 105 | + | defer p.mu.RUnlock() | |||
| 106 | + | return p.notifications | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (p *AppearanceParser) consumeScheme() (solicited bool, accepted bool) { | |||
| 110 | + | p.mu.Lock() | |||
| 111 | + | defer p.mu.Unlock() | |||
| 112 | + | if p.pendingScheme { | |||
| 113 | + | p.pendingScheme = false | |||
| 114 | + | return true, true | |||
| 115 | + | } | |||
| 116 | + | return false, p.notifications | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // ParseAppearanceOSC parses one complete BEL/ST-terminated OSC sequence. | |||
| 120 | + | // Combined OSC 4 responses produce one ColorEvent for every valid pair. | |||
| 121 | + | func ParseAppearanceOSC(raw []byte) []ColorEvent { | |||
| 122 | + | s := strings.TrimSuffix(string(raw), "\a") | |||
| 123 | + | s = strings.TrimSuffix(s, "\x1b\\") | |||
| 124 | + | if !strings.HasPrefix(s, "\x1b]") { | |||
| 125 | + | return nil | |||
| 126 | + | } | |||
| 127 | + | body := strings.TrimPrefix(s, "\x1b]") | |||
| 128 | + | parts := strings.Split(body, ";") | |||
| 129 | + | if len(parts) < 2 { | |||
| 130 | + | return nil | |||
| 131 | + | } | |||
| 132 | + | kind, err := strconv.Atoi(parts[0]) | |||
| 133 | + | if err != nil { | |||
| 134 | + | return nil | |||
| 135 | + | } | |||
| 136 | + | switch kind { | |||
| 137 | + | case 10, 11: | |||
| 138 | + | color, ok := parseTerminalRGB(strings.Join(parts[1:], ";")) | |||
| 139 | + | if !ok { | |||
| 140 | + | return nil | |||
| 141 | + | } | |||
| 142 | + | return []ColorEvent{{Kind: kind, Slot: -1, Color: color}} | |||
| 143 | + | case 4: | |||
| 144 | + | var events []ColorEvent | |||
| 145 | + | for idx := 1; idx+1 < len(parts); idx += 2 { | |||
| 146 | + | slot, err := strconv.Atoi(parts[idx]) | |||
| 147 | + | if err != nil || slot < 0 || slot >= 16 { | |||
| 148 | + | continue | |||
| 149 | + | } | |||
| 150 | + | color, ok := parseTerminalRGB(parts[idx+1]) | |||
| 151 | + | if ok { | |||
| 152 | + | events = append(events, ColorEvent{Kind: 4, Slot: slot, Color: color}) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | return events | |||
| 156 | + | default: | |||
| 157 | + | return nil | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // ParseAppearanceCSI parses the two runtime CSI response families used by | |||
| 162 | + | // terminal-owned themes: current scheme (997) and mode ownership (DECRQM). | |||
| 163 | + | func ParseAppearanceCSI(raw []byte) (scheme *SchemeEvent, mode *ModeReportEvent) { | |||
| 164 | + | s := string(raw) | |||
| 165 | + | if !strings.HasPrefix(s, "\x1b[") { | |||
| 166 | + | return nil, nil | |||
| 167 | + | } | |||
| 168 | + | body := strings.TrimPrefix(s, "\x1b[") | |||
| 169 | + | if strings.HasPrefix(body, "?997;") && strings.HasSuffix(body, "n") { | |||
| 170 | + | value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?997;"), "n")) | |||
| 171 | + | if err == nil && (value == 1 || value == 2) { | |||
| 172 | + | return &SchemeEvent{Light: value == 2}, nil | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | if strings.HasPrefix(body, "?2031;") && strings.HasSuffix(body, "$y") { | |||
| 176 | + | value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(body, "?2031;"), "$y")) | |||
| 177 | + | if err == nil { | |||
| 178 | + | return nil, &ModeReportEvent{Mode: 2031, Status: value} | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | return nil, nil | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // AppearanceSource filters accepted terminal appearance replies before they | |||
| 185 | + | // reach Reader. It deliberately leaves bracketed-paste payload opaque. | |||
| 186 | + | type AppearanceSource struct { | |||
| 187 | + | read func() (byte, error) | |||
| 188 | + | peek func(time.Duration) (byte, bool, error) | |||
| 189 | + | parser *AppearanceParser | |||
| 190 | + | emit func(InputEvent) | |||
| 191 | + | ||||
| 192 | + | pending []byte | |||
| 193 | + | inPaste bool | |||
| 194 | + | pasteTail []byte | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func NewAppearanceSource(read func() (byte, error), peek func(time.Duration) (byte, bool, error), parser *AppearanceParser, emit func(InputEvent)) *AppearanceSource { | |||
| 198 | + | return &AppearanceSource{read: read, peek: peek, parser: parser, emit: emit} | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func (s *AppearanceSource) ReadByte() (byte, error) { | |||
| 202 | + | if len(s.pending) > 0 { | |||
| 203 | + | b := s.pending[0] | |||
| 204 | + | s.pending = s.pending[1:] | |||
| 205 | + | return b, nil | |||
| 206 | + | } | |||
| 207 | + | for { | |||
| 208 | + | b, err := s.read() | |||
| 209 | + | if err != nil || b != '\x1b' || s.inPaste || s.peek == nil { | |||
| 210 | + | s.trackPaste(b) | |||
| 211 | + | return b, err | |||
| 212 | + | } | |||
| 213 | + | lookahead := time.Millisecond | |||
| 214 | + | if s.parser != nil && (s.parser.acceptsColors() || s.parser.acceptsNotifications()) { | |||
| 215 | + | lookahead = 50 * time.Millisecond | |||
| 216 | + | } | |||
| 217 | + | next, ok, err := s.peek(lookahead) | |||
| 218 | + | if err != nil || !ok || (next != ']' && next != '[') { | |||
| 219 | + | if ok { | |||
| 220 | + | s.pending = append(s.pending, next) | |||
| 221 | + | } | |||
| 222 | + | return b, err | |||
| 223 | + | } | |||
| 224 | + | raw := []byte{'\x1b', next} | |||
| 225 | + | if next == ']' { | |||
| 226 | + | sequence, err := s.readOSC(raw) | |||
| 227 | + | if err != nil { | |||
| 228 | + | return 0, err | |||
| 229 | + | } | |||
| 230 | + | if events := ParseAppearanceOSC(sequence); len(events) != 0 && s.parser != nil && s.parser.acceptsColors() { | |||
| 231 | + | for _, event := range events { | |||
| 232 | + | e := event | |||
| 233 | + | s.emit(InputEvent{Color: &e}) | |||
| 234 | + | } | |||
| 235 | + | continue | |||
| 236 | + | } | |||
| 237 | + | s.pending = append(s.pending, sequence[1:]...) | |||
| 238 | + | return '\x1b', nil | |||
| 239 | + | } | |||
| 240 | + | sequence, err := s.readCSI(raw) | |||
| 241 | + | if err != nil { | |||
| 242 | + | return 0, err | |||
| 243 | + | } | |||
| 244 | + | if bytes.Equal(sequence, []byte("\x1b[200~")) { | |||
| 245 | + | s.inPaste = true | |||
| 246 | + | } | |||
| 247 | + | scheme, mode := ParseAppearanceCSI(sequence) | |||
| 248 | + | if scheme != nil && s.parser != nil { | |||
| 249 | + | if solicited, accepted := s.parser.consumeScheme(); accepted { | |||
| 250 | + | scheme.Solicited = solicited | |||
| 251 | + | s.emit(InputEvent{Scheme: scheme}) | |||
| 252 | + | continue | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | if mode != nil { | |||
| 256 | + | s.emit(InputEvent{Mode: mode}) | |||
| 257 | + | continue | |||
| 258 | + | } | |||
| 259 | + | s.pending = append(s.pending, sequence[1:]...) | |||
| 260 | + | return '\x1b', nil | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func (s *AppearanceSource) readOSC(raw []byte) ([]byte, error) { | |||
| 265 | + | const limit = 4096 | |||
| 266 | + | for len(raw) < limit { | |||
| 267 | + | b, err := s.read() | |||
| 268 | + | if err != nil { | |||
| 269 | + | return raw, err | |||
| 270 | + | } | |||
| 271 | + | raw = append(raw, b) | |||
| 272 | + | if b == '\a' || (len(raw) >= 2 && raw[len(raw)-2] == '\x1b' && b == '\\') { | |||
| 273 | + | return raw, nil | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | return raw, nil | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | func (s *AppearanceSource) readCSI(raw []byte) ([]byte, error) { | |||
| 280 | + | const limit = 256 | |||
| 281 | + | for len(raw) < limit { | |||
| 282 | + | b, err := s.read() | |||
| 283 | + | if err != nil { | |||
| 284 | + | return raw, err | |||
| 285 | + | } | |||
| 286 | + | raw = append(raw, b) | |||
| 287 | + | if b >= 0x40 && b <= 0x7e { | |||
| 288 | + | return raw, nil | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | return raw, nil | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func (s *AppearanceSource) trackPaste(b byte) { | |||
| 295 | + | if !s.inPaste { | |||
| 296 | + | return | |||
| 297 | + | } | |||
| 298 | + | const end = "\x1b[201~" | |||
| 299 | + | s.pasteTail = append(s.pasteTail, b) | |||
| 300 | + | if len(s.pasteTail) > len(end) { | |||
| 301 | + | s.pasteTail = s.pasteTail[1:] | |||
| 302 | + | } | |||
| 303 | + | if string(s.pasteTail) == end { | |||
| 304 | + | s.inPaste = false | |||
| 305 | + | s.pasteTail = nil | |||
| 306 | + | } | |||
| 307 | + | } | |||
A packages/tui/appearance_protocol_test.go +136 -0
| @@ -0,0 +1,136 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestParseAppearanceOSC(t *testing.T) { | |
| 10 | + | events := ParseAppearanceOSC([]byte("\x1b]4;1;rgb:ff/00/00;12;#123456\x1b\\")) | |
| 11 | + | if len(events) != 2 { | |
| 12 | + | t.Fatalf("events = %#v, want two palette entries", events) | |
| 13 | + | } | |
| 14 | + | if events[0].Slot != 1 || events[0].Color != ColorRGB(255, 0, 0) { | |
| 15 | + | t.Fatalf("first event = %#v", events[0]) | |
| 16 | + | } | |
| 17 | + | if events[1].Slot != 12 || events[1].Color != ColorRGB(0x12, 0x34, 0x56) { | |
| 18 | + | t.Fatalf("second event = %#v", events[1]) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestParseAppearanceCSI(t *testing.T) { | |
| 23 | + | scheme, mode := ParseAppearanceCSI([]byte("\x1b[?997;2n")) | |
| 24 | + | if scheme == nil || !scheme.Light || mode != nil { | |
| 25 | + | t.Fatalf("scheme parse = %#v, %#v", scheme, mode) | |
| 26 | + | } | |
| 27 | + | scheme, mode = ParseAppearanceCSI([]byte("\x1b[?2031;2$y")) | |
| 28 | + | if scheme != nil || mode == nil || mode.Status != 2 { | |
| 29 | + | t.Fatalf("mode parse = %#v, %#v", scheme, mode) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type appearanceBytes struct { | |
| 34 | + | data []byte | |
| 35 | + | minPeek time.Duration | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (s *appearanceBytes) read() (byte, error) { | |
| 39 | + | if len(s.data) == 0 { | |
| 40 | + | return 0, io.EOF | |
| 41 | + | } | |
| 42 | + | b := s.data[0] | |
| 43 | + | s.data = s.data[1:] | |
| 44 | + | return b, nil | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (s *appearanceBytes) peek(timeout time.Duration) (byte, bool, error) { | |
| 48 | + | if timeout < s.minPeek { | |
| 49 | + | return 0, false, nil | |
| 50 | + | } | |
| 51 | + | if len(s.data) == 0 { | |
| 52 | + | return 0, false, nil | |
| 53 | + | } | |
| 54 | + | b := s.data[0] | |
| 55 | + | s.data = s.data[1:] | |
| 56 | + | return b, true, nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestAppearanceSourceConsumesOnlyPendingReplies(t *testing.T) { | |
| 60 | + | input := &appearanceBytes{data: []byte("a\x1b]11;#123456\ab")} | |
| 61 | + | parser := &AppearanceParser{} | |
| 62 | + | parser.SetPendingColors(true) | |
| 63 | + | var events []InputEvent | |
| 64 | + | source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) }) | |
| 65 | + | var got []byte | |
| 66 | + | for { | |
| 67 | + | b, err := source.ReadByte() | |
| 68 | + | if err == io.EOF { | |
| 69 | + | break | |
| 70 | + | } | |
| 71 | + | if err != nil { | |
| 72 | + | t.Fatal(err) | |
| 73 | + | } | |
| 74 | + | got = append(got, b) | |
| 75 | + | } | |
| 76 | + | if string(got) != "ab" { | |
| 77 | + | t.Fatalf("remaining bytes = %q, want ab", got) | |
| 78 | + | } | |
| 79 | + | if len(events) != 1 || events[0].Color == nil || events[0].Color.Kind != 11 { | |
| 80 | + | t.Fatalf("events = %#v", events) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestAppearanceSourceAcceptsDelayedReplyWhileQueryPending(t *testing.T) { | |
| 85 | + | input := &appearanceBytes{data: []byte("\x1b]11;#123456\a"), minPeek: 2 * time.Millisecond} | |
| 86 | + | parser := &AppearanceParser{} | |
| 87 | + | parser.SetPendingColors(true) | |
| 88 | + | var events []InputEvent | |
| 89 | + | source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) }) | |
| 90 | + | if _, err := source.ReadByte(); err != io.EOF { | |
| 91 | + | t.Fatalf("ReadByte error = %v, want EOF after consuming reply", err) | |
| 92 | + | } | |
| 93 | + | if len(events) != 1 || events[0].Color == nil || events[0].Color.Kind != 11 { | |
| 94 | + | t.Fatalf("delayed reply events = %#v", events) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestAppearanceSourceMarksOnlyPendingSchemeReplySolicited(t *testing.T) { | |
| 99 | + | input := &appearanceBytes{data: []byte("\x1b[?997;1n\x1b[?997;2n")} | |
| 100 | + | parser := &AppearanceParser{} | |
| 101 | + | parser.SetPendingScheme(true) | |
| 102 | + | parser.SetNotifications(true) | |
| 103 | + | var events []InputEvent | |
| 104 | + | source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) }) | |
| 105 | + | for { | |
| 106 | + | _, err := source.ReadByte() | |
| 107 | + | if err == io.EOF { | |
| 108 | + | break | |
| 109 | + | } | |
| 110 | + | if err != nil { | |
| 111 | + | t.Fatal(err) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | if len(events) != 2 || events[0].Scheme == nil || events[1].Scheme == nil || !events[0].Scheme.Solicited || events[1].Scheme.Solicited { | |
| 115 | + | t.Fatalf("scheme events = %#v", events) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestAppearanceSourceLeavesPasteOpaque(t *testing.T) { | |
| 120 | + | input := &appearanceBytes{data: []byte("\x1b[200~x\x1b]11;#123456\ay\x1b[201~")} | |
| 121 | + | parser := &AppearanceParser{} | |
| 122 | + | parser.SetPendingColors(true) | |
| 123 | + | var events []InputEvent | |
| 124 | + | source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) }) | |
| 125 | + | reader := NewReader(source.ReadByte) | |
| 126 | + | key, err := reader.Read() | |
| 127 | + | if err != nil { | |
| 128 | + | t.Fatal(err) | |
| 129 | + | } | |
| 130 | + | if key.Kind != KeyPaste || key.Paste != "x\x1b]11;#123456\ay" { | |
| 131 | + | t.Fatalf("paste = %#v", key) | |
| 132 | + | } | |
| 133 | + | if len(events) != 0 { | |
| 134 | + | t.Fatalf("appearance events leaked from paste: %#v", events) | |
| 135 | + | } | |
| 136 | + | } | |
| @@ -0,0 +1,136 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "io" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestParseAppearanceOSC(t *testing.T) { | |||
| 10 | + | events := ParseAppearanceOSC([]byte("\x1b]4;1;rgb:ff/00/00;12;#123456\x1b\\")) | |||
| 11 | + | if len(events) != 2 { | |||
| 12 | + | t.Fatalf("events = %#v, want two palette entries", events) | |||
| 13 | + | } | |||
| 14 | + | if events[0].Slot != 1 || events[0].Color != ColorRGB(255, 0, 0) { | |||
| 15 | + | t.Fatalf("first event = %#v", events[0]) | |||
| 16 | + | } | |||
| 17 | + | if events[1].Slot != 12 || events[1].Color != ColorRGB(0x12, 0x34, 0x56) { | |||
| 18 | + | t.Fatalf("second event = %#v", events[1]) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestParseAppearanceCSI(t *testing.T) { | |||
| 23 | + | scheme, mode := ParseAppearanceCSI([]byte("\x1b[?997;2n")) | |||
| 24 | + | if scheme == nil || !scheme.Light || mode != nil { | |||
| 25 | + | t.Fatalf("scheme parse = %#v, %#v", scheme, mode) | |||
| 26 | + | } | |||
| 27 | + | scheme, mode = ParseAppearanceCSI([]byte("\x1b[?2031;2$y")) | |||
| 28 | + | if scheme != nil || mode == nil || mode.Status != 2 { | |||
| 29 | + | t.Fatalf("mode parse = %#v, %#v", scheme, mode) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | type appearanceBytes struct { | |||
| 34 | + | data []byte | |||
| 35 | + | minPeek time.Duration | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (s *appearanceBytes) read() (byte, error) { | |||
| 39 | + | if len(s.data) == 0 { | |||
| 40 | + | return 0, io.EOF | |||
| 41 | + | } | |||
| 42 | + | b := s.data[0] | |||
| 43 | + | s.data = s.data[1:] | |||
| 44 | + | return b, nil | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (s *appearanceBytes) peek(timeout time.Duration) (byte, bool, error) { | |||
| 48 | + | if timeout < s.minPeek { | |||
| 49 | + | return 0, false, nil | |||
| 50 | + | } | |||
| 51 | + | if len(s.data) == 0 { | |||
| 52 | + | return 0, false, nil | |||
| 53 | + | } | |||
| 54 | + | b := s.data[0] | |||
| 55 | + | s.data = s.data[1:] | |||
| 56 | + | return b, true, nil | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestAppearanceSourceConsumesOnlyPendingReplies(t *testing.T) { | |||
| 60 | + | input := &appearanceBytes{data: []byte("a\x1b]11;#123456\ab")} | |||
| 61 | + | parser := &AppearanceParser{} | |||
| 62 | + | parser.SetPendingColors(true) | |||
| 63 | + | var events []InputEvent | |||
| 64 | + | source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) }) | |||
| 65 | + | var got []byte | |||
| 66 | + | for { | |||
| 67 | + | b, err := source.ReadByte() | |||
| 68 | + | if err == io.EOF { | |||
| 69 | + | break | |||
| 70 | + | } | |||
| 71 | + | if err != nil { | |||
| 72 | + | t.Fatal(err) | |||
| 73 | + | } | |||
| 74 | + | got = append(got, b) | |||
| 75 | + | } | |||
| 76 | + | if string(got) != "ab" { | |||
| 77 | + | t.Fatalf("remaining bytes = %q, want ab", got) | |||
| 78 | + | } | |||
| 79 | + | if len(events) != 1 || events[0].Color == nil || events[0].Color.Kind != 11 { | |||
| 80 | + | t.Fatalf("events = %#v", events) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestAppearanceSourceAcceptsDelayedReplyWhileQueryPending(t *testing.T) { | |||
| 85 | + | input := &appearanceBytes{data: []byte("\x1b]11;#123456\a"), minPeek: 2 * time.Millisecond} | |||
| 86 | + | parser := &AppearanceParser{} | |||
| 87 | + | parser.SetPendingColors(true) | |||
| 88 | + | var events []InputEvent | |||
| 89 | + | source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) }) | |||
| 90 | + | if _, err := source.ReadByte(); err != io.EOF { | |||
| 91 | + | t.Fatalf("ReadByte error = %v, want EOF after consuming reply", err) | |||
| 92 | + | } | |||
| 93 | + | if len(events) != 1 || events[0].Color == nil || events[0].Color.Kind != 11 { | |||
| 94 | + | t.Fatalf("delayed reply events = %#v", events) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func TestAppearanceSourceMarksOnlyPendingSchemeReplySolicited(t *testing.T) { | |||
| 99 | + | input := &appearanceBytes{data: []byte("\x1b[?997;1n\x1b[?997;2n")} | |||
| 100 | + | parser := &AppearanceParser{} | |||
| 101 | + | parser.SetPendingScheme(true) | |||
| 102 | + | parser.SetNotifications(true) | |||
| 103 | + | var events []InputEvent | |||
| 104 | + | source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) }) | |||
| 105 | + | for { | |||
| 106 | + | _, err := source.ReadByte() | |||
| 107 | + | if err == io.EOF { | |||
| 108 | + | break | |||
| 109 | + | } | |||
| 110 | + | if err != nil { | |||
| 111 | + | t.Fatal(err) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | if len(events) != 2 || events[0].Scheme == nil || events[1].Scheme == nil || !events[0].Scheme.Solicited || events[1].Scheme.Solicited { | |||
| 115 | + | t.Fatalf("scheme events = %#v", events) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestAppearanceSourceLeavesPasteOpaque(t *testing.T) { | |||
| 120 | + | input := &appearanceBytes{data: []byte("\x1b[200~x\x1b]11;#123456\ay\x1b[201~")} | |||
| 121 | + | parser := &AppearanceParser{} | |||
| 122 | + | parser.SetPendingColors(true) | |||
| 123 | + | var events []InputEvent | |||
| 124 | + | source := NewAppearanceSource(input.read, input.peek, parser, func(event InputEvent) { events = append(events, event) }) | |||
| 125 | + | reader := NewReader(source.ReadByte) | |||
| 126 | + | key, err := reader.Read() | |||
| 127 | + | if err != nil { | |||
| 128 | + | t.Fatal(err) | |||
| 129 | + | } | |||
| 130 | + | if key.Kind != KeyPaste || key.Paste != "x\x1b]11;#123456\ay" { | |||
| 131 | + | t.Fatalf("paste = %#v", key) | |||
| 132 | + | } | |||
| 133 | + | if len(events) != 0 { | |||
| 134 | + | t.Fatalf("appearance events leaked from paste: %#v", events) | |||
| 135 | + | } | |||
| 136 | + | } | |||
M packages/tui/detect_bg.go +22 -33
| @@ -21,39 +21,28 @@ func DetectTrueColor(termEnv, colorTerm string) bool { | |||
| 21 | 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 | 37 | // 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. | |
| 28 | 40 | // | |
| 29 | 41 | // The query / parse runs synchronously before the TUI is initialised so the | |
| 30 | 42 | // returned snapshot can drive the entire session. We briefly put stdin into | |
| 31 | 43 | // raw mode and disable echo so OSC replies do not leak onto the user's screen. | |
| 32 | 44 | 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)) | |
| 57 | 46 | } | |
| 58 | 47 | ||
| 59 | 48 | type oscColorResponse struct { | |
| @@ -63,7 +52,7 @@ type oscColorResponse struct { | |||
| 63 | 52 | } | |
| 64 | 53 | ||
| 65 | 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 | 56 | if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) || timeout <= 0 { | |
| 68 | 57 | return profile | |
| 69 | 58 | } | |
| @@ -97,11 +86,11 @@ func detectTerminalProfile(timeout time.Duration) TerminalProfile { | |||
| 97 | 86 | } | |
| 98 | 87 | } | |
| 99 | 88 | 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 | |
| 105 | 94 | } | |
| 106 | 95 | return profile | |
| 107 | 96 | } | |
| @@ -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 current | 37 | // DetectThemeFromBackground queries the controlling tty for its current | ||
| 25 | - | // foreground, background, and ANSI palette using OSC 10/11/4. The returned | 38 | + | // 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 auto | 39 | + | // 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 the | 41 | // The query / parse runs synchronously before the TUI is initialised so the | ||
| 30 | // returned snapshot can drive the entire session. We briefly put stdin into | 42 | // 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 profile | 57 | 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.5 | 92 | + | 0.7152*float64(profile.Background.G)+ |
| 104 | - | profile.SchemeKnown = true | 93 | + | 0.0722*float64(profile.Background.B) >= 127.5 |
| 105 | } | 94 | } | ||
| 106 | return profile | 95 | return profile | ||
| 107 | } | 96 | } | ||
M packages/tui/highlight.go +175 -13
| @@ -3,6 +3,7 @@ package tui | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "path/filepath" | |
| 6 | + | "strconv" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | ||
| @@ -36,22 +37,26 @@ func (th Theme) HighlightCode(src, lang string) []string { | |||
| 36 | 37 | highlightCache.store(styleKey, lang, src, out) | |
| 37 | 38 | 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 | 51 | style := th.chromaStyle() | |
| 40 | 52 | formatterName := "terminal256" | |
| 41 | - | if th.Terminal.TrueColor { | |
| 53 | + | if th.Terminal.ColorDepth() == ColorDepthTrueColor { | |
| 42 | 54 | formatterName = "terminal16m" | |
| 43 | 55 | } | |
| 44 | 56 | formatter := formatters.Get(formatterName) | |
| 45 | 57 | if formatter == nil { | |
| 46 | 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 | 60 | var buf bytes.Buffer | |
| 56 | 61 | if err := formatter.Format(&buf, style, iterator); err != nil { | |
| 57 | 62 | out := strings.Split(src, "\n") | |
| @@ -63,6 +68,134 @@ func (th Theme) HighlightCode(src, lang string) []string { | |||
| 63 | 68 | return out | |
| 64 | 69 | } | |
| 65 | 70 | ||
| 71 | + | // formatTerminalTokens keeps Chroma's lexer but owns terminal formatting for | |
| 72 | + | // adaptive themes. Chroma's terminal formatters map duplicate ANSI RGB values | |
| 73 | + | // through the xterm cube, losing an intended palette slot such as bright blue. | |
| 74 | + | func formatTerminalTokens(th Theme, iterator chroma.Iterator) []string { | |
| 75 | + | var out strings.Builder | |
| 76 | + | for token := iterator(); token != chroma.EOF; token = iterator() { | |
| 77 | + | color, bold, italic := terminalTokenStyle(th, token.Type) | |
| 78 | + | prefix := th.fgPrefix(color) | |
| 79 | + | if bold { | |
| 80 | + | prefix += "\x1b[1m" | |
| 81 | + | } | |
| 82 | + | if italic { | |
| 83 | + | prefix += "\x1b[3m" | |
| 84 | + | } | |
| 85 | + | // Coalesced Chroma tokens can span rows. Close the style before each | |
| 86 | + | // newline and reopen it for the next segment so every rendered row is | |
| 87 | + | // self-contained and cannot bleed into padding or following text. | |
| 88 | + | for index, segment := range strings.Split(token.Value, "\n") { | |
| 89 | + | if index > 0 { | |
| 90 | + | out.WriteByte('\n') | |
| 91 | + | } | |
| 92 | + | if segment == "" { | |
| 93 | + | continue | |
| 94 | + | } | |
| 95 | + | out.WriteString(prefix) | |
| 96 | + | out.WriteString(segment) | |
| 97 | + | out.WriteString(reset) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | return strings.Split(strings.TrimRight(out.String(), "\n"), "\n") | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func terminalTokenStyle(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) { | |
| 104 | + | color, bold, italic := terminalTokenDefault(th, typ) | |
| 105 | + | if entry, ok := terminalSyntaxEntry(th, typ); ok { | |
| 106 | + | if entry.Colour.IsSet() { | |
| 107 | + | color = ColorRGB(int(entry.Colour.Red()), int(entry.Colour.Green()), int(entry.Colour.Blue())) | |
| 108 | + | } | |
| 109 | + | if entry.Bold != chroma.Pass { | |
| 110 | + | bold = entry.Bold == chroma.Yes | |
| 111 | + | } | |
| 112 | + | if entry.Italic != chroma.Pass { | |
| 113 | + | italic = entry.Italic == chroma.Yes | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | return color, bold, italic | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func terminalTokenDefault(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) { | |
| 120 | + | switch { | |
| 121 | + | case typ.InCategory(chroma.Comment): | |
| 122 | + | return th.Muted, false, true | |
| 123 | + | case typ == chroma.LiteralStringEscape || typ == chroma.Error: | |
| 124 | + | return th.Error, false, false | |
| 125 | + | case typ.InSubCategory(chroma.LiteralString): | |
| 126 | + | return th.Tool, false, false | |
| 127 | + | case typ.InSubCategory(chroma.LiteralNumber): | |
| 128 | + | return th.Warning, false, false | |
| 129 | + | case typ.InCategory(chroma.Keyword): | |
| 130 | + | return th.Accent, true, false | |
| 131 | + | case typ.InCategory(chroma.Name): | |
| 132 | + | return TerminalPaletteSlot(14), false, false | |
| 133 | + | default: | |
| 134 | + | return TerminalDefault(), false, false | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func terminalSyntaxEntry(th Theme, typ chroma.TokenType) (chroma.StyleEntry, bool) { | |
| 139 | + | if entry := terminalSyntaxOverride(th.Syntax, typ); entry != "" { | |
| 140 | + | parsed, err := chroma.ParseStyleEntry(entry) | |
| 141 | + | return parsed, err == nil | |
| 142 | + | } | |
| 143 | + | if th.SyntaxBaseStyle != "" { | |
| 144 | + | return styles.Get(th.SyntaxBaseStyle).Get(typ), true | |
| 145 | + | } | |
| 146 | + | return chroma.StyleEntry{}, false | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func terminalSyntaxOverride(s SyntaxTheme, typ chroma.TokenType) string { | |
| 150 | + | switch { | |
| 151 | + | case typ == chroma.KeywordConstant: | |
| 152 | + | return firstSyntaxOverride(s.KeywordConstant, s.Keyword) | |
| 153 | + | case typ == chroma.KeywordDeclaration: | |
| 154 | + | return firstSyntaxOverride(s.KeywordDeclaration, s.Keyword) | |
| 155 | + | case typ == chroma.KeywordNamespace: | |
| 156 | + | return firstSyntaxOverride(s.KeywordNamespace, s.Keyword) | |
| 157 | + | case typ == chroma.KeywordReserved: | |
| 158 | + | return firstSyntaxOverride(s.KeywordReserved, s.Keyword) | |
| 159 | + | case typ == chroma.KeywordType: | |
| 160 | + | return firstSyntaxOverride(s.KeywordType, s.Keyword) | |
| 161 | + | case typ.InCategory(chroma.Keyword): | |
| 162 | + | return s.Keyword | |
| 163 | + | case typ == chroma.NameBuiltin: | |
| 164 | + | return firstSyntaxOverride(s.NameBuiltin, s.NameFunction) | |
| 165 | + | case typ == chroma.NameFunction: | |
| 166 | + | return s.NameFunction | |
| 167 | + | case typ == chroma.NameClass: | |
| 168 | + | return firstSyntaxOverride(s.NameClass, s.NameFunction) | |
| 169 | + | case typ == chroma.NameDecorator: | |
| 170 | + | return firstSyntaxOverride(s.NameDecorator, s.NameFunction) | |
| 171 | + | case typ == chroma.LiteralStringEscape: | |
| 172 | + | return firstSyntaxOverride(s.LiteralStringEscape, s.LiteralString) | |
| 173 | + | case typ.InSubCategory(chroma.LiteralString): | |
| 174 | + | return s.LiteralString | |
| 175 | + | case typ.InSubCategory(chroma.LiteralNumber): | |
| 176 | + | return s.LiteralNumber | |
| 177 | + | case typ == chroma.CommentPreproc: | |
| 178 | + | return firstSyntaxOverride(s.CommentPreproc, s.Comment) | |
| 179 | + | case typ.InCategory(chroma.Comment): | |
| 180 | + | return s.Comment | |
| 181 | + | case typ.InCategory(chroma.Operator): | |
| 182 | + | return s.Operator | |
| 183 | + | case typ.InCategory(chroma.Punctuation): | |
| 184 | + | return s.Punctuation | |
| 185 | + | default: | |
| 186 | + | return s.Text | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func firstSyntaxOverride(entries ...string) string { | |
| 191 | + | for _, entry := range entries { | |
| 192 | + | if entry != "" { | |
| 193 | + | return entry | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | return "" | |
| 197 | + | } | |
| 198 | + | ||
| 66 | 199 | // highlightResultCache is a simple LRU-ish cache: when it exceeds its | |
| 67 | 200 | // capacity we drop half of the entries (the oldest, based on insertion | |
| 68 | 201 | // order). That's good enough since tool_result text doesn't change once | |
| @@ -242,12 +375,8 @@ func (th Theme) chromaStyle() *chroma.Style { | |||
| 242 | 375 | ||
| 243 | 376 | func (th Theme) syntaxKey() string { | |
| 244 | 377 | syn := th.Syntax | |
| 245 | - | outputMode := "256" | |
| 246 | - | if th.Terminal.TrueColor { | |
| 247 | - | outputMode = "truecolor" | |
| 248 | - | } | |
| 249 | 378 | return strings.Join([]string{ | |
| 250 | - | outputMode, | |
| 379 | + | th.terminalSyntaxKeyPart(), | |
| 251 | 380 | th.SyntaxBaseStyle, | |
| 252 | 381 | syn.Keyword, | |
| 253 | 382 | syn.KeywordConstant, | |
| @@ -270,6 +399,39 @@ func (th Theme) syntaxKey() string { | |||
| 270 | 399 | }, "\x1f") | |
| 271 | 400 | } | |
| 272 | 401 | ||
| 402 | + | func (th Theme) terminalSyntaxKeyPart() string { | |
| 403 | + | h := fnv64aInit | |
| 404 | + | h = fnv64aWriteByte(h, byte(th.Terminal.ColorDepth())) | |
| 405 | + | if th.UseTerminalPalette { | |
| 406 | + | h = fnv64aWriteByte(h, 1) | |
| 407 | + | } else { | |
| 408 | + | h = fnv64aWriteByte(h, 0) | |
| 409 | + | } | |
| 410 | + | h = fnv64aWriteUint64(h, uint64(th.Terminal.PaletteKnown)) | |
| 411 | + | for _, color := range th.Terminal.Palette { | |
| 412 | + | h = hashThemeColor(h, color) | |
| 413 | + | } | |
| 414 | + | for _, color := range []TerminalColor{ | |
| 415 | + | th.Terminal.Foreground, | |
| 416 | + | th.Terminal.Background, | |
| 417 | + | th.Accent, | |
| 418 | + | th.Muted, | |
| 419 | + | th.Tool, | |
| 420 | + | th.Warning, | |
| 421 | + | th.Error, | |
| 422 | + | } { | |
| 423 | + | h = hashThemeColor(h, color) | |
| 424 | + | } | |
| 425 | + | for _, value := range []bool{th.Terminal.HasForeground, th.Terminal.HasBackground, th.Terminal.TrueColor, th.Terminal.Light, th.Terminal.SchemeKnown} { | |
| 426 | + | if value { | |
| 427 | + | h = fnv64aWriteByte(h, 1) | |
| 428 | + | } else { | |
| 429 | + | h = fnv64aWriteByte(h, 0) | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | return strconv.FormatUint(h, 16) | |
| 433 | + | } | |
| 434 | + | ||
| 273 | 435 | // extLang maps file extensions to chroma lexer names. Only | |
| 274 | 436 | // extensions that chroma supports are included. | |
| 275 | 437 | var extLang = map[string]string{ | |
| @@ -3,6 +3,7 @@ package tui | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | + | "strconv" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | "sync" | 8 | "sync" | ||
| 8 | 9 | ||||
| @@ -36,22 +37,26 @@ func (th Theme) HighlightCode(src, lang string) []string { | |||||
| 36 | highlightCache.store(styleKey, lang, src, out) | 37 | highlightCache.store(styleKey, lang, src, out) | ||
| 37 | return out | 38 | 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.Buffer | 60 | var buf bytes.Buffer | ||
| 56 | if err := formatter.Format(&buf, style, iterator); err != nil { | 61 | if err := formatter.Format(&buf, style, iterator); err != nil { | ||
| 57 | out := strings.Split(src, "\n") | 62 | out := strings.Split(src, "\n") | ||
| @@ -63,6 +68,134 @@ func (th Theme) HighlightCode(src, lang string) []string { | |||||
| 63 | return out | 68 | return out | ||
| 64 | } | 69 | } | ||
| 65 | 70 | ||||
| 71 | + | // formatTerminalTokens keeps Chroma's lexer but owns terminal formatting for | |||
| 72 | + | // adaptive themes. Chroma's terminal formatters map duplicate ANSI RGB values | |||
| 73 | + | // through the xterm cube, losing an intended palette slot such as bright blue. | |||
| 74 | + | func formatTerminalTokens(th Theme, iterator chroma.Iterator) []string { | |||
| 75 | + | var out strings.Builder | |||
| 76 | + | for token := iterator(); token != chroma.EOF; token = iterator() { | |||
| 77 | + | color, bold, italic := terminalTokenStyle(th, token.Type) | |||
| 78 | + | prefix := th.fgPrefix(color) | |||
| 79 | + | if bold { | |||
| 80 | + | prefix += "\x1b[1m" | |||
| 81 | + | } | |||
| 82 | + | if italic { | |||
| 83 | + | prefix += "\x1b[3m" | |||
| 84 | + | } | |||
| 85 | + | // Coalesced Chroma tokens can span rows. Close the style before each | |||
| 86 | + | // newline and reopen it for the next segment so every rendered row is | |||
| 87 | + | // self-contained and cannot bleed into padding or following text. | |||
| 88 | + | for index, segment := range strings.Split(token.Value, "\n") { | |||
| 89 | + | if index > 0 { | |||
| 90 | + | out.WriteByte('\n') | |||
| 91 | + | } | |||
| 92 | + | if segment == "" { | |||
| 93 | + | continue | |||
| 94 | + | } | |||
| 95 | + | out.WriteString(prefix) | |||
| 96 | + | out.WriteString(segment) | |||
| 97 | + | out.WriteString(reset) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | return strings.Split(strings.TrimRight(out.String(), "\n"), "\n") | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func terminalTokenStyle(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) { | |||
| 104 | + | color, bold, italic := terminalTokenDefault(th, typ) | |||
| 105 | + | if entry, ok := terminalSyntaxEntry(th, typ); ok { | |||
| 106 | + | if entry.Colour.IsSet() { | |||
| 107 | + | color = ColorRGB(int(entry.Colour.Red()), int(entry.Colour.Green()), int(entry.Colour.Blue())) | |||
| 108 | + | } | |||
| 109 | + | if entry.Bold != chroma.Pass { | |||
| 110 | + | bold = entry.Bold == chroma.Yes | |||
| 111 | + | } | |||
| 112 | + | if entry.Italic != chroma.Pass { | |||
| 113 | + | italic = entry.Italic == chroma.Yes | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | return color, bold, italic | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func terminalTokenDefault(th Theme, typ chroma.TokenType) (TerminalColor, bool, bool) { | |||
| 120 | + | switch { | |||
| 121 | + | case typ.InCategory(chroma.Comment): | |||
| 122 | + | return th.Muted, false, true | |||
| 123 | + | case typ == chroma.LiteralStringEscape || typ == chroma.Error: | |||
| 124 | + | return th.Error, false, false | |||
| 125 | + | case typ.InSubCategory(chroma.LiteralString): | |||
| 126 | + | return th.Tool, false, false | |||
| 127 | + | case typ.InSubCategory(chroma.LiteralNumber): | |||
| 128 | + | return th.Warning, false, false | |||
| 129 | + | case typ.InCategory(chroma.Keyword): | |||
| 130 | + | return th.Accent, true, false | |||
| 131 | + | case typ.InCategory(chroma.Name): | |||
| 132 | + | return TerminalPaletteSlot(14), false, false | |||
| 133 | + | default: | |||
| 134 | + | return TerminalDefault(), false, false | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func terminalSyntaxEntry(th Theme, typ chroma.TokenType) (chroma.StyleEntry, bool) { | |||
| 139 | + | if entry := terminalSyntaxOverride(th.Syntax, typ); entry != "" { | |||
| 140 | + | parsed, err := chroma.ParseStyleEntry(entry) | |||
| 141 | + | return parsed, err == nil | |||
| 142 | + | } | |||
| 143 | + | if th.SyntaxBaseStyle != "" { | |||
| 144 | + | return styles.Get(th.SyntaxBaseStyle).Get(typ), true | |||
| 145 | + | } | |||
| 146 | + | return chroma.StyleEntry{}, false | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func terminalSyntaxOverride(s SyntaxTheme, typ chroma.TokenType) string { | |||
| 150 | + | switch { | |||
| 151 | + | case typ == chroma.KeywordConstant: | |||
| 152 | + | return firstSyntaxOverride(s.KeywordConstant, s.Keyword) | |||
| 153 | + | case typ == chroma.KeywordDeclaration: | |||
| 154 | + | return firstSyntaxOverride(s.KeywordDeclaration, s.Keyword) | |||
| 155 | + | case typ == chroma.KeywordNamespace: | |||
| 156 | + | return firstSyntaxOverride(s.KeywordNamespace, s.Keyword) | |||
| 157 | + | case typ == chroma.KeywordReserved: | |||
| 158 | + | return firstSyntaxOverride(s.KeywordReserved, s.Keyword) | |||
| 159 | + | case typ == chroma.KeywordType: | |||
| 160 | + | return firstSyntaxOverride(s.KeywordType, s.Keyword) | |||
| 161 | + | case typ.InCategory(chroma.Keyword): | |||
| 162 | + | return s.Keyword | |||
| 163 | + | case typ == chroma.NameBuiltin: | |||
| 164 | + | return firstSyntaxOverride(s.NameBuiltin, s.NameFunction) | |||
| 165 | + | case typ == chroma.NameFunction: | |||
| 166 | + | return s.NameFunction | |||
| 167 | + | case typ == chroma.NameClass: | |||
| 168 | + | return firstSyntaxOverride(s.NameClass, s.NameFunction) | |||
| 169 | + | case typ == chroma.NameDecorator: | |||
| 170 | + | return firstSyntaxOverride(s.NameDecorator, s.NameFunction) | |||
| 171 | + | case typ == chroma.LiteralStringEscape: | |||
| 172 | + | return firstSyntaxOverride(s.LiteralStringEscape, s.LiteralString) | |||
| 173 | + | case typ.InSubCategory(chroma.LiteralString): | |||
| 174 | + | return s.LiteralString | |||
| 175 | + | case typ.InSubCategory(chroma.LiteralNumber): | |||
| 176 | + | return s.LiteralNumber | |||
| 177 | + | case typ == chroma.CommentPreproc: | |||
| 178 | + | return firstSyntaxOverride(s.CommentPreproc, s.Comment) | |||
| 179 | + | case typ.InCategory(chroma.Comment): | |||
| 180 | + | return s.Comment | |||
| 181 | + | case typ.InCategory(chroma.Operator): | |||
| 182 | + | return s.Operator | |||
| 183 | + | case typ.InCategory(chroma.Punctuation): | |||
| 184 | + | return s.Punctuation | |||
| 185 | + | default: | |||
| 186 | + | return s.Text | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func firstSyntaxOverride(entries ...string) string { | |||
| 191 | + | for _, entry := range entries { | |||
| 192 | + | if entry != "" { | |||
| 193 | + | return entry | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | return "" | |||
| 197 | + | } | |||
| 198 | + | ||||
| 66 | // highlightResultCache is a simple LRU-ish cache: when it exceeds its | 199 | // highlightResultCache is a simple LRU-ish cache: when it exceeds its | ||
| 67 | // capacity we drop half of the entries (the oldest, based on insertion | 200 | // 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 once | 201 | // order). That's good enough since tool_result text doesn't change once | ||
| @@ -242,12 +375,8 @@ func (th Theme) chromaStyle() *chroma.Style { | |||||
| 242 | 375 | ||||
| 243 | func (th Theme) syntaxKey() string { | 376 | func (th Theme) syntaxKey() string { | ||
| 244 | syn := th.Syntax | 377 | syn := th.Syntax | ||
| 245 | - | outputMode := "256" | |||
| 246 | - | if th.Terminal.TrueColor { | |||
| 247 | - | outputMode = "truecolor" | |||
| 248 | - | } | |||
| 249 | return strings.Join([]string{ | 378 | return strings.Join([]string{ | ||
| 250 | - | outputMode, | 379 | + | th.terminalSyntaxKeyPart(), |
| 251 | th.SyntaxBaseStyle, | 380 | th.SyntaxBaseStyle, | ||
| 252 | syn.Keyword, | 381 | syn.Keyword, | ||
| 253 | syn.KeywordConstant, | 382 | syn.KeywordConstant, | ||
| @@ -270,6 +399,39 @@ func (th Theme) syntaxKey() string { | |||||
| 270 | }, "\x1f") | 399 | }, "\x1f") | ||
| 271 | } | 400 | } | ||
| 272 | 401 | ||||
| 402 | + | func (th Theme) terminalSyntaxKeyPart() string { | |||
| 403 | + | h := fnv64aInit | |||
| 404 | + | h = fnv64aWriteByte(h, byte(th.Terminal.ColorDepth())) | |||
| 405 | + | if th.UseTerminalPalette { | |||
| 406 | + | h = fnv64aWriteByte(h, 1) | |||
| 407 | + | } else { | |||
| 408 | + | h = fnv64aWriteByte(h, 0) | |||
| 409 | + | } | |||
| 410 | + | h = fnv64aWriteUint64(h, uint64(th.Terminal.PaletteKnown)) | |||
| 411 | + | for _, color := range th.Terminal.Palette { | |||
| 412 | + | h = hashThemeColor(h, color) | |||
| 413 | + | } | |||
| 414 | + | for _, color := range []TerminalColor{ | |||
| 415 | + | th.Terminal.Foreground, | |||
| 416 | + | th.Terminal.Background, | |||
| 417 | + | th.Accent, | |||
| 418 | + | th.Muted, | |||
| 419 | + | th.Tool, | |||
| 420 | + | th.Warning, | |||
| 421 | + | th.Error, | |||
| 422 | + | } { | |||
| 423 | + | h = hashThemeColor(h, color) | |||
| 424 | + | } | |||
| 425 | + | for _, value := range []bool{th.Terminal.HasForeground, th.Terminal.HasBackground, th.Terminal.TrueColor, th.Terminal.Light, th.Terminal.SchemeKnown} { | |||
| 426 | + | if value { | |||
| 427 | + | h = fnv64aWriteByte(h, 1) | |||
| 428 | + | } else { | |||
| 429 | + | h = fnv64aWriteByte(h, 0) | |||
| 430 | + | } | |||
| 431 | + | } | |||
| 432 | + | return strconv.FormatUint(h, 16) | |||
| 433 | + | } | |||
| 434 | + | ||||
| 273 | // extLang maps file extensions to chroma lexer names. Only | 435 | // extLang maps file extensions to chroma lexer names. Only | ||
| 274 | // extensions that chroma supports are included. | 436 | // extensions that chroma supports are included. | ||
| 275 | var extLang = map[string]string{ | 437 | var extLang = map[string]string{ | ||
A packages/tui/highlight_terminal_test.go +76 -0
| @@ -0,0 +1,76 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestTerminalSyntaxPreservesPaletteSlots(t *testing.T) { | |
| 9 | + | profile := TerminalProfile{Depth: ColorDepthIndexed256} | |
| 10 | + | th := TerminalTheme(profile) | |
| 11 | + | got := strings.Join(th.HighlightCode("func main() { return 1 }", "go"), "\n") | |
| 12 | + | for _, sequence := range []string{"\x1b[94m", "\x1b[93m", "\x1b[39m"} { | |
| 13 | + | if !strings.Contains(got, sequence) { | |
| 14 | + | t.Fatalf("highlight output missing %q: %q", sequence, got) | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | if strings.Contains(got, "\x1b[38;5;196m") { | |
| 18 | + | t.Fatalf("terminal formatter remapped ANSI slots through xterm cube: %q", got) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestTerminalSyntaxUsesReportedRGBPalette(t *testing.T) { | |
| 23 | + | profile := terminalTestProfile(ColorDepthTrueColor) | |
| 24 | + | th := TerminalTheme(profile) | |
| 25 | + | got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n") | |
| 26 | + | if !strings.Contains(got, "\x1b[38;2;40;100;240m") { | |
| 27 | + | t.Fatalf("highlight output did not use reported bright-blue palette: %q", got) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestTerminalSyntaxCacheIncludesSemanticRoles(t *testing.T) { | |
| 32 | + | profile := TerminalProfile{Depth: ColorDepthTrueColor} | |
| 33 | + | first := TerminalTheme(profile) | |
| 34 | + | first.Accent = ColorRGB(1, 2, 3) | |
| 35 | + | second := TerminalTheme(profile) | |
| 36 | + | second.Accent = ColorRGB(4, 5, 6) | |
| 37 | + | ||
| 38 | + | const source = "func terminalSyntaxCacheSentinel() {}" | |
| 39 | + | if got := strings.Join(first.HighlightCode(source, "go"), "\n"); !strings.Contains(got, "\x1b[38;2;1;2;3m") { | |
| 40 | + | t.Fatalf("first output = %q, want first accent", got) | |
| 41 | + | } | |
| 42 | + | if got := strings.Join(second.HighlightCode(source, "go"), "\n"); !strings.Contains(got, "\x1b[38;2;4;5;6m") { | |
| 43 | + | t.Fatalf("second output reused first semantic color: %q", got) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestTerminalSyntaxHonorsCustomOverrides(t *testing.T) { | |
| 48 | + | th := TerminalTheme(TerminalProfile{Depth: ColorDepthTrueColor}) | |
| 49 | + | th.Syntax.Keyword = "#112233 bold" | |
| 50 | + | got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n") | |
| 51 | + | if !strings.Contains(got, "\x1b[38;2;17;34;51m\x1b[1mfunc") { | |
| 52 | + | t.Fatalf("terminal formatter ignored custom keyword override: %q", got) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestTerminalSyntaxHonorsCustomBaseStyle(t *testing.T) { | |
| 57 | + | th := TerminalTheme(TerminalProfile{Depth: ColorDepthTrueColor}) | |
| 58 | + | th.SyntaxBaseStyle = "monokai" | |
| 59 | + | got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n") | |
| 60 | + | if !strings.Contains(got, "\x1b[38;2;102;217;239m") { | |
| 61 | + | t.Fatalf("terminal formatter ignored custom syntax base style: %q", got) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestTerminalSyntaxBalancesMultilineTokens(t *testing.T) { | |
| 66 | + | th := TerminalTheme(TerminalProfile{Depth: ColorDepthIndexed256}) | |
| 67 | + | lines := th.HighlightCode("/* first\nsecond */", "go") | |
| 68 | + | if len(lines) != 2 { | |
| 69 | + | t.Fatalf("line count = %d, want 2: %#v", len(lines), lines) | |
| 70 | + | } | |
| 71 | + | for _, line := range lines { | |
| 72 | + | if !strings.HasSuffix(line, reset) { | |
| 73 | + | t.Fatalf("line does not reset terminal style: %q", line) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| @@ -0,0 +1,76 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestTerminalSyntaxPreservesPaletteSlots(t *testing.T) { | |||
| 9 | + | profile := TerminalProfile{Depth: ColorDepthIndexed256} | |||
| 10 | + | th := TerminalTheme(profile) | |||
| 11 | + | got := strings.Join(th.HighlightCode("func main() { return 1 }", "go"), "\n") | |||
| 12 | + | for _, sequence := range []string{"\x1b[94m", "\x1b[93m", "\x1b[39m"} { | |||
| 13 | + | if !strings.Contains(got, sequence) { | |||
| 14 | + | t.Fatalf("highlight output missing %q: %q", sequence, got) | |||
| 15 | + | } | |||
| 16 | + | } | |||
| 17 | + | if strings.Contains(got, "\x1b[38;5;196m") { | |||
| 18 | + | t.Fatalf("terminal formatter remapped ANSI slots through xterm cube: %q", got) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestTerminalSyntaxUsesReportedRGBPalette(t *testing.T) { | |||
| 23 | + | profile := terminalTestProfile(ColorDepthTrueColor) | |||
| 24 | + | th := TerminalTheme(profile) | |||
| 25 | + | got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n") | |||
| 26 | + | if !strings.Contains(got, "\x1b[38;2;40;100;240m") { | |||
| 27 | + | t.Fatalf("highlight output did not use reported bright-blue palette: %q", got) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestTerminalSyntaxCacheIncludesSemanticRoles(t *testing.T) { | |||
| 32 | + | profile := TerminalProfile{Depth: ColorDepthTrueColor} | |||
| 33 | + | first := TerminalTheme(profile) | |||
| 34 | + | first.Accent = ColorRGB(1, 2, 3) | |||
| 35 | + | second := TerminalTheme(profile) | |||
| 36 | + | second.Accent = ColorRGB(4, 5, 6) | |||
| 37 | + | ||||
| 38 | + | const source = "func terminalSyntaxCacheSentinel() {}" | |||
| 39 | + | if got := strings.Join(first.HighlightCode(source, "go"), "\n"); !strings.Contains(got, "\x1b[38;2;1;2;3m") { | |||
| 40 | + | t.Fatalf("first output = %q, want first accent", got) | |||
| 41 | + | } | |||
| 42 | + | if got := strings.Join(second.HighlightCode(source, "go"), "\n"); !strings.Contains(got, "\x1b[38;2;4;5;6m") { | |||
| 43 | + | t.Fatalf("second output reused first semantic color: %q", got) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestTerminalSyntaxHonorsCustomOverrides(t *testing.T) { | |||
| 48 | + | th := TerminalTheme(TerminalProfile{Depth: ColorDepthTrueColor}) | |||
| 49 | + | th.Syntax.Keyword = "#112233 bold" | |||
| 50 | + | got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n") | |||
| 51 | + | if !strings.Contains(got, "\x1b[38;2;17;34;51m\x1b[1mfunc") { | |||
| 52 | + | t.Fatalf("terminal formatter ignored custom keyword override: %q", got) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestTerminalSyntaxHonorsCustomBaseStyle(t *testing.T) { | |||
| 57 | + | th := TerminalTheme(TerminalProfile{Depth: ColorDepthTrueColor}) | |||
| 58 | + | th.SyntaxBaseStyle = "monokai" | |||
| 59 | + | got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n") | |||
| 60 | + | if !strings.Contains(got, "\x1b[38;2;102;217;239m") { | |||
| 61 | + | t.Fatalf("terminal formatter ignored custom syntax base style: %q", got) | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestTerminalSyntaxBalancesMultilineTokens(t *testing.T) { | |||
| 66 | + | th := TerminalTheme(TerminalProfile{Depth: ColorDepthIndexed256}) | |||
| 67 | + | lines := th.HighlightCode("/* first\nsecond */", "go") | |||
| 68 | + | if len(lines) != 2 { | |||
| 69 | + | t.Fatalf("line count = %d, want 2: %#v", len(lines), lines) | |||
| 70 | + | } | |||
| 71 | + | for _, line := range lines { | |||
| 72 | + | if !strings.HasSuffix(line, reset) { | |||
| 73 | + | t.Fatalf("line does not reset terminal style: %q", line) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | } | |||
M packages/tui/terminal.go +7 -1
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | 7 | "strings" | |
| 8 | + | "sync" | |
| 8 | 9 | "time" | |
| 9 | 10 | "unicode" | |
| 10 | 11 | ||
| @@ -36,6 +37,7 @@ type ProcTerm struct { | |||
| 36 | 37 | out *os.File | |
| 37 | 38 | in *os.File | |
| 38 | 39 | resizeCBs []func() | |
| 40 | + | writeMu sync.Mutex | |
| 39 | 41 | } | |
| 40 | 42 | ||
| 41 | 43 | // NewProcTerm returns a Terminal bound to stdin/stdout. | |
| @@ -43,7 +45,11 @@ func NewProcTerm() *ProcTerm { | |||
| 43 | 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 | 54 | // WriteBell emits one standalone terminal alert character. Callers should | |
| 49 | 55 | // 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.File | 37 | out *os.File | ||
| 37 | in *os.File | 38 | 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 should | 54 | // WriteBell emits one standalone terminal alert character. Callers should | ||
| 49 | // route it through the same output writer used for the rest of the terminal | 55 | // 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 ( | |||
| 8 | 8 | "sync/atomic" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 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. | |
| 14 | 15 | type TerminalColor struct { | |
| 15 | 16 | Mode terminalColorMode | |
| 16 | 17 | Index int | |
| @@ -22,20 +23,37 @@ type TerminalColor struct { | |||
| 22 | 23 | type terminalColorMode int | |
| 23 | 24 | ||
| 24 | 25 | const ( | |
| 25 | - | terminalColor256 terminalColorMode = iota | |
| 26 | + | terminalColorDefault terminalColorMode = iota | |
| 27 | + | terminalColorPalette | |
| 28 | + | terminalColor256 | |
| 26 | 29 | terminalColorANSI | |
| 27 | 30 | 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 | 37 | func Color256(index int) TerminalColor { return TerminalColor{Mode: terminalColor256, Index: index} } | |
| 31 | 38 | func ColorANSI(sgr int) TerminalColor { return TerminalColor{Mode: terminalColorANSI, Index: sgr} } | |
| 32 | 39 | func ColorRGB(r, g, b int) TerminalColor { | |
| 33 | 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 | 54 | // 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. | |
| 39 | 57 | type TerminalProfile struct { | |
| 40 | 58 | Foreground TerminalColor | |
| 41 | 59 | Background TerminalColor | |
| @@ -45,9 +63,22 @@ type TerminalProfile struct { | |||
| 45 | 63 | ||
| 46 | 64 | Palette [16]TerminalColor | |
| 47 | 65 | 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 | |
| 51 | 82 | } | |
| 52 | 83 | ||
| 53 | 84 | // PaletteColor returns a reported ANSI palette entry when available. | |
| @@ -78,12 +109,11 @@ type Theme struct { | |||
| 78 | 109 | SelectionBG TerminalColor // background for highlighted rows | |
| 79 | 110 | SelectionFG TerminalColor // foreground for highlighted rows | |
| 80 | 111 | ||
| 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 | |
| 87 | 117 | ||
| 88 | 118 | SpinnerFrames []string | |
| 89 | 119 | SpinnerIntervalMS int | |
| @@ -231,8 +261,7 @@ func (t Theme) bgPrefix(color TerminalColor) string { | |||
| 231 | 261 | } | |
| 232 | 262 | ||
| 233 | 263 | // 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. | |
| 236 | 265 | func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor { | |
| 237 | 266 | percent = clampPercent(percent) | |
| 238 | 267 | foreground := t.resolveColor(color) | |
| @@ -250,31 +279,42 @@ func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor { | |||
| 250 | 279 | } | |
| 251 | 280 | ||
| 252 | 281 | func (t Theme) resolveColor(color TerminalColor) TerminalColor { | |
| 282 | + | depth := t.Terminal.ColorDepth() | |
| 253 | 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 | 294 | case terminalColor256: | |
| 255 | 295 | 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: | |
| 262 | 298 | r, g, b := xterm256RGB(index) | |
| 263 | 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 | 305 | case terminalColorANSI: | |
| 267 | - | if t.Inherited && t.Terminal.TrueColor { | |
| 306 | + | if depth == ColorDepthTrueColor { | |
| 268 | 307 | if index, ok := ansiSGRToXtermIndex(color.Index); ok { | |
| 269 | 308 | if palette, ok := t.Terminal.PaletteColor(index); ok { | |
| 270 | 309 | return palette | |
| 271 | 310 | } | |
| 272 | - | r, g, b := xterm256RGB(index) | |
| 273 | - | return ColorRGB(r, g, b) | |
| 274 | 311 | } | |
| 275 | 312 | } | |
| 276 | 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 | 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 | 324 | func sgrFGColor(color TerminalColor) string { | |
| 285 | 325 | switch color.Mode { | |
| 326 | + | case terminalColorDefault: | |
| 327 | + | return "\x1b[39m" | |
| 286 | 328 | case terminalColorANSI: | |
| 287 | 329 | return "\x1b[" + itoa(ansiForegroundSGR(color.Index)) + "m" | |
| 288 | 330 | case terminalColorRGB: | |
| @@ -413,6 +455,8 @@ func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" | |||
| 413 | 455 | func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" } | |
| 414 | 456 | func sgrBGColor(c TerminalColor) string { | |
| 415 | 457 | switch c.Mode { | |
| 458 | + | case terminalColorDefault: | |
| 459 | + | return "\x1b[49m" | |
| 416 | 460 | case terminalColorANSI: | |
| 417 | 461 | return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m" | |
| 418 | 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 | 501 | func clampXtermIndex(index int) int { | |
| 426 | 502 | if index < 0 { | |
| 427 | 503 | return 0 | |
| @@ -459,6 +535,9 @@ func rgbForTerminalColor(color TerminalColor) ([3]int, bool) { | |||
| 459 | 535 | case terminalColor256: | |
| 460 | 536 | r, g, b := xterm256RGB(clampXtermIndex(color.Index)) | |
| 461 | 537 | 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 | 541 | case terminalColorANSI: | |
| 463 | 542 | if index, ok := ansiSGRToXtermIndex(color.Index); ok { | |
| 464 | 543 | 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 spaces | 11 | + | // 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 terminalColorMode | 16 | Mode terminalColorMode | ||
| 16 | Index int | 17 | Index int | ||
| @@ -22,20 +23,37 @@ type TerminalColor struct { | |||||
| 22 | type terminalColorMode int | 23 | type terminalColorMode int | ||
| 23 | 24 | ||||
| 24 | const ( | 25 | const ( | ||
| 25 | - | terminalColor256 terminalColorMode = iota | 26 | + | terminalColorDefault terminalColorMode = iota |
| 27 | + | terminalColorPalette | |||
| 28 | + | terminalColor256 | |||
| 26 | terminalColorANSI | 29 | terminalColorANSI | ||
| 27 | terminalColorRGB | 30 | 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 the | 54 | // TerminalProfile is the best-effort color snapshot reported by the | ||
| 37 | - | // controlling terminal. The ANSI palette is optional; unknown slots are | 55 | + | // 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 TerminalColor | 58 | Foreground TerminalColor | ||
| 41 | Background TerminalColor | 59 | Background TerminalColor | ||
| @@ -45,9 +63,22 @@ type TerminalProfile struct { | |||||
| 45 | 63 | ||||
| 46 | Palette [16]TerminalColor | 64 | Palette [16]TerminalColor | ||
| 47 | PaletteKnown uint16 | 65 | PaletteKnown uint16 | ||
| 48 | - | TrueColor bool | 66 | + | Depth ColorDepth |
| 49 | - | Light bool | 67 | + | // TrueColor is retained for callers compiled against the older profile. |
| 50 | - | SchemeKnown bool | 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 | |||
| 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 rows | 109 | SelectionBG TerminalColor // background for highlighted rows | ||
| 79 | SelectionFG TerminalColor // foreground for highlighted rows | 110 | SelectionFG TerminalColor // foreground for highlighted rows | ||
| 80 | 111 | ||||
| 81 | - | // Inherited selects terminal-owned colors. Terminal.TrueColor controls | 112 | + | // UseTerminalPalette resolves TerminalPaletteSlot roles against the |
| 82 | - | // whether fixed indexed values are emitted as xterm-256 or truecolor and | 113 | + | // reported palette on truecolor terminals. It has no effect on explicit |
| 83 | - | // whether RGB values are quantized for terminals without direct-color | 114 | + | // Color256 values from a custom theme. |
| 84 | - | // support. | 115 | + | UseTerminalPalette bool |
| 85 | - | Inherited bool | 116 | + | Terminal TerminalProfile |
| 86 | - | Terminal TerminalProfile | |||
| 87 | 117 | ||||
| 88 | SpinnerFrames []string | 118 | SpinnerFrames []string | ||
| 89 | SpinnerIntervalMS int | 119 | 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 used | 263 | // 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 | 264 | + | // 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 palette | 309 | 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 0 | 503 | 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}, true | 537 | return [3]int{r, g, b}, true | ||
| 538 | + | case terminalColorPalette: | |||
| 539 | + | r, g, b := xterm256RGB(clampANSIIndex(color.Index)) | |||
| 540 | + | return [3]int{r, g, b}, true | |||
| 462 | case terminalColorANSI: | 541 | case terminalColorANSI: | ||
| 463 | if index, ok := ansiSGRToXtermIndex(color.Index); ok { | 542 | if index, ok := ansiSGRToXtermIndex(color.Index); ok { | ||
| 464 | r, g, b := xterm256RGB(index) | 543 | r, g, b := xterm256RGB(index) | ||
M packages/tui/theme_inherited_test.go +48 -155
| @@ -16,9 +16,7 @@ func TestDetectTrueColor(t *testing.T) { | |||
| 16 | 16 | {name: "COLORTERM truecolor", term: "screen", colorTerm: "truecolor", want: true}, | |
| 17 | 17 | {name: "COLORTERM 24bit", term: "screen", colorTerm: "24bit", want: true}, | |
| 18 | 18 | {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}, | |
| 22 | 20 | } | |
| 23 | 21 | for _, tt := range tests { | |
| 24 | 22 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -29,187 +27,82 @@ func TestDetectTrueColor(t *testing.T) { | |||
| 29 | 27 | } | |
| 30 | 28 | } | |
| 31 | 29 | ||
| 32 | - | func TestParseOSCColorResponse(t *testing.T) { | |
| 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 | |
| 39 | 34 | }{ | |
| 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) | |
| 97 | 41 | } | |
| 98 | 42 | } | |
| 99 | 43 | } | |
| 100 | 44 | ||
| 101 | - | func inheritedTestTheme(trueColor bool) Theme { | |
| 102 | - | detected := Dark | |
| 103 | - | detected.Terminal = TerminalProfile{ | |
| 45 | + | func terminalTestProfile(depth ColorDepth) TerminalProfile { | |
| 46 | + | profile := TerminalProfile{ | |
| 104 | 47 | Foreground: ColorRGB(220, 210, 200), | |
| 105 | 48 | Background: ColorRGB(12, 18, 24), | |
| 106 | 49 | HasForeground: true, | |
| 107 | 50 | HasBackground: true, | |
| 108 | - | TrueColor: trueColor, | |
| 51 | + | Depth: depth, | |
| 109 | 52 | } | |
| 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 | |
| 115 | 58 | } | |
| 116 | 59 | ||
| 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") | |
| 127 | 64 | } | |
| 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) | |
| 130 | 67 | } | |
| 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) | |
| 133 | 70 | } | |
| 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) | |
| 155 | 73 | } | |
| 156 | 74 | } | |
| 157 | 75 | ||
| 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") | |
| 169 | 80 | } | |
| 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) | |
| 172 | 83 | } | |
| 173 | 84 | } | |
| 174 | 85 | ||
| 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) | |
| 184 | 91 | } | |
| 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) | |
| 199 | 94 | } | |
| 200 | 95 | } | |
| 201 | 96 | ||
| 202 | - | func TestRendererDetectsInheritedSelectionBackground(t *testing.T) { | |
| 97 | + | func TestTerminalThemeRendererInvalidationDoesNotClearScrollback(t *testing.T) { | |
| 98 | + | t.Setenv("TERM_PROGRAM", "") | |
| 203 | 99 | var buf bytes.Buffer | |
| 204 | 100 | r := NewRenderer(&buf) | |
| 205 | 101 | r.Resize(20, 2) | |
| 206 | - | r.SetTheme(inheritedTestTheme(true)) | |
| 207 | - | selected := r.theme.SelectionStyle() + "selected" + reset | |
| 208 | - | r.Draw([]string{"", selected}, -1, -1) | |
| 209 | 102 | buf.Reset() | |
| 210 | - | ||
| 211 | - | r.Draw([]string{"", "plain"}, -1, -1) | |
| 212 | - | if !strings.Contains(buf.String(), MoveTo(1, 1)) { | |
| 213 | - | t.Fatalf("selection repaint did not include unchanged rows: %q", buf.String()) | |
| 103 | + | r.SetTheme(TerminalTheme(terminalTestProfile(ColorDepthTrueColor))) | |
| 104 | + | r.Invalidate() | |
| 105 | + | if strings.Contains(buf.String(), SeqClearScrollback) { | |
| 106 | + | t.Fatalf("theme invalidation cleared scrollback: %q", buf.String()) | |
| 214 | 107 | } | |
| 215 | 108 | } | |
| @@ -16,9 +16,7 @@ func TestDetectTrueColor(t *testing.T) { | |||||
| 16 | {name: "COLORTERM truecolor", term: "screen", colorTerm: "truecolor", want: true}, | 16 | {name: "COLORTERM truecolor", term: "screen", colorTerm: "truecolor", want: true}, | ||
| 17 | {name: "COLORTERM 24bit", term: "screen", colorTerm: "24bit", want: true}, | 17 | {name: "COLORTERM 24bit", term: "screen", colorTerm: "24bit", want: true}, | ||
| 18 | {name: "direct TERM", term: "xterm-direct", want: true}, | 18 | {name: "direct TERM", term: "xterm-direct", want: true}, | ||
| 19 | - | {name: "direct TERM suffix", term: "screen-direct", want: true}, | 19 | + | {name: "256 color TERM", term: "xterm-256color", want: false}, |
| 20 | - | {name: "256 color TERM", term: "xterm-256color", colorTerm: "", want: false}, | |||
| 21 | - | {name: "unknown TERM", term: "dumb", colorTerm: "", want: false}, | |||
| 22 | } | 20 | } | ||
| 23 | for _, tt := range tests { | 21 | for _, tt := range tests { | ||
| 24 | t.Run(tt.name, func(t *testing.T) { | 22 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -29,187 +27,82 @@ func TestDetectTrueColor(t *testing.T) { | |||||
| 29 | } | 27 | } | ||
| 30 | } | 28 | } | ||
| 31 | 29 | ||||
| 32 | - | func TestParseOSCColorResponse(t *testing.T) { | 30 | + | func TestDetectColorDepth(t *testing.T) { |
| 33 | - | tests := []struct { | 31 | + | for _, tt := range []struct { |
| 34 | - | name string | 32 | + | term, colorTerm string |
| 35 | - | raw string | 33 | + | 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 := Dark | 46 | + | 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<<13 | 56 | + | profile.PaletteKnown = 1<<10 | 1<<12 | 1<<13 |
| 114 | - | return InheritedTheme(detected) | 57 | + | return profile |
| 115 | } | 58 | } | ||
| 116 | 59 | ||||
| 117 | - | func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) { | 60 | + | func TestTerminalThemeUsesTerminalDefaultsAndPaletteSlots(t *testing.T) { |
| 118 | - | th := inheritedTestTheme(true) | 61 | + | th := TerminalTheme(terminalTestProfile(ColorDepthTrueColor)) |
| 119 | - | if !th.Inherited || !th.Terminal.TrueColor { | 62 | + | if th.Background != nil { |
| 120 | - | t.Fatalf("inherited theme mode = (inherited=%v, trueColor=%v), want truecolor", th.Inherited, th.Terminal.TrueColor) | 63 | + | t.Fatal("terminal theme painted a full-row background") |
| 121 | - | } | |||
| 122 | - | if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") { | |||
| 123 | - | t.Fatalf("accent did not use the reported terminal palette: %q", got) | |||
| 124 | - | } | |||
| 125 | - | if got := th.FGColor(th.FG, "x"); !strings.Contains(got, "\x1b[38;2;220;210;200m") { | |||
| 126 | - | t.Fatalf("foreground did not inherit the terminal default: %q", got) | |||
| 127 | } | 64 | } | ||
| 128 | - | if got := th.UserBubble("x", 3); !strings.Contains(got, "\x1b[48;2;") { | 65 | + | if th.FG != TerminalDefault() || th.Accent != TerminalPaletteSlot(12) { |
| 129 | - | t.Fatalf("user bubble did not use truecolor background: %q", got) | 66 | + | t.Fatalf("terminal roles = fg %#v accent %#v", th.FG, th.Accent) |
| 130 | } | 67 | } | ||
| 131 | - | if got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n"); !strings.Contains(got, "\x1b[38;2;") { | 68 | + | if got := th.FGColor(th.FG, "x"); got != "\x1b[39mx\x1b[0m" { |
| 132 | - | t.Fatalf("syntax highlighting did not use truecolor: %q", got) | 69 | + | t.Fatalf("default foreground = %q", got) |
| 133 | } | 70 | } | ||
| 134 | - | if got := th.DimColor(th.FG, 45); got.Mode != terminalColorRGB { | 71 | + | if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") { |
| 135 | - | t.Fatalf("DimColor mode = %v, want RGB", got.Mode) | 72 | + | t.Fatalf("accent did not use reported palette: %q", got) |
| 136 | - | } | |||
| 137 | - | } | |||
| 138 | - | ||||
| 139 | - | func TestInheritedThemeFallsBackTo256Colors(t *testing.T) { | |||
| 140 | - | th := inheritedTestTheme(false) | |||
| 141 | - | if !th.Inherited || th.Terminal.TrueColor { | |||
| 142 | - | t.Fatalf("inherited theme mode = (inherited=%v, trueColor=%v), want 256", th.Inherited, th.Terminal.TrueColor) | |||
| 143 | - | } | |||
| 144 | - | if got := th.FGColor(th.Accent, "x"); !strings.Contains(got, "\x1b[38;5;12m") || strings.Contains(got, "\x1b[38;2;") { | |||
| 145 | - | t.Fatalf("accent did not use 256-color fallback: %q", got) | |||
| 146 | - | } | |||
| 147 | - | if got := th.UserBubble("x", 3); strings.Contains(got, "\x1b[48;2;") || !strings.Contains(got, "\x1b[48;5;") { | |||
| 148 | - | t.Fatalf("user bubble did not quantize its background: %q", got) | |||
| 149 | - | } | |||
| 150 | - | if got := th.DimColor(th.FG, 45); got.Mode != terminalColor256 { | |||
| 151 | - | t.Fatalf("DimColor mode = %v, want 256", got.Mode) | |||
| 152 | - | } | |||
| 153 | - | if got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n"); strings.Contains(got, "\x1b[38;2;") { | |||
| 154 | - | t.Fatalf("syntax highlighting unexpectedly used truecolor: %q", got) | |||
| 155 | } | 73 | } | ||
| 156 | } | 74 | } | ||
| 157 | 75 | ||||
| 158 | - | func TestExplicitThemesFollowTerminalCapability(t *testing.T) { | 76 | + | func TestTerminalThemeUnknownProfileUsesANSI(t *testing.T) { |
| 159 | - | if got, want := Dark.FGColor(Dark.Accent, "x"), "\x1b[38;5;111mx\x1b[0m"; got != want { | 77 | + | th := TerminalTheme(TerminalProfile{Depth: ColorDepthANSI16}) |
| 160 | - | t.Fatalf("explicit theme output = %q, want %q", got, want) | 78 | + | if th.FG == Dark.FG || th.FG == Light.FG { |
| 161 | - | } | 79 | + | t.Fatal("unknown auto profile selected a fixed built-in theme") |
| 162 | - | if got := Dark.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;5;") || strings.Contains(got, "\x1b[48;2;") { | |||
| 163 | - | t.Fatalf("non-truecolor RGB bubble did not quantize: %q", got) | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | - | trueColor := withTerminalProfile(Dark, Theme{Terminal: TerminalProfile{TrueColor: true}}) | |||
| 167 | - | if got := trueColor.FGColor(trueColor.Accent, "x"); !strings.Contains(got, "\x1b[38;2;") { | |||
| 168 | - | t.Fatalf("truecolor explicit theme did not emit RGB: %q", got) | |||
| 169 | } | 80 | } | ||
| 170 | - | if got := trueColor.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;2;66;69;75m") { | 81 | + | if got := th.FGColor(th.Accent, "x"); got != "\x1b[94mx\x1b[0m" { |
| 171 | - | t.Fatalf("truecolor explicit RGB bubble did not emit RGB: %q", got) | 82 | + | t.Fatalf("terminal palette fallback = %q", got) |
| 172 | } | 83 | } | ||
| 173 | } | 84 | } | ||
| 174 | 85 | ||||
| 175 | - | func TestAvailableThemesIncludesInherited(t *testing.T) { | 86 | + | func TestTerminalPaletteSlotsDoNotAliasExplicitIndexedColors(t *testing.T) { |
| 176 | - | options := AvailableThemes(t.TempDir()) | 87 | + | profile := terminalTestProfile(ColorDepthTrueColor) |
| 177 | - | for _, option := range options { | 88 | + | th := TerminalTheme(profile) |
| 178 | - | if option.Value == "inherited" { | 89 | + | if got := th.FGColor(TerminalPaletteSlot(12), "x"); !strings.Contains(got, "40;100;240") { |
| 179 | - | if option.Label != "inherited (from terminal)" || !option.Builtin { | 90 | + | t.Fatalf("terminal slot = %q", got) |
| 180 | - | t.Fatalf("unexpected inherited option: %#v", option) | |||
| 181 | - | } | |||
| 182 | - | return | |||
| 183 | - | } | |||
| 184 | } | 91 | } | ||
| 185 | - | t.Fatal("available themes did not include inherited") | 92 | + | if got := th.FGColor(Color256(12), "x"); !strings.Contains(got, "0;0;255") { |
| 186 | - | } | 93 | + | t.Fatalf("explicit xterm index adopted terminal palette: %q", got) |
| 187 | - | ||||
| 188 | - | func TestIsLightThemePreservesExplicitDarkTheme(t *testing.T) { | |||
| 189 | - | dark := Dark | |||
| 190 | - | dark.Terminal.SchemeKnown = true | |||
| 191 | - | dark.Terminal.Light = true | |||
| 192 | - | if IsLightTheme(dark) { | |||
| 193 | - | t.Fatal("explicit dark theme was reclassified from the terminal scheme") | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | inherited := InheritedTheme(dark) | |||
| 197 | - | if !IsLightTheme(inherited) { | |||
| 198 | - | t.Fatal("inherited light terminal theme was not recognized as light") | |||
| 199 | } | 94 | } | ||
| 200 | } | 95 | } | ||
| 201 | 96 | ||||
| 202 | - | func TestRendererDetectsInheritedSelectionBackground(t *testing.T) { | 97 | + | func TestTerminalThemeRendererInvalidationDoesNotClearScrollback(t *testing.T) { |
| 98 | + | t.Setenv("TERM_PROGRAM", "") | |||
| 203 | var buf bytes.Buffer | 99 | var buf bytes.Buffer | ||
| 204 | r := NewRenderer(&buf) | 100 | r := NewRenderer(&buf) | ||
| 205 | r.Resize(20, 2) | 101 | r.Resize(20, 2) | ||
| 206 | - | r.SetTheme(inheritedTestTheme(true)) | |||
| 207 | - | selected := r.theme.SelectionStyle() + "selected" + reset | |||
| 208 | - | r.Draw([]string{"", selected}, -1, -1) | |||
| 209 | buf.Reset() | 102 | buf.Reset() | ||
| 210 | - | 103 | + | r.SetTheme(TerminalTheme(terminalTestProfile(ColorDepthTrueColor))) | |
| 211 | - | r.Draw([]string{"", "plain"}, -1, -1) | 104 | + | r.Invalidate() |
| 212 | - | if !strings.Contains(buf.String(), MoveTo(1, 1)) { | 105 | + | if strings.Contains(buf.String(), SeqClearScrollback) { |
| 213 | - | t.Fatalf("selection repaint did not include unchanged rows: %q", buf.String()) | 106 | + | t.Fatalf("theme invalidation cleared scrollback: %q", buf.String()) |
| 214 | } | 107 | } | ||
| 215 | } | 108 | } | ||
M packages/tui/theme_loader.go +240 -134
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package tui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "crypto/sha256" | |
| 4 | 5 | "encoding/json" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "fmt" | |
| @@ -9,6 +10,9 @@ import ( | |||
| 9 | 10 | "sort" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "time" | |
| 13 | + | ||
| 14 | + | "github.com/alecthomas/chroma/v2" | |
| 15 | + | "github.com/alecthomas/chroma/v2/styles" | |
| 12 | 16 | ) | |
| 13 | 17 | ||
| 14 | 18 | // ThemeOption is one selectable theme discovered under $ZUT_HOME/themes. | |
| @@ -41,9 +45,11 @@ func (tf *ThemeFile) UnmarshalJSON(data []byte) error { | |||
| 41 | 45 | // {"spinner_frames":[".","o"],"spinner_interval_ms":120}. | |
| 42 | 46 | // Metadata fields are ignored by ThemeOverrides because they do not | |
| 43 | 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 | 51 | *tf = ThemeFile(a) | |
| 46 | - | return nil | |
| 52 | + | return validateThemeFile(*tf) | |
| 47 | 53 | } | |
| 48 | 54 | ||
| 49 | 55 | type ThemeFileColorModes struct { | |
| @@ -61,16 +67,22 @@ func (m *ThemeFileColorModes) UnmarshalJSON(data []byte) error { | |||
| 61 | 67 | } | |
| 62 | 68 | if v, ok := raw["dark"]; ok { | |
| 63 | 69 | 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 | 74 | if v, ok := raw["light"]; ok { | |
| 67 | 75 | 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 | 80 | // Also allow colors to directly contain overrides shared by both | |
| 71 | 81 | // modes, e.g. {"colors":{"accent":204}}. | |
| 72 | 82 | 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 | 86 | m.Base = base | |
| 75 | 87 | return nil | |
| 76 | 88 | } | |
| @@ -138,13 +150,13 @@ func (c *TerminalColorValue) UnmarshalJSON(data []byte) error { | |||
| 138 | 150 | var index int | |
| 139 | 151 | if err := json.Unmarshal(data, &index); err == nil { | |
| 140 | 152 | c.TerminalColor = Color256(index) | |
| 141 | - | return nil | |
| 153 | + | return validateExplicitTerminalColor(c.TerminalColor) | |
| 142 | 154 | } | |
| 143 | 155 | var s string | |
| 144 | 156 | if err := json.Unmarshal(data, &s); err == nil { | |
| 145 | 157 | if rgb, ok := parseHexColor(s); ok { | |
| 146 | 158 | c.TerminalColor = rgb | |
| 147 | - | return nil | |
| 159 | + | return validateExplicitTerminalColor(c.TerminalColor) | |
| 148 | 160 | } | |
| 149 | 161 | return fmt.Errorf("invalid terminal color %q", s) | |
| 150 | 162 | } | |
| @@ -168,58 +180,45 @@ func (c *TerminalColorValue) UnmarshalJSON(data []byte) error { | |||
| 168 | 180 | default: | |
| 169 | 181 | return fmt.Errorf("unknown terminal color mode %q", obj.Mode) | |
| 170 | 182 | } | |
| 171 | - | return nil | |
| 183 | + | return validateExplicitTerminalColor(c.TerminalColor) | |
| 172 | 184 | } | |
| 173 | 185 | ||
| 174 | - | // InheritedTheme returns a theme derived from the terminal snapshot carried | |
| 175 | - | // by detected. It prefers the terminal's reported ANSI palette for semantic | |
| 176 | - | // accents, derives muted and selected surfaces by fading toward the terminal | |
| 177 | - | // background, and uses truecolor output only when the terminal advertises it. | |
| 178 | - | func InheritedTheme(detected Theme) Theme { | |
| 179 | - | base := Dark | |
| 180 | - | if detected.Terminal.SchemeKnown { | |
| 181 | - | if detected.Terminal.Light { | |
| 182 | - | base = Light | |
| 183 | - | } | |
| 184 | - | } else if IsLightTheme(detected) { | |
| 185 | - | base = Light | |
| 186 | - | } | |
| 187 | - | t := withTerminalProfile(base, detected) | |
| 188 | - | t.Inherited = true | |
| 189 | - | ||
| 190 | - | if t.Terminal.HasForeground { | |
| 191 | - | t.FG = t.Terminal.Foreground | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | accentFallback := t.Accent | |
| 195 | - | if IsLightTheme(t) { | |
| 196 | - | accentFallback = Light.Accent | |
| 197 | - | } | |
| 198 | - | t.Accent = terminalPaletteColor(t.Terminal, accentFallback, | |
| 199 | - | ansiBrightBlue, ansiBlue, ansiBrightCyan, ansiCyan, ansiBrightMagenta, ansiMagenta) | |
| 200 | - | t.Assistant = t.Accent | |
| 201 | - | t.Tool = terminalPaletteColor(t.Terminal, t.Tool, ansiBrightGreen, ansiGreen) | |
| 202 | - | t.Error = terminalPaletteColor(t.Terminal, t.Error, ansiBrightRed, ansiRed) | |
| 203 | - | t.Warning = terminalPaletteColor(t.Terminal, t.Warning, ansiBrightYellow, ansiYellow) | |
| 204 | - | t.Spinner = terminalPaletteColor(t.Terminal, t.Spinner, ansiBrightMagenta, ansiMagenta) | |
| 205 | - | t.ThinkingMax = terminalPaletteColor(t.Terminal, t.ThinkingMax, ansiBrightMagenta, ansiMagenta) | |
| 206 | - | t.User = terminalPaletteColor(t.Terminal, t.User, ansiBrightYellow, ansiYellow) | |
| 207 | - | ||
| 208 | - | if t.Terminal.HasForeground && t.Terminal.HasBackground { | |
| 209 | - | t.Muted = t.DimColor(t.FG, 45) | |
| 186 | + | // TerminalTheme constructs zut's adaptive default from terminal-owned | |
| 187 | + | // defaults and ANSI slots. It never chooses a fixed built-in palette merely | |
| 188 | + | // because a terminal reports a light background. | |
| 189 | + | func TerminalTheme(profile TerminalProfile) Theme { | |
| 190 | + | t := Theme{ | |
| 191 | + | FG: TerminalDefault(), | |
| 192 | + | Muted: TerminalPaletteSlot(8), | |
| 193 | + | Accent: TerminalPaletteSlot(12), | |
| 194 | + | User: TerminalPaletteSlot(13), | |
| 195 | + | UserBubbleBG: TerminalPaletteSlot(8), | |
| 196 | + | UserBubbleFG: TerminalDefault(), | |
| 197 | + | Assistant: TerminalPaletteSlot(12), | |
| 198 | + | Tool: TerminalPaletteSlot(10), | |
| 199 | + | ToolOut: TerminalPaletteSlot(8), | |
| 200 | + | Error: TerminalPaletteSlot(9), | |
| 201 | + | Warning: TerminalPaletteSlot(11), | |
| 202 | + | Spinner: TerminalPaletteSlot(13), | |
| 203 | + | ThinkingMax: TerminalPaletteSlot(13), | |
| 204 | + | SelectionBG: TerminalPaletteSlot(4), | |
| 205 | + | SelectionFG: TerminalDefault(), | |
| 206 | + | UseTerminalPalette: true, | |
| 207 | + | Terminal: profile, | |
| 208 | + | SpinnerFrames: append([]string(nil), defaultSpinnerFrames...), | |
| 209 | + | SpinnerIntervalMS: 80, | |
| 210 | + | // Terminal syntax is resolved directly from semantic terminal roles. | |
| 211 | + | // Keep JSON syntax fields empty until a custom theme explicitly sets one. | |
| 212 | + | Syntax: SyntaxTheme{}, | |
| 213 | + | } | |
| 214 | + | if profile.HasForeground && profile.HasBackground { | |
| 215 | + | t.Muted = t.DimColor(profile.Foreground, 45) | |
| 210 | 216 | t.ToolOut = t.Muted | |
| 211 | - | background := t.Terminal.Background | |
| 212 | - | foreground := t.Terminal.Foreground | |
| 213 | - | t.UserBubbleBG = blendTerminalColors(background, foreground, 12) | |
| 214 | - | t.SelectionBG = blendTerminalColors(background, foreground, 25) | |
| 217 | + | t.UserBubbleBG = blendTerminalColors(profile.Background, profile.Foreground, 12) | |
| 218 | + | t.SelectionBG = blendTerminalColors(profile.Background, profile.Foreground, 25) | |
| 215 | 219 | } | |
| 216 | - | ||
| 217 | - | t.UserBubbleFG = t.FG | |
| 218 | - | t.SelectionFG = t.FG | |
| 219 | - | ||
| 220 | - | // Keep the terminal's configured background untouched. Painting an | |
| 221 | - | // inherited RGB background across every row would make terminal theme | |
| 222 | - | // changes and scrollback behavior less predictable. | |
| 220 | + | // Auto must leave both the terminal background and cursor color owned by | |
| 221 | + | // the terminal. Custom themes can intentionally override Background. | |
| 223 | 222 | t.Background = nil | |
| 224 | 223 | return t | |
| 225 | 224 | } | |
| @@ -229,35 +228,6 @@ func withTerminalProfile(base, detected Theme) Theme { | |||
| 229 | 228 | return base | |
| 230 | 229 | } | |
| 231 | 230 | ||
| 232 | - | // Standard ANSI palette slots used as inherited role preferences. | |
| 233 | - | const ( | |
| 234 | - | ansiRed = 1 | |
| 235 | - | ansiGreen = 2 | |
| 236 | - | ansiYellow = 3 | |
| 237 | - | ansiBlue = 4 | |
| 238 | - | ansiMagenta = 5 | |
| 239 | - | ansiCyan = 6 | |
| 240 | - | ansiBrightRed = 9 | |
| 241 | - | ansiBrightGreen = 10 | |
| 242 | - | ansiBrightYellow = 11 | |
| 243 | - | ansiBrightBlue = 12 | |
| 244 | - | ansiBrightMagenta = 13 | |
| 245 | - | ansiBrightCyan = 14 | |
| 246 | - | ) | |
| 247 | - | ||
| 248 | - | func terminalPaletteColor(profile TerminalProfile, fallback TerminalColor, preferred ...int) TerminalColor { | |
| 249 | - | for _, index := range preferred { | |
| 250 | - | if _, ok := profile.PaletteColor(index); ok { | |
| 251 | - | // Keep the palette slot as the semantic value. Truecolor | |
| 252 | - | // resolution expands it back to the terminal-reported RGB; | |
| 253 | - | // 256-color output preserves the slot rather than re-quantizing | |
| 254 | - | // the reported RGB to a nearby cube entry. | |
| 255 | - | return Color256(index) | |
| 256 | - | } | |
| 257 | - | } | |
| 258 | - | return fallback | |
| 259 | - | } | |
| 260 | - | ||
| 261 | 231 | func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor { | |
| 262 | 232 | fromRGB, _ := rgbForTerminalColor(from) | |
| 263 | 233 | toRGB, _ := rgbForTerminalColor(to) | |
| @@ -269,67 +239,150 @@ func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor { | |||
| 269 | 239 | ) | |
| 270 | 240 | } | |
| 271 | 241 | ||
| 272 | - | // LoadThemeFromHome applies a custom theme from $ZUT_HOME/themes/*.json | |
| 273 | - | // to detected. Empty/auto/default keeps the built-in detected theme. | |
| 274 | - | // If preferred is set, it may be a theme name, a basename without | |
| 275 | - | // .json, or an absolute/relative path. | |
| 276 | - | func DetectThemeWithCustom(zutHome, preferred string, timeout time.Duration) (Theme, string, error) { | |
| 277 | - | detected := DetectThemeFromBackground(timeout) | |
| 278 | - | return LoadThemeFromHome(zutHome, preferred, detected) | |
| 242 | + | const maxThemeFileSize = 1 << 20 | |
| 243 | + | ||
| 244 | + | // ThemeSource is an immutable, fully validated custom theme revision. Runtime | |
| 245 | + | // profile changes resolve this value without touching the filesystem again. | |
| 246 | + | type ThemeSource struct { | |
| 247 | + | Name string | |
| 248 | + | Path string | |
| 249 | + | Digest [sha256.Size]byte | |
| 250 | + | File ThemeFile | |
| 279 | 251 | } | |
| 280 | 252 | ||
| 281 | - | func LoadThemeFromHome(zutHome, preferred string, detected Theme) (Theme, string, error) { | |
| 282 | - | path, err := resolveThemePath(zutHome, preferred) | |
| 283 | - | if err != nil || path == "" { | |
| 284 | - | return detected, "", err | |
| 253 | + | // ThemeResolution is the pure result of resolving a preference against one | |
| 254 | + | // terminal snapshot and, for custom preferences, one accepted source file. | |
| 255 | + | type ThemeResolution struct { | |
| 256 | + | Theme Theme | |
| 257 | + | Name string | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // ThemePreference separates the persisted setting from the process-only | |
| 261 | + | // ZUT_THEME override. Only dark/light force a running session. | |
| 262 | + | type ThemePreference struct { | |
| 263 | + | Persisted string | |
| 264 | + | Effective string | |
| 265 | + | Forced bool | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func ResolveThemePreference(persisted, env string) ThemePreference { | |
| 269 | + | preference := ThemePreference{Persisted: strings.TrimSpace(persisted), Effective: strings.TrimSpace(persisted)} | |
| 270 | + | switch strings.ToLower(strings.TrimSpace(env)) { | |
| 271 | + | case "dark", "light": | |
| 272 | + | preference.Effective = strings.ToLower(strings.TrimSpace(env)) | |
| 273 | + | preference.Forced = true | |
| 274 | + | case "", "auto": | |
| 275 | + | // Persisted setting remains effective. | |
| 276 | + | } | |
| 277 | + | if preference.Effective == "" { | |
| 278 | + | preference.Effective = "auto" | |
| 279 | + | } | |
| 280 | + | return preference | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // LoadThemeSource resolves and validates a custom source. Built-in selections | |
| 284 | + | // have no source and return nil, nil. The file limit prevents a polling reload | |
| 285 | + | // from allocating an unbounded partial write. | |
| 286 | + | func LoadThemeSource(zutHome, preference string) (*ThemeSource, error) { | |
| 287 | + | path, err := resolveThemePath(zutHome, preference) | |
| 288 | + | if err != nil || path == "" || strings.HasPrefix(path, "builtin:") { | |
| 289 | + | return nil, err | |
| 285 | 290 | } | |
| 286 | - | switch path { | |
| 287 | - | case "builtin:inherited": | |
| 288 | - | return InheritedTheme(detected), "inherited", nil | |
| 289 | - | case "builtin:dark": | |
| 290 | - | return withTerminalProfile(Dark, detected), "dark", nil | |
| 291 | - | case "builtin:light": | |
| 292 | - | return withTerminalProfile(Light, detected), "light", nil | |
| 291 | + | info, err := os.Stat(path) | |
| 292 | + | if err != nil { | |
| 293 | + | return nil, fmt.Errorf("theme %s: %w", path, err) | |
| 294 | + | } | |
| 295 | + | if info.Size() > maxThemeFileSize { | |
| 296 | + | return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize) | |
| 293 | 297 | } | |
| 294 | 298 | b, err := os.ReadFile(path) | |
| 295 | 299 | if err != nil { | |
| 296 | - | return detected, "", err | |
| 300 | + | return nil, fmt.Errorf("read theme %s: %w", path, err) | |
| 297 | 301 | } | |
| 298 | - | var tf ThemeFile | |
| 299 | - | if err := json.Unmarshal(b, &tf); err != nil { | |
| 300 | - | return detected, "", fmt.Errorf("parse theme %s: %w", path, err) | |
| 301 | - | } | |
| 302 | - | if tf.Name == "" { | |
| 303 | - | tf.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) | |
| 304 | - | } | |
| 305 | - | base := applyThemeOverrides(detected, tf.Overrides) | |
| 306 | - | base = applyThemeOverrides(base, tf.Colors.Base) | |
| 307 | - | if isLightTheme(detected) { | |
| 308 | - | if tf.Colors.HasLight { | |
| 309 | - | base = applyThemeOverrides(base, tf.Colors.Light) | |
| 310 | - | } else if tf.Colors.HasDark { | |
| 311 | - | base = applyThemeOverrides(base, tf.Colors.Dark) | |
| 312 | - | } | |
| 313 | - | } else { | |
| 314 | - | if tf.Colors.HasDark { | |
| 315 | - | base = applyThemeOverrides(base, tf.Colors.Dark) | |
| 316 | - | } else if tf.Colors.HasLight { | |
| 317 | - | base = applyThemeOverrides(base, tf.Colors.Light) | |
| 302 | + | if len(b) > maxThemeFileSize { | |
| 303 | + | return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize) | |
| 304 | + | } | |
| 305 | + | var file ThemeFile | |
| 306 | + | if err := json.Unmarshal(b, &file); err != nil { | |
| 307 | + | return nil, fmt.Errorf("parse theme %s: %w", path, err) | |
| 308 | + | } | |
| 309 | + | if file.Name == "" { | |
| 310 | + | file.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) | |
| 311 | + | } | |
| 312 | + | return &ThemeSource{Name: file.Name, Path: path, Digest: sha256.Sum256(b), File: file}, nil | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // ResolveTheme performs no I/O. Custom themes always overlay TerminalTheme, | |
| 316 | + | // so omitted roles continue to follow the controlling terminal. | |
| 317 | + | func ResolveTheme(preference string, source *ThemeSource, profile TerminalProfile) ThemeResolution { | |
| 318 | + | preference = strings.ToLower(strings.TrimSpace(preference)) | |
| 319 | + | switch preference { | |
| 320 | + | case "", "auto", "default", "system": | |
| 321 | + | return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"} | |
| 322 | + | case "dark": | |
| 323 | + | return ThemeResolution{Theme: withTerminalProfile(Dark, Theme{Terminal: profile}), Name: "dark"} | |
| 324 | + | case "light": | |
| 325 | + | return ThemeResolution{Theme: withTerminalProfile(Light, Theme{Terminal: profile}), Name: "light"} | |
| 326 | + | } | |
| 327 | + | if source == nil { | |
| 328 | + | return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"} | |
| 329 | + | } | |
| 330 | + | base := TerminalTheme(profile) | |
| 331 | + | base = applyThemeOverrides(base, source.File.Overrides) | |
| 332 | + | base = applyThemeOverrides(base, source.File.Colors.Base) | |
| 333 | + | if profileIsLight(profile) { | |
| 334 | + | if source.File.Colors.HasLight { | |
| 335 | + | base = applyThemeOverrides(base, source.File.Colors.Light) | |
| 336 | + | } else if source.File.Colors.HasDark { | |
| 337 | + | base = applyThemeOverrides(base, source.File.Colors.Dark) | |
| 318 | 338 | } | |
| 339 | + | } else if source.File.Colors.HasDark { | |
| 340 | + | base = applyThemeOverrides(base, source.File.Colors.Dark) | |
| 341 | + | } else if source.File.Colors.HasLight { | |
| 342 | + | base = applyThemeOverrides(base, source.File.Colors.Light) | |
| 343 | + | } | |
| 344 | + | return ThemeResolution{Theme: base, Name: source.Name} | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | func profileIsLight(profile TerminalProfile) bool { | |
| 348 | + | if profile.SchemeKnown { | |
| 349 | + | return profile.Light | |
| 350 | + | } | |
| 351 | + | if profile.HasBackground { | |
| 352 | + | r, g, b := profile.Background.R, profile.Background.G, profile.Background.B | |
| 353 | + | return 0.2126*float64(r)+0.7152*float64(g)+0.0722*float64(b) >= 127.5 | |
| 354 | + | } | |
| 355 | + | return false | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | // DetectThemeWithCustom performs the initial bounded profile query then uses | |
| 359 | + | // the same loader and pure resolver as runtime selection. | |
| 360 | + | func DetectThemeWithCustom(zutHome, preferred string, timeout time.Duration) (Theme, string, error) { | |
| 361 | + | detected := DetectThemeFromBackground(timeout) | |
| 362 | + | return LoadThemeFromHome(zutHome, preferred, detected) | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | // LoadThemeFromHome is retained as the startup convenience wrapper. New | |
| 366 | + | // runtime code should keep the ThemeSource and call ResolveTheme directly. | |
| 367 | + | func LoadThemeFromHome(zutHome, preferred string, detected Theme) (Theme, string, error) { | |
| 368 | + | profile := detected.Terminal | |
| 369 | + | source, err := LoadThemeSource(zutHome, preferred) | |
| 370 | + | if err != nil { | |
| 371 | + | return TerminalTheme(profile), "", err | |
| 319 | 372 | } | |
| 320 | - | return base, tf.Name, nil | |
| 373 | + | returned := ResolveTheme(preferred, source, profile) | |
| 374 | + | return returned.Theme, returned.Name, nil | |
| 321 | 375 | } | |
| 322 | 376 | ||
| 323 | 377 | // AvailableThemes returns built-in and user-installed themes suitable | |
| 324 | 378 | // for a settings picker. Invalid JSON files are skipped. | |
| 325 | 379 | func AvailableThemes(zutHome string) []ThemeOption { | |
| 326 | 380 | out := []ThemeOption{ | |
| 327 | - | {Value: "auto", Label: "auto", Description: "detect terminal background and use zut defaults", Builtin: true}, | |
| 328 | - | {Value: "inherited", Label: "inherited (from terminal)", Description: "use terminal colors with truecolor or best-effort 256-color output", Builtin: true}, | |
| 381 | + | {Value: "auto", Label: "auto", Description: "follow terminal colors and appearance", Builtin: true}, | |
| 329 | 382 | {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true}, | |
| 330 | 383 | {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true}, | |
| 331 | 384 | } | |
| 332 | - | seen := map[string]bool{"auto": true, "inherited": true, "dark": true, "light": true} | |
| 385 | + | seen := map[string]bool{"auto": true, "dark": true, "light": true} | |
| 333 | 386 | paths, _ := themeFilesIn(filepath.Join(zutHome, "themes")) | |
| 334 | 387 | sort.Strings(paths) | |
| 335 | 388 | for _, path := range paths { | |
| @@ -399,8 +452,6 @@ func resolveThemePath(zutHome, preferred string) (string, error) { | |||
| 399 | 452 | switch strings.ToLower(preferred) { | |
| 400 | 453 | case "", "auto", "default", "system": | |
| 401 | 454 | return "", nil | |
| 402 | - | case "inherited", "terminal", "terminal-inherited": | |
| 403 | - | return "builtin:inherited", nil | |
| 404 | 455 | case "dark": | |
| 405 | 456 | return "builtin:dark", nil | |
| 406 | 457 | case "light": | |
| @@ -569,14 +620,69 @@ func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme { | |||
| 569 | 620 | return s | |
| 570 | 621 | } | |
| 571 | 622 | ||
| 572 | - | func IsLightTheme(th Theme) bool { | |
| 573 | - | if th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG { | |
| 574 | - | return true | |
| 623 | + | func validateExplicitTerminalColor(color TerminalColor) error { | |
| 624 | + | switch color.Mode { | |
| 625 | + | case terminalColor256: | |
| 626 | + | if color.Index < 0 || color.Index > 255 { | |
| 627 | + | return fmt.Errorf("xterm-256 index %d is outside 0..255", color.Index) | |
| 628 | + | } | |
| 629 | + | case terminalColorANSI: | |
| 630 | + | if _, ok := ansiSGRToXtermIndex(color.Index); !ok { | |
| 631 | + | return fmt.Errorf("ANSI SGR %d is not a palette color", color.Index) | |
| 632 | + | } | |
| 633 | + | case terminalColorRGB: | |
| 634 | + | if color.R < 0 || color.R > 255 || color.G < 0 || color.G > 255 || color.B < 0 || color.B > 255 { | |
| 635 | + | return fmt.Errorf("RGB color (%d,%d,%d) is outside 0..255", color.R, color.G, color.B) | |
| 636 | + | } | |
| 637 | + | default: | |
| 638 | + | return errors.New("theme colors must be explicit xterm-256, ANSI, or RGB values") | |
| 639 | + | } | |
| 640 | + | return nil | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | func validateThemeFile(file ThemeFile) error { | |
| 644 | + | for _, overrides := range []ThemeOverrides{file.Overrides, file.Colors.Base, file.Colors.Dark, file.Colors.Light} { | |
| 645 | + | if err := validateThemeOverrides(overrides); err != nil { | |
| 646 | + | return err | |
| 647 | + | } | |
| 575 | 648 | } | |
| 576 | - | return th.Inherited && th.Terminal.SchemeKnown && th.Terminal.Light | |
| 649 | + | return nil | |
| 577 | 650 | } | |
| 578 | 651 | ||
| 579 | - | func isLightTheme(th Theme) bool { return IsLightTheme(th) } | |
| 652 | + | func validateThemeOverrides(o ThemeOverrides) error { | |
| 653 | + | for _, color := range []*TerminalColorValue{o.FG, o.Muted, o.Accent, o.Background, o.User, o.UserBubbleBG, o.UserBubbleFG, o.Assistant, o.Tool, o.ToolOut, o.Error, o.Warning, o.Spinner, o.ThinkingMax, o.ThinkingMaxCamel, o.SelectionBG, o.SelectionFG} { | |
| 654 | + | if color != nil { | |
| 655 | + | if err := validateExplicitTerminalColor(color.TerminalColor); err != nil { | |
| 656 | + | return err | |
| 657 | + | } | |
| 658 | + | } | |
| 659 | + | } | |
| 660 | + | if len(o.SpinnerFrames) > 64 { | |
| 661 | + | return errors.New("spinner_frames has more than 64 entries") | |
| 662 | + | } | |
| 663 | + | for _, frame := range o.SpinnerFrames { | |
| 664 | + | if frame == "" || len([]rune(frame)) > 16 { | |
| 665 | + | return fmt.Errorf("invalid spinner frame %q", frame) | |
| 666 | + | } | |
| 667 | + | } | |
| 668 | + | if o.SpinnerIntervalMS != nil && (*o.SpinnerIntervalMS < 10 || *o.SpinnerIntervalMS > 10000) { | |
| 669 | + | return fmt.Errorf("spinner_interval_ms %d is outside 10..10000", *o.SpinnerIntervalMS) | |
| 670 | + | } | |
| 671 | + | if o.SyntaxBaseStyle != nil && *o.SyntaxBaseStyle != "" { | |
| 672 | + | if _, ok := styles.Registry[*o.SyntaxBaseStyle]; !ok { | |
| 673 | + | return fmt.Errorf("unknown syntax_base_style %q", *o.SyntaxBaseStyle) | |
| 674 | + | } | |
| 675 | + | } | |
| 676 | + | for _, entry := range []*string{o.Syntax.Keyword, o.Syntax.KeywordConstant, o.Syntax.KeywordDeclaration, o.Syntax.KeywordNamespace, o.Syntax.KeywordReserved, o.Syntax.KeywordType, o.Syntax.NameBuiltin, o.Syntax.NameFunction, o.Syntax.NameClass, o.Syntax.NameDecorator, o.Syntax.LiteralString, o.Syntax.LiteralStringEscape, o.Syntax.LiteralNumber, o.Syntax.Comment, o.Syntax.CommentPreproc, o.Syntax.Operator, o.Syntax.Punctuation, o.Syntax.Text} { | |
| 677 | + | if entry == nil { | |
| 678 | + | continue | |
| 679 | + | } | |
| 680 | + | if _, err := chroma.ParseStyleEntry(*entry); err != nil { | |
| 681 | + | return fmt.Errorf("invalid syntax style %q: %w", *entry, err) | |
| 682 | + | } | |
| 683 | + | } | |
| 684 | + | return nil | |
| 685 | + | } | |
| 580 | 686 | ||
| 581 | 687 | func parseHexColor(s string) (TerminalColor, bool) { | |
| 582 | 688 | s = strings.TrimPrefix(strings.TrimSpace(s), "#") | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package tui | 1 | 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 not | 46 | // 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 nil | 52 | + | 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 = true | 69 | 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 = true | 75 | 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 both | 80 | // 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 ThemeOverrides | 82 | 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 = base | 86 | m.Base = base | ||
| 75 | return nil | 87 | return nil | ||
| 76 | } | 88 | } | ||
| @@ -138,13 +150,13 @@ func (c *TerminalColorValue) UnmarshalJSON(data []byte) error { | |||||
| 138 | var index int | 150 | 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 nil | 153 | + | return validateExplicitTerminalColor(c.TerminalColor) |
| 142 | } | 154 | } | ||
| 143 | var s string | 155 | 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 = rgb | 158 | c.TerminalColor = rgb | ||
| 147 | - | return nil | 159 | + | return validateExplicitTerminalColor(c.TerminalColor) |
| 148 | } | 160 | } | ||
| 149 | return fmt.Errorf("invalid terminal color %q", s) | 161 | return fmt.Errorf("invalid terminal color %q", s) | ||
| 150 | } | 162 | } | ||
| @@ -168,58 +180,45 @@ func (c *TerminalColorValue) UnmarshalJSON(data []byte) error { | |||||
| 168 | default: | 180 | default: | ||
| 169 | return fmt.Errorf("unknown terminal color mode %q", obj.Mode) | 181 | return fmt.Errorf("unknown terminal color mode %q", obj.Mode) | ||
| 170 | } | 182 | } | ||
| 171 | - | return nil | 183 | + | return validateExplicitTerminalColor(c.TerminalColor) |
| 172 | } | 184 | } | ||
| 173 | 185 | ||||
| 174 | - | // InheritedTheme returns a theme derived from the terminal snapshot carried | 186 | + | // TerminalTheme constructs zut's adaptive default from terminal-owned |
| 175 | - | // by detected. It prefers the terminal's reported ANSI palette for semantic | 187 | + | // defaults and ANSI slots. It never chooses a fixed built-in palette merely |
| 176 | - | // accents, derives muted and selected surfaces by fading toward the terminal | 188 | + | // 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 := Dark | 191 | + | FG: TerminalDefault(), |
| 180 | - | if detected.Terminal.SchemeKnown { | 192 | + | Muted: TerminalPaletteSlot(8), |
| 181 | - | if detected.Terminal.Light { | 193 | + | Accent: TerminalPaletteSlot(12), |
| 182 | - | base = Light | 194 | + | User: TerminalPaletteSlot(13), |
| 183 | - | } | 195 | + | UserBubbleBG: TerminalPaletteSlot(8), |
| 184 | - | } else if IsLightTheme(detected) { | 196 | + | UserBubbleFG: TerminalDefault(), |
| 185 | - | base = Light | 197 | + | Assistant: TerminalPaletteSlot(12), |
| 186 | - | } | 198 | + | Tool: TerminalPaletteSlot(10), |
| 187 | - | t := withTerminalProfile(base, detected) | 199 | + | ToolOut: TerminalPaletteSlot(8), |
| 188 | - | t.Inherited = true | 200 | + | Error: TerminalPaletteSlot(9), |
| 189 | - | 201 | + | Warning: TerminalPaletteSlot(11), | |
| 190 | - | if t.Terminal.HasForeground { | 202 | + | Spinner: TerminalPaletteSlot(13), |
| 191 | - | t.FG = t.Terminal.Foreground | 203 | + | ThinkingMax: TerminalPaletteSlot(13), |
| 192 | - | } | 204 | + | SelectionBG: TerminalPaletteSlot(4), |
| 193 | - | 205 | + | SelectionFG: TerminalDefault(), | |
| 194 | - | accentFallback := t.Accent | 206 | + | UseTerminalPalette: true, |
| 195 | - | if IsLightTheme(t) { | 207 | + | Terminal: profile, |
| 196 | - | accentFallback = Light.Accent | 208 | + | SpinnerFrames: append([]string(nil), defaultSpinnerFrames...), |
| 197 | - | } | 209 | + | SpinnerIntervalMS: 80, |
| 198 | - | t.Accent = terminalPaletteColor(t.Terminal, accentFallback, | 210 | + | // Terminal syntax is resolved directly from semantic terminal roles. |
| 199 | - | ansiBrightBlue, ansiBlue, ansiBrightCyan, ansiCyan, ansiBrightMagenta, ansiMagenta) | 211 | + | // Keep JSON syntax fields empty until a custom theme explicitly sets one. |
| 200 | - | t.Assistant = t.Accent | 212 | + | Syntax: SyntaxTheme{}, |
| 201 | - | t.Tool = terminalPaletteColor(t.Terminal, t.Tool, ansiBrightGreen, ansiGreen) | 213 | + | } |
| 202 | - | t.Error = terminalPaletteColor(t.Terminal, t.Error, ansiBrightRed, ansiRed) | 214 | + | if profile.HasForeground && profile.HasBackground { |
| 203 | - | t.Warning = terminalPaletteColor(t.Terminal, t.Warning, ansiBrightYellow, ansiYellow) | 215 | + | t.Muted = t.DimColor(profile.Foreground, 45) |
| 204 | - | t.Spinner = terminalPaletteColor(t.Terminal, t.Spinner, ansiBrightMagenta, ansiMagenta) | |||
| 205 | - | t.ThinkingMax = terminalPaletteColor(t.Terminal, t.ThinkingMax, ansiBrightMagenta, ansiMagenta) | |||
| 206 | - | t.User = terminalPaletteColor(t.Terminal, t.User, ansiBrightYellow, ansiYellow) | |||
| 207 | - | ||||
| 208 | - | if t.Terminal.HasForeground && t.Terminal.HasBackground { | |||
| 209 | - | t.Muted = t.DimColor(t.FG, 45) | |||
| 210 | t.ToolOut = t.Muted | 216 | t.ToolOut = t.Muted | ||
| 211 | - | background := t.Terminal.Background | 217 | + | t.UserBubbleBG = blendTerminalColors(profile.Background, profile.Foreground, 12) |
| 212 | - | foreground := t.Terminal.Foreground | 218 | + | t.SelectionBG = blendTerminalColors(profile.Background, profile.Foreground, 25) |
| 213 | - | t.UserBubbleBG = blendTerminalColors(background, foreground, 12) | |||
| 214 | - | t.SelectionBG = blendTerminalColors(background, foreground, 25) | |||
| 215 | } | 219 | } | ||
| 216 | - | 220 | + | // Auto must leave both the terminal background and cursor color owned by | |
| 217 | - | t.UserBubbleFG = t.FG | 221 | + | // 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 = nil | 222 | t.Background = nil | ||
| 224 | return t | 223 | return t | ||
| 225 | } | 224 | } | ||
| @@ -229,35 +228,6 @@ func withTerminalProfile(base, detected Theme) Theme { | |||||
| 229 | return base | 228 | return base | ||
| 230 | } | 229 | } | ||
| 231 | 230 | ||||
| 232 | - | // Standard ANSI palette slots used as inherited role preferences. | |||
| 233 | - | const ( | |||
| 234 | - | ansiRed = 1 | |||
| 235 | - | ansiGreen = 2 | |||
| 236 | - | ansiYellow = 3 | |||
| 237 | - | ansiBlue = 4 | |||
| 238 | - | ansiMagenta = 5 | |||
| 239 | - | ansiCyan = 6 | |||
| 240 | - | ansiBrightRed = 9 | |||
| 241 | - | ansiBrightGreen = 10 | |||
| 242 | - | ansiBrightYellow = 11 | |||
| 243 | - | ansiBrightBlue = 12 | |||
| 244 | - | ansiBrightMagenta = 13 | |||
| 245 | - | ansiBrightCyan = 14 | |||
| 246 | - | ) | |||
| 247 | - | ||||
| 248 | - | func terminalPaletteColor(profile TerminalProfile, fallback TerminalColor, preferred ...int) TerminalColor { | |||
| 249 | - | for _, index := range preferred { | |||
| 250 | - | if _, ok := profile.PaletteColor(index); ok { | |||
| 251 | - | // Keep the palette slot as the semantic value. Truecolor | |||
| 252 | - | // resolution expands it back to the terminal-reported RGB; | |||
| 253 | - | // 256-color output preserves the slot rather than re-quantizing | |||
| 254 | - | // the reported RGB to a nearby cube entry. | |||
| 255 | - | return Color256(index) | |||
| 256 | - | } | |||
| 257 | - | } | |||
| 258 | - | return fallback | |||
| 259 | - | } | |||
| 260 | - | ||||
| 261 | func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor { | 231 | func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor { | ||
| 262 | fromRGB, _ := rgbForTerminalColor(from) | 232 | fromRGB, _ := rgbForTerminalColor(from) | ||
| 263 | toRGB, _ := rgbForTerminalColor(to) | 233 | toRGB, _ := rgbForTerminalColor(to) | ||
| @@ -269,67 +239,150 @@ func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor { | |||||
| 269 | ) | 239 | ) | ||
| 270 | } | 240 | } | ||
| 271 | 241 | ||||
| 272 | - | // LoadThemeFromHome applies a custom theme from $ZUT_HOME/themes/*.json | 242 | + | const maxThemeFileSize = 1 << 20 |
| 273 | - | // to detected. Empty/auto/default keeps the built-in detected theme. | 243 | + | |
| 274 | - | // If preferred is set, it may be a theme name, a basename without | 244 | + | // ThemeSource is an immutable, fully validated custom theme revision. Runtime |
| 275 | - | // .json, or an absolute/relative path. | 245 | + | // profile changes resolve this value without touching the filesystem again. |
| 276 | - | func DetectThemeWithCustom(zutHome, preferred string, timeout time.Duration) (Theme, string, error) { | 246 | + | type ThemeSource struct { |
| 277 | - | detected := DetectThemeFromBackground(timeout) | 247 | + | Name string |
| 278 | - | return LoadThemeFromHome(zutHome, preferred, detected) | 248 | + | Path string |
| 249 | + | Digest [sha256.Size]byte | |||
| 250 | + | File ThemeFile | |||
| 279 | } | 251 | } | ||
| 280 | 252 | ||||
| 281 | - | func LoadThemeFromHome(zutHome, preferred string, detected Theme) (Theme, string, error) { | 253 | + | // ThemeResolution is the pure result of resolving a preference against one |
| 282 | - | path, err := resolveThemePath(zutHome, preferred) | 254 | + | // terminal snapshot and, for custom preferences, one accepted source file. |
| 283 | - | if err != nil || path == "" { | 255 | + | type ThemeResolution struct { |
| 284 | - | return detected, "", err | 256 | + | Theme Theme |
| 257 | + | Name string | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // ThemePreference separates the persisted setting from the process-only | |||
| 261 | + | // ZUT_THEME override. Only dark/light force a running session. | |||
| 262 | + | type ThemePreference struct { | |||
| 263 | + | Persisted string | |||
| 264 | + | Effective string | |||
| 265 | + | Forced bool | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | func ResolveThemePreference(persisted, env string) ThemePreference { | |||
| 269 | + | preference := ThemePreference{Persisted: strings.TrimSpace(persisted), Effective: strings.TrimSpace(persisted)} | |||
| 270 | + | switch strings.ToLower(strings.TrimSpace(env)) { | |||
| 271 | + | case "dark", "light": | |||
| 272 | + | preference.Effective = strings.ToLower(strings.TrimSpace(env)) | |||
| 273 | + | preference.Forced = true | |||
| 274 | + | case "", "auto": | |||
| 275 | + | // Persisted setting remains effective. | |||
| 276 | + | } | |||
| 277 | + | if preference.Effective == "" { | |||
| 278 | + | preference.Effective = "auto" | |||
| 279 | + | } | |||
| 280 | + | return preference | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | // LoadThemeSource resolves and validates a custom source. Built-in selections | |||
| 284 | + | // have no source and return nil, nil. The file limit prevents a polling reload | |||
| 285 | + | // from allocating an unbounded partial write. | |||
| 286 | + | func LoadThemeSource(zutHome, preference string) (*ThemeSource, error) { | |||
| 287 | + | path, err := resolveThemePath(zutHome, preference) | |||
| 288 | + | if err != nil || path == "" || strings.HasPrefix(path, "builtin:") { | |||
| 289 | + | return nil, err | |||
| 285 | } | 290 | } | ||
| 286 | - | switch path { | 291 | + | info, err := os.Stat(path) |
| 287 | - | case "builtin:inherited": | 292 | + | if err != nil { |
| 288 | - | return InheritedTheme(detected), "inherited", nil | 293 | + | return nil, fmt.Errorf("theme %s: %w", path, err) |
| 289 | - | case "builtin:dark": | 294 | + | } |
| 290 | - | return withTerminalProfile(Dark, detected), "dark", nil | 295 | + | if info.Size() > maxThemeFileSize { |
| 291 | - | case "builtin:light": | 296 | + | return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize) |
| 292 | - | return withTerminalProfile(Light, detected), "light", nil | |||
| 293 | } | 297 | } | ||
| 294 | b, err := os.ReadFile(path) | 298 | b, err := os.ReadFile(path) | ||
| 295 | if err != nil { | 299 | if err != nil { | ||
| 296 | - | return detected, "", err | 300 | + | return nil, fmt.Errorf("read theme %s: %w", path, err) |
| 297 | } | 301 | } | ||
| 298 | - | var tf ThemeFile | 302 | + | if len(b) > maxThemeFileSize { |
| 299 | - | if err := json.Unmarshal(b, &tf); err != nil { | 303 | + | return nil, fmt.Errorf("theme %s exceeds %d bytes", path, maxThemeFileSize) |
| 300 | - | return detected, "", fmt.Errorf("parse theme %s: %w", path, err) | 304 | + | } |
| 301 | - | } | 305 | + | var file ThemeFile |
| 302 | - | if tf.Name == "" { | 306 | + | if err := json.Unmarshal(b, &file); err != nil { |
| 303 | - | tf.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) | 307 | + | return nil, fmt.Errorf("parse theme %s: %w", path, err) |
| 304 | - | } | 308 | + | } |
| 305 | - | base := applyThemeOverrides(detected, tf.Overrides) | 309 | + | if file.Name == "" { |
| 306 | - | base = applyThemeOverrides(base, tf.Colors.Base) | 310 | + | file.Name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) |
| 307 | - | if isLightTheme(detected) { | 311 | + | } |
| 308 | - | if tf.Colors.HasLight { | 312 | + | return &ThemeSource{Name: file.Name, Path: path, Digest: sha256.Sum256(b), File: file}, nil |
| 309 | - | base = applyThemeOverrides(base, tf.Colors.Light) | 313 | + | } |
| 310 | - | } else if tf.Colors.HasDark { | 314 | + | |
| 311 | - | base = applyThemeOverrides(base, tf.Colors.Dark) | 315 | + | // ResolveTheme performs no I/O. Custom themes always overlay TerminalTheme, |
| 312 | - | } | 316 | + | // so omitted roles continue to follow the controlling terminal. |
| 313 | - | } else { | 317 | + | func ResolveTheme(preference string, source *ThemeSource, profile TerminalProfile) ThemeResolution { |
| 314 | - | if tf.Colors.HasDark { | 318 | + | preference = strings.ToLower(strings.TrimSpace(preference)) |
| 315 | - | base = applyThemeOverrides(base, tf.Colors.Dark) | 319 | + | switch preference { |
| 316 | - | } else if tf.Colors.HasLight { | 320 | + | case "", "auto", "default", "system": |
| 317 | - | base = applyThemeOverrides(base, tf.Colors.Light) | 321 | + | return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"} |
| 322 | + | case "dark": | |||
| 323 | + | return ThemeResolution{Theme: withTerminalProfile(Dark, Theme{Terminal: profile}), Name: "dark"} | |||
| 324 | + | case "light": | |||
| 325 | + | return ThemeResolution{Theme: withTerminalProfile(Light, Theme{Terminal: profile}), Name: "light"} | |||
| 326 | + | } | |||
| 327 | + | if source == nil { | |||
| 328 | + | return ThemeResolution{Theme: TerminalTheme(profile), Name: "auto"} | |||
| 329 | + | } | |||
| 330 | + | base := TerminalTheme(profile) | |||
| 331 | + | base = applyThemeOverrides(base, source.File.Overrides) | |||
| 332 | + | base = applyThemeOverrides(base, source.File.Colors.Base) | |||
| 333 | + | if profileIsLight(profile) { | |||
| 334 | + | if source.File.Colors.HasLight { | |||
| 335 | + | base = applyThemeOverrides(base, source.File.Colors.Light) | |||
| 336 | + | } else if source.File.Colors.HasDark { | |||
| 337 | + | base = applyThemeOverrides(base, source.File.Colors.Dark) | |||
| 318 | } | 338 | } | ||
| 339 | + | } else if source.File.Colors.HasDark { | |||
| 340 | + | base = applyThemeOverrides(base, source.File.Colors.Dark) | |||
| 341 | + | } else if source.File.Colors.HasLight { | |||
| 342 | + | base = applyThemeOverrides(base, source.File.Colors.Light) | |||
| 343 | + | } | |||
| 344 | + | return ThemeResolution{Theme: base, Name: source.Name} | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | func profileIsLight(profile TerminalProfile) bool { | |||
| 348 | + | if profile.SchemeKnown { | |||
| 349 | + | return profile.Light | |||
| 350 | + | } | |||
| 351 | + | if profile.HasBackground { | |||
| 352 | + | r, g, b := profile.Background.R, profile.Background.G, profile.Background.B | |||
| 353 | + | return 0.2126*float64(r)+0.7152*float64(g)+0.0722*float64(b) >= 127.5 | |||
| 354 | + | } | |||
| 355 | + | return false | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | // DetectThemeWithCustom performs the initial bounded profile query then uses | |||
| 359 | + | // the same loader and pure resolver as runtime selection. | |||
| 360 | + | func DetectThemeWithCustom(zutHome, preferred string, timeout time.Duration) (Theme, string, error) { | |||
| 361 | + | detected := DetectThemeFromBackground(timeout) | |||
| 362 | + | return LoadThemeFromHome(zutHome, preferred, detected) | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | // LoadThemeFromHome is retained as the startup convenience wrapper. New | |||
| 366 | + | // runtime code should keep the ThemeSource and call ResolveTheme directly. | |||
| 367 | + | func LoadThemeFromHome(zutHome, preferred string, detected Theme) (Theme, string, error) { | |||
| 368 | + | profile := detected.Terminal | |||
| 369 | + | source, err := LoadThemeSource(zutHome, preferred) | |||
| 370 | + | if err != nil { | |||
| 371 | + | return TerminalTheme(profile), "", err | |||
| 319 | } | 372 | } | ||
| 320 | - | return base, tf.Name, nil | 373 | + | returned := ResolveTheme(preferred, source, profile) |
| 374 | + | return returned.Theme, returned.Name, nil | |||
| 321 | } | 375 | } | ||
| 322 | 376 | ||||
| 323 | // AvailableThemes returns built-in and user-installed themes suitable | 377 | // AvailableThemes returns built-in and user-installed themes suitable | ||
| 324 | // for a settings picker. Invalid JSON files are skipped. | 378 | // for a settings picker. Invalid JSON files are skipped. | ||
| 325 | func AvailableThemes(zutHome string) []ThemeOption { | 379 | func AvailableThemes(zutHome string) []ThemeOption { | ||
| 326 | out := []ThemeOption{ | 380 | out := []ThemeOption{ | ||
| 327 | - | {Value: "auto", Label: "auto", Description: "detect terminal background and use zut defaults", Builtin: true}, | 381 | + | {Value: "auto", Label: "auto", Description: "follow terminal colors and appearance", Builtin: true}, |
| 328 | - | {Value: "inherited", Label: "inherited (from terminal)", Description: "use terminal colors with truecolor or best-effort 256-color output", Builtin: true}, | |||
| 329 | {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true}, | 382 | {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true}, | ||
| 330 | {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true}, | 383 | {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true}, | ||
| 331 | } | 384 | } | ||
| 332 | - | seen := map[string]bool{"auto": true, "inherited": true, "dark": true, "light": true} | 385 | + | seen := map[string]bool{"auto": true, "dark": true, "light": true} |
| 333 | paths, _ := themeFilesIn(filepath.Join(zutHome, "themes")) | 386 | paths, _ := themeFilesIn(filepath.Join(zutHome, "themes")) | ||
| 334 | sort.Strings(paths) | 387 | sort.Strings(paths) | ||
| 335 | for _, path := range paths { | 388 | for _, path := range paths { | ||
| @@ -399,8 +452,6 @@ func resolveThemePath(zutHome, preferred string) (string, error) { | |||||
| 399 | switch strings.ToLower(preferred) { | 452 | switch strings.ToLower(preferred) { | ||
| 400 | case "", "auto", "default", "system": | 453 | case "", "auto", "default", "system": | ||
| 401 | return "", nil | 454 | return "", nil | ||
| 402 | - | case "inherited", "terminal", "terminal-inherited": | |||
| 403 | - | return "builtin:inherited", nil | |||
| 404 | case "dark": | 455 | case "dark": | ||
| 405 | return "builtin:dark", nil | 456 | return "builtin:dark", nil | ||
| 406 | case "light": | 457 | case "light": | ||
| @@ -569,14 +620,69 @@ func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme { | |||||
| 569 | return s | 620 | return s | ||
| 570 | } | 621 | } | ||
| 571 | 622 | ||||
| 572 | - | func IsLightTheme(th Theme) bool { | 623 | + | func validateExplicitTerminalColor(color TerminalColor) error { |
| 573 | - | if th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG { | 624 | + | switch color.Mode { |
| 574 | - | return true | 625 | + | case terminalColor256: |
| 626 | + | if color.Index < 0 || color.Index > 255 { | |||
| 627 | + | return fmt.Errorf("xterm-256 index %d is outside 0..255", color.Index) | |||
| 628 | + | } | |||
| 629 | + | case terminalColorANSI: | |||
| 630 | + | if _, ok := ansiSGRToXtermIndex(color.Index); !ok { | |||
| 631 | + | return fmt.Errorf("ANSI SGR %d is not a palette color", color.Index) | |||
| 632 | + | } | |||
| 633 | + | case terminalColorRGB: | |||
| 634 | + | if color.R < 0 || color.R > 255 || color.G < 0 || color.G > 255 || color.B < 0 || color.B > 255 { | |||
| 635 | + | return fmt.Errorf("RGB color (%d,%d,%d) is outside 0..255", color.R, color.G, color.B) | |||
| 636 | + | } | |||
| 637 | + | default: | |||
| 638 | + | return errors.New("theme colors must be explicit xterm-256, ANSI, or RGB values") | |||
| 639 | + | } | |||
| 640 | + | return nil | |||
| 641 | + | } | |||
| 642 | + | ||||
| 643 | + | func validateThemeFile(file ThemeFile) error { | |||
| 644 | + | for _, overrides := range []ThemeOverrides{file.Overrides, file.Colors.Base, file.Colors.Dark, file.Colors.Light} { | |||
| 645 | + | if err := validateThemeOverrides(overrides); err != nil { | |||
| 646 | + | return err | |||
| 647 | + | } | |||
| 575 | } | 648 | } | ||
| 576 | - | return th.Inherited && th.Terminal.SchemeKnown && th.Terminal.Light | 649 | + | return nil |
| 577 | } | 650 | } | ||
| 578 | 651 | ||||
| 579 | - | func isLightTheme(th Theme) bool { return IsLightTheme(th) } | 652 | + | func validateThemeOverrides(o ThemeOverrides) error { |
| 653 | + | for _, color := range []*TerminalColorValue{o.FG, o.Muted, o.Accent, o.Background, o.User, o.UserBubbleBG, o.UserBubbleFG, o.Assistant, o.Tool, o.ToolOut, o.Error, o.Warning, o.Spinner, o.ThinkingMax, o.ThinkingMaxCamel, o.SelectionBG, o.SelectionFG} { | |||
| 654 | + | if color != nil { | |||
| 655 | + | if err := validateExplicitTerminalColor(color.TerminalColor); err != nil { | |||
| 656 | + | return err | |||
| 657 | + | } | |||
| 658 | + | } | |||
| 659 | + | } | |||
| 660 | + | if len(o.SpinnerFrames) > 64 { | |||
| 661 | + | return errors.New("spinner_frames has more than 64 entries") | |||
| 662 | + | } | |||
| 663 | + | for _, frame := range o.SpinnerFrames { | |||
| 664 | + | if frame == "" || len([]rune(frame)) > 16 { | |||
| 665 | + | return fmt.Errorf("invalid spinner frame %q", frame) | |||
| 666 | + | } | |||
| 667 | + | } | |||
| 668 | + | if o.SpinnerIntervalMS != nil && (*o.SpinnerIntervalMS < 10 || *o.SpinnerIntervalMS > 10000) { | |||
| 669 | + | return fmt.Errorf("spinner_interval_ms %d is outside 10..10000", *o.SpinnerIntervalMS) | |||
| 670 | + | } | |||
| 671 | + | if o.SyntaxBaseStyle != nil && *o.SyntaxBaseStyle != "" { | |||
| 672 | + | if _, ok := styles.Registry[*o.SyntaxBaseStyle]; !ok { | |||
| 673 | + | return fmt.Errorf("unknown syntax_base_style %q", *o.SyntaxBaseStyle) | |||
| 674 | + | } | |||
| 675 | + | } | |||
| 676 | + | for _, entry := range []*string{o.Syntax.Keyword, o.Syntax.KeywordConstant, o.Syntax.KeywordDeclaration, o.Syntax.KeywordNamespace, o.Syntax.KeywordReserved, o.Syntax.KeywordType, o.Syntax.NameBuiltin, o.Syntax.NameFunction, o.Syntax.NameClass, o.Syntax.NameDecorator, o.Syntax.LiteralString, o.Syntax.LiteralStringEscape, o.Syntax.LiteralNumber, o.Syntax.Comment, o.Syntax.CommentPreproc, o.Syntax.Operator, o.Syntax.Punctuation, o.Syntax.Text} { | |||
| 677 | + | if entry == nil { | |||
| 678 | + | continue | |||
| 679 | + | } | |||
| 680 | + | if _, err := chroma.ParseStyleEntry(*entry); err != nil { | |||
| 681 | + | return fmt.Errorf("invalid syntax style %q: %w", *entry, err) | |||
| 682 | + | } | |||
| 683 | + | } | |||
| 684 | + | return nil | |||
| 685 | + | } | |||
| 580 | 686 | ||||
| 581 | func parseHexColor(s string) (TerminalColor, bool) { | 687 | func parseHexColor(s string) (TerminalColor, bool) { | ||
| 582 | s = strings.TrimPrefix(strings.TrimSpace(s), "#") | 688 | s = strings.TrimPrefix(strings.TrimSpace(s), "#") | ||
M packages/tui/theme_loader_test.go +41 -9
| @@ -33,8 +33,8 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |||
| 33 | 33 | if th.Accent != Color256(204) { | |
| 34 | 34 | t.Fatalf("accent = %#v, want 204", th.Accent) | |
| 35 | 35 | } | |
| 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) | |
| 38 | 38 | } | |
| 39 | 39 | if len(th.SpinnerFrames) == 0 { | |
| 40 | 40 | t.Fatal("spinner frames should be inherited") | |
| @@ -66,15 +66,17 @@ func TestLoadThemeAllowsSpinnerAppearanceOverrides(t *testing.T) { | |||
| 66 | 66 | if th.SpinnerFrames[1] != "o" || th.SpinnerIntervalMS != 200 { | |
| 67 | 67 | t.Fatalf("spinner appearance overrides not applied: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS) | |
| 68 | 68 | } | |
| 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) | |
| 71 | 71 | } | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 74 | func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | |
| 75 | 75 | home := writeThemeFile(t, "darkonly", `{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`) | |
| 76 | + | detected := Light | |
| 77 | + | detected.Terminal = TerminalProfile{SchemeKnown: true, Light: true} | |
| 76 | 78 | ||
| 77 | - | th, _, err := LoadThemeFromHome(home, "darkonly", Light) | |
| 79 | + | th, _, err := LoadThemeFromHome(home, "darkonly", detected) | |
| 78 | 80 | if err != nil { | |
| 79 | 81 | t.Fatal(err) | |
| 80 | 82 | } | |
| @@ -84,8 +86,8 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | |||
| 84 | 86 | if th.SpinnerIntervalMS != 120 { | |
| 85 | 87 | t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS) | |
| 86 | 88 | } | |
| 87 | - | if th.FG != Light.FG { | |
| 88 | - | t.Fatalf("fg = %#v, want inherited light fg %#v", th.FG, Light.FG) | |
| 89 | + | if th.FG != TerminalDefault() { | |
| 90 | + | t.Fatalf("fg = %#v, want terminal default", th.FG) | |
| 89 | 91 | } | |
| 90 | 92 | } | |
| 91 | 93 | ||
| @@ -102,8 +104,8 @@ func TestLoadThemeIgnoresLegacySpinnerMessages(t *testing.T) { | |||
| 102 | 104 | if len(th.SpinnerFrames) != len(Light.SpinnerFrames) || th.SpinnerIntervalMS != Light.SpinnerIntervalMS { | |
| 103 | 105 | t.Fatalf("legacy spinner_messages changed spinner appearance: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS) | |
| 104 | 106 | } | |
| 105 | - | if th.FG != Light.FG { | |
| 106 | - | t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG) | |
| 107 | + | if th.FG != TerminalDefault() { | |
| 108 | + | t.Fatalf("fg = %#v, want terminal default", th.FG) | |
| 107 | 109 | } | |
| 108 | 110 | } | |
| 109 | 111 | ||
| @@ -145,3 +147,33 @@ func TestLoadThemeQuantizesRGBSemanticColorsWithoutTrueColor(t *testing.T) { | |||
| 145 | 147 | t.Fatalf("RGB accent fallback = %q, want xterm-256 index %d", got, want) | |
| 146 | 148 | } | |
| 147 | 149 | } | |
| 150 | + | ||
| 151 | + | func TestLoadThemeRejectsInvalidNestedValues(t *testing.T) { | |
| 152 | + | for _, body := range []string{ | |
| 153 | + | `{"colors":{"dark":{"accent":999}}}`, | |
| 154 | + | `{"colors":{"light":{"syntax":{"keyword":"not a style"}}}}`, | |
| 155 | + | `{"spinner_interval_ms":1}`, | |
| 156 | + | `{"syntax_base_style":"not-a-style"}`, | |
| 157 | + | } { | |
| 158 | + | home := writeThemeFile(t, "invalid", body) | |
| 159 | + | if _, err := LoadThemeSource(home, "invalid"); err == nil { | |
| 160 | + | t.Fatalf("LoadThemeSource(%s) succeeded", body) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestResolveThemeCustomUsesReportedScheme(t *testing.T) { | |
| 166 | + | home := writeThemeFile(t, "split", `{"colors":{"dark":{"accent":20},"light":{"accent":21}}}`) | |
| 167 | + | source, err := LoadThemeSource(home, "split") | |
| 168 | + | if err != nil { | |
| 169 | + | t.Fatal(err) | |
| 170 | + | } | |
| 171 | + | profile := TerminalProfile{SchemeKnown: true, Light: true} | |
| 172 | + | if got := ResolveTheme("split", source, profile).Theme.Accent; got != Color256(21) { | |
| 173 | + | t.Fatalf("light branch accent = %#v, want 21", got) | |
| 174 | + | } | |
| 175 | + | dark := TerminalProfile{SchemeKnown: true, Light: false} | |
| 176 | + | if got := ResolveTheme("split", source, dark).Theme.Accent; got != Color256(20) { | |
| 177 | + | t.Fatalf("dark branch accent = %#v, want 20", got) | |
| 178 | + | } | |
| 179 | + | } | |
| @@ -33,8 +33,8 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |||||
| 33 | if th.Accent != Color256(204) { | 33 | if th.Accent != Color256(204) { | ||
| 34 | t.Fatalf("accent = %#v, want 204", th.Accent) | 34 | t.Fatalf("accent = %#v, want 204", th.Accent) | ||
| 35 | } | 35 | } | ||
| 36 | - | if th.FG != Dark.FG { | 36 | + | if th.FG != TerminalDefault() { |
| 37 | - | t.Fatalf("fg = %#v, want inherited %#v", th.FG, Dark.FG) | 37 | + | t.Fatalf("fg = %#v, want terminal default", th.FG) |
| 38 | } | 38 | } | ||
| 39 | if len(th.SpinnerFrames) == 0 { | 39 | if len(th.SpinnerFrames) == 0 { | ||
| 40 | t.Fatal("spinner frames should be inherited") | 40 | t.Fatal("spinner frames should be inherited") | ||
| @@ -66,15 +66,17 @@ func TestLoadThemeAllowsSpinnerAppearanceOverrides(t *testing.T) { | |||||
| 66 | if th.SpinnerFrames[1] != "o" || th.SpinnerIntervalMS != 200 { | 66 | if th.SpinnerFrames[1] != "o" || th.SpinnerIntervalMS != 200 { | ||
| 67 | t.Fatalf("spinner appearance overrides not applied: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS) | 67 | t.Fatalf("spinner appearance overrides not applied: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS) | ||
| 68 | } | 68 | } | ||
| 69 | - | if th.Accent != Dark.Accent { | 69 | + | if th.Accent != TerminalPaletteSlot(12) { |
| 70 | - | t.Fatalf("accent = %#v, want inherited %#v", th.Accent, Dark.Accent) | 70 | + | t.Fatalf("accent = %#v, want terminal palette slot 12", th.Accent) |
| 71 | } | 71 | } | ||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| 74 | func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | 74 | func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | ||
| 75 | home := writeThemeFile(t, "darkonly", `{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`) | 75 | home := writeThemeFile(t, "darkonly", `{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`) | ||
| 76 | + | detected := Light | |||
| 77 | + | detected.Terminal = TerminalProfile{SchemeKnown: true, Light: true} | |||
| 76 | 78 | ||||
| 77 | - | th, _, err := LoadThemeFromHome(home, "darkonly", Light) | 79 | + | th, _, err := LoadThemeFromHome(home, "darkonly", detected) |
| 78 | if err != nil { | 80 | if err != nil { | ||
| 79 | t.Fatal(err) | 81 | t.Fatal(err) | ||
| 80 | } | 82 | } | ||
| @@ -84,8 +86,8 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | |||||
| 84 | if th.SpinnerIntervalMS != 120 { | 86 | if th.SpinnerIntervalMS != 120 { | ||
| 85 | t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS) | 87 | t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS) | ||
| 86 | } | 88 | } | ||
| 87 | - | if th.FG != Light.FG { | 89 | + | if th.FG != TerminalDefault() { |
| 88 | - | t.Fatalf("fg = %#v, want inherited light fg %#v", th.FG, Light.FG) | 90 | + | t.Fatalf("fg = %#v, want terminal default", th.FG) |
| 89 | } | 91 | } | ||
| 90 | } | 92 | } | ||
| 91 | 93 | ||||
| @@ -102,8 +104,8 @@ func TestLoadThemeIgnoresLegacySpinnerMessages(t *testing.T) { | |||||
| 102 | if len(th.SpinnerFrames) != len(Light.SpinnerFrames) || th.SpinnerIntervalMS != Light.SpinnerIntervalMS { | 104 | if len(th.SpinnerFrames) != len(Light.SpinnerFrames) || th.SpinnerIntervalMS != Light.SpinnerIntervalMS { | ||
| 103 | t.Fatalf("legacy spinner_messages changed spinner appearance: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS) | 105 | t.Fatalf("legacy spinner_messages changed spinner appearance: %#v %d", th.SpinnerFrames, th.SpinnerIntervalMS) | ||
| 104 | } | 106 | } | ||
| 105 | - | if th.FG != Light.FG { | 107 | + | if th.FG != TerminalDefault() { |
| 106 | - | t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG) | 108 | + | t.Fatalf("fg = %#v, want terminal default", th.FG) |
| 107 | } | 109 | } | ||
| 108 | } | 110 | } | ||
| 109 | 111 | ||||
| @@ -145,3 +147,33 @@ func TestLoadThemeQuantizesRGBSemanticColorsWithoutTrueColor(t *testing.T) { | |||||
| 145 | t.Fatalf("RGB accent fallback = %q, want xterm-256 index %d", got, want) | 147 | t.Fatalf("RGB accent fallback = %q, want xterm-256 index %d", got, want) | ||
| 146 | } | 148 | } | ||
| 147 | } | 149 | } | ||
| 150 | + | ||||
| 151 | + | func TestLoadThemeRejectsInvalidNestedValues(t *testing.T) { | |||
| 152 | + | for _, body := range []string{ | |||
| 153 | + | `{"colors":{"dark":{"accent":999}}}`, | |||
| 154 | + | `{"colors":{"light":{"syntax":{"keyword":"not a style"}}}}`, | |||
| 155 | + | `{"spinner_interval_ms":1}`, | |||
| 156 | + | `{"syntax_base_style":"not-a-style"}`, | |||
| 157 | + | } { | |||
| 158 | + | home := writeThemeFile(t, "invalid", body) | |||
| 159 | + | if _, err := LoadThemeSource(home, "invalid"); err == nil { | |||
| 160 | + | t.Fatalf("LoadThemeSource(%s) succeeded", body) | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestResolveThemeCustomUsesReportedScheme(t *testing.T) { | |||
| 166 | + | home := writeThemeFile(t, "split", `{"colors":{"dark":{"accent":20},"light":{"accent":21}}}`) | |||
| 167 | + | source, err := LoadThemeSource(home, "split") | |||
| 168 | + | if err != nil { | |||
| 169 | + | t.Fatal(err) | |||
| 170 | + | } | |||
| 171 | + | profile := TerminalProfile{SchemeKnown: true, Light: true} | |||
| 172 | + | if got := ResolveTheme("split", source, profile).Theme.Accent; got != Color256(21) { | |||
| 173 | + | t.Fatalf("light branch accent = %#v, want 21", got) | |||
| 174 | + | } | |||
| 175 | + | dark := TerminalProfile{SchemeKnown: true, Light: false} | |||
| 176 | + | if got := ResolveTheme("split", source, dark).Theme.Accent; got != Color256(20) { | |||
| 177 | + | t.Fatalf("dark branch accent = %#v, want 20", got) | |||
| 178 | + | } | |||
| 179 | + | } | |||
M packages/tui/view.go +3 -1
| @@ -1208,8 +1208,10 @@ func toolThemeKey(theme Theme) uint64 { | |||
| 1208 | 1208 | for _, c := range theme.Terminal.Palette { | |
| 1209 | 1209 | h = hashThemeColor(h, c) | |
| 1210 | 1210 | } | |
| 1211 | + | h = fnv64aWriteByte(h, byte(theme.Terminal.ColorDepth())) | |
| 1212 | + | h = fnv64aWriteUint64(h, uint64(theme.Terminal.PaletteKnown)) | |
| 1211 | 1213 | for _, value := range []bool{ | |
| 1212 | - | theme.Inherited, | |
| 1214 | + | theme.UseTerminalPalette, | |
| 1213 | 1215 | theme.Terminal.HasForeground, | |
| 1214 | 1216 | theme.Terminal.HasBackground, | |
| 1215 | 1217 | 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, | ||