feat(tui): promote semantic theme colors to truecolor
- commit
- add512eec9095b395e6dcf9b6882ec6de79438cf
- author
- brice · · 2026-08-05 14:41 UTC
- committer
- brice · · 2026-08-05 15:08 UTC
- parent
- bfb904a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
22 files · +449 -354
M README.md +1 -1
| @@ -419,7 +419,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 419 | 419 | - **show loaded resources at startup**: list the active Zotfile agent (for `zot 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`. | |
| 420 | 420 | - **TUI settings**: opens a sub-view for input layout and status placement. **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` (`above_input` or `below_input` for the position fields). | |
| 421 | 421 | - **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`. | |
| 422 | - | - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Inherited mode uses terminal colors with truecolor or best-effort 256-color output; theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md). | |
| 422 | + | - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZOT_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, zot resets to auto. See [docs/themes.md](docs/themes.md). | |
| 423 | 423 | - **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. | |
| 424 | 424 | ||
| 425 | 425 | ### `/skills` | |
| @@ -419,7 +419,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 419 | - **show loaded resources at startup**: list the active Zotfile agent (for `zot 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`. | 419 | - **show loaded resources at startup**: list the active Zotfile agent (for `zot 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`. | ||
| 420 | - **TUI settings**: opens a sub-view for input layout and status placement. **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` (`above_input` or `below_input` for the position fields). | 420 | - **TUI settings**: opens a sub-view for input layout and status placement. **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` (`above_input` or `below_input` for the position fields). | ||
| 421 | - **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`. | 421 | - **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`. | ||
| 422 | - | - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Inherited mode uses terminal colors with truecolor or best-effort 256-color output; theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md). | 422 | + | - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZOT_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, zot resets to auto. See [docs/themes.md](docs/themes.md). |
| 423 | - **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. | 423 | - **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. | ||
| 424 | 424 | ||||
| 425 | ### `/skills` | 425 | ### `/skills` | ||
M docs/themes.md +32 -14
| @@ -34,6 +34,20 @@ The built-in choices are: | |||
| 34 | 34 | The inherited terminal snapshot is captured at startup and reused when the | |
| 35 | 35 | setting is changed live; restart zot to pick up terminal palette changes. | |
| 36 | 36 | ||
| 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, zot 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. | |
| 44 | + | ||
| 45 | + | ## Theme proposals | |
| 46 | + | ||
| 47 | + | - **shades of gray (truecolor)** — a neutral grayscale palette for the UI and | |
| 48 | + | syntax highlighting, with an xterm-256 fallback when direct color is | |
| 49 | + | unavailable. | |
| 50 | + | ||
| 37 | 51 | Theme files bundled with extensions are discovered in-place from loaded | |
| 38 | 52 | extension directories: | |
| 39 | 53 | ||
| @@ -191,11 +205,22 @@ You may also put overrides directly at the top level or directly under | |||
| 191 | 205 | ||
| 192 | 206 | ## Color fields | |
| 193 | 207 | ||
| 194 | - | Most color fields are xterm-256 indexes (`0`–`255`) for JSON themes. | |
| 195 | - | The built-in inherited theme resolves semantic accents through the terminal's | |
| 196 | - | ANSI palette, fades muted/selected surfaces toward the terminal background, | |
| 197 | - | and emits truecolor or best-effort 256-color sequences according to terminal | |
| 198 | - | capability. | |
| 208 | + | Every color field accepts the following JSON forms: | |
| 209 | + | ||
| 210 | + | ```json | |
| 211 | + | 254 | |
| 212 | + | "#42454b" | |
| 213 | + | { "mode": "256", "index": 254 } | |
| 214 | + | { "mode": "ansi", "index": 100 } | |
| 215 | + | { "mode": "rgb", "r": 66, "g": 69, "b": 75 } | |
| 216 | + | ``` | |
| 217 | + | ||
| 218 | + | Numeric values are xterm-256 indexes. ANSI values use SGR codes, with | |
| 219 | + | standard foreground/background ranges normalized to the role where necessary. | |
| 220 | + | RGB values are emitted directly when truecolor is advertised and quantized to | |
| 221 | + | xterm-256 otherwise. The built-in inherited theme additionally resolves | |
| 222 | + | semantic accents through the terminal's ANSI palette and derives muted and | |
| 223 | + | selected surfaces from the terminal foreground/background. | |
| 199 | 224 | ||
| 200 | 225 | - `fg` — default foreground text. | |
| 201 | 226 | - `muted` — secondary text, dividers, gutters, inactive hints. | |
| @@ -214,15 +239,8 @@ capability. | |||
| 214 | 239 | - `selection_bg` — highlighted row background. | |
| 215 | 240 | - `selection_fg` — highlighted row foreground. | |
| 216 | 241 | ||
| 217 | - | `background` and `user_bubble_bg` support richer terminal color forms: | |
| 218 | - | ||
| 219 | - | ```json | |
| 220 | - | 254 | |
| 221 | - | "#42454b" | |
| 222 | - | { "mode": "256", "index": 254 } | |
| 223 | - | { "mode": "ansi", "index": 100 } | |
| 224 | - | { "mode": "rgb", "r": 66, "g": 69, "b": 75 } | |
| 225 | - | ``` | |
| 242 | + | The same forms apply to `background`, `user_bubble_bg`, and every other | |
| 243 | + | semantic color field listed above. | |
| 226 | 244 | ||
| 227 | 245 | ## Spinner fields | |
| 228 | 246 | ||
| @@ -34,6 +34,20 @@ The built-in choices are: | |||||
| 34 | The inherited terminal snapshot is captured at startup and reused when the | 34 | The inherited terminal snapshot is captured at startup and reused when the | ||
| 35 | setting is changed live; restart zot to pick up terminal palette changes. | 35 | setting is changed live; restart zot to pick up terminal palette changes. | ||
| 36 | 36 | ||||
| 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, zot 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. | |||
| 44 | + | ||||
| 45 | + | ## Theme proposals | |||
| 46 | + | ||||
| 47 | + | - **shades of gray (truecolor)** — a neutral grayscale palette for the UI and | |||
| 48 | + | syntax highlighting, with an xterm-256 fallback when direct color is | |||
| 49 | + | unavailable. | |||
| 50 | + | ||||
| 37 | Theme files bundled with extensions are discovered in-place from loaded | 51 | Theme files bundled with extensions are discovered in-place from loaded | ||
| 38 | extension directories: | 52 | extension directories: | ||
| 39 | 53 | ||||
| @@ -191,11 +205,22 @@ You may also put overrides directly at the top level or directly under | |||||
| 191 | 205 | ||||
| 192 | ## Color fields | 206 | ## Color fields | ||
| 193 | 207 | ||||
| 194 | - | Most color fields are xterm-256 indexes (`0`–`255`) for JSON themes. | 208 | + | Every color field accepts the following JSON forms: |
| 195 | - | The built-in inherited theme resolves semantic accents through the terminal's | 209 | + | |
| 196 | - | ANSI palette, fades muted/selected surfaces toward the terminal background, | 210 | + | ```json |
| 197 | - | and emits truecolor or best-effort 256-color sequences according to terminal | 211 | + | 254 |
| 198 | - | capability. | 212 | + | "#42454b" |
| 213 | + | { "mode": "256", "index": 254 } | |||
| 214 | + | { "mode": "ansi", "index": 100 } | |||
| 215 | + | { "mode": "rgb", "r": 66, "g": 69, "b": 75 } | |||
| 216 | + | ``` | |||
| 217 | + | ||||
| 218 | + | Numeric values are xterm-256 indexes. ANSI values use SGR codes, with | |||
| 219 | + | standard foreground/background ranges normalized to the role where necessary. | |||
| 220 | + | RGB values are emitted directly when truecolor is advertised and quantized to | |||
| 221 | + | xterm-256 otherwise. The built-in inherited theme additionally resolves | |||
| 222 | + | semantic accents through the terminal's ANSI palette and derives muted and | |||
| 223 | + | selected surfaces from the terminal foreground/background. | |||
| 199 | 224 | ||||
| 200 | - `fg` — default foreground text. | 225 | - `fg` — default foreground text. | ||
| 201 | - `muted` — secondary text, dividers, gutters, inactive hints. | 226 | - `muted` — secondary text, dividers, gutters, inactive hints. | ||
| @@ -214,15 +239,8 @@ capability. | |||||
| 214 | - `selection_bg` — highlighted row background. | 239 | - `selection_bg` — highlighted row background. | ||
| 215 | - `selection_fg` — highlighted row foreground. | 240 | - `selection_fg` — highlighted row foreground. | ||
| 216 | 241 | ||||
| 217 | - | `background` and `user_bubble_bg` support richer terminal color forms: | 242 | + | The same forms apply to `background`, `user_bubble_bg`, and every other |
| 218 | - | 243 | + | semantic color field listed above. | |
| 219 | - | ```json | |||
| 220 | - | 254 | |||
| 221 | - | "#42454b" | |||
| 222 | - | { "mode": "256", "index": 254 } | |||
| 223 | - | { "mode": "ansi", "index": 100 } | |||
| 224 | - | { "mode": "rgb", "r": 66, "g": 69, "b": 75 } | |||
| 225 | - | ``` | |||
| 226 | 244 | ||||
| 227 | ## Spinner fields | 245 | ## Spinner fields | ||
| 228 | 246 | ||||
M packages/agent/args.go +5 -2
| @@ -370,11 +370,14 @@ func PrintHelp(version string) { | |||
| 370 | 370 | th := tui.Dark | |
| 371 | 371 | fd := int(os.Stderr.Fd()) | |
| 372 | 372 | useColor := term.IsTerminal(fd) | |
| 373 | - | style := func(c int, s string) string { | |
| 373 | + | if useColor && tui.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM")) { | |
| 374 | + | th.Terminal.TrueColor = true | |
| 375 | + | } | |
| 376 | + | style := func(c tui.TerminalColor, s string) string { | |
| 374 | 377 | if !useColor { | |
| 375 | 378 | return s | |
| 376 | 379 | } | |
| 377 | - | return th.FG256(c, s) | |
| 380 | + | return th.FGColor(c, s) | |
| 378 | 381 | } | |
| 379 | 382 | assistant := func(s string) string { return style(th.Assistant, s) } | |
| 380 | 383 | muted := func(s string) string { return style(th.Muted, s) } | |
| @@ -370,11 +370,14 @@ func PrintHelp(version string) { | |||||
| 370 | th := tui.Dark | 370 | th := tui.Dark | ||
| 371 | fd := int(os.Stderr.Fd()) | 371 | fd := int(os.Stderr.Fd()) | ||
| 372 | useColor := term.IsTerminal(fd) | 372 | useColor := term.IsTerminal(fd) | ||
| 373 | - | style := func(c int, s string) string { | 373 | + | if useColor && tui.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM")) { |
| 374 | + | th.Terminal.TrueColor = true | |||
| 375 | + | } | |||
| 376 | + | style := func(c tui.TerminalColor, s string) string { | |||
| 374 | if !useColor { | 377 | if !useColor { | ||
| 375 | return s | 378 | return s | ||
| 376 | } | 379 | } | ||
| 377 | - | return th.FG256(c, s) | 380 | + | return th.FGColor(c, s) |
| 378 | } | 381 | } | ||
| 379 | assistant := func(s string) string { return style(th.Assistant, s) } | 382 | assistant := func(s string) string { return style(th.Assistant, s) } | ||
| 380 | muted := func(s string) string { return style(th.Muted, s) } | 383 | muted := func(s string) string { return style(th.Muted, s) } | ||
M packages/agent/modes/dialog_frame.go +10 -10
| @@ -16,13 +16,13 @@ import ( | |||
| 16 | 16 | func frameHeader(th tui.Theme, title string, width int) string { | |
| 17 | 17 | label := "── " + title + " " | |
| 18 | 18 | if width <= 0 { | |
| 19 | - | return th.FG256(th.Muted, label) | |
| 19 | + | return th.FGColor(th.Muted, label) | |
| 20 | 20 | } | |
| 21 | 21 | padLen := width - runewidth.StringWidth(label) | |
| 22 | 22 | if padLen < 0 { | |
| 23 | 23 | padLen = 0 | |
| 24 | 24 | } | |
| 25 | - | return th.FG256(th.Muted, label+strings.Repeat("─", padLen)) | |
| 25 | + | return th.FGColor(th.Muted, label+strings.Repeat("─", padLen)) | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | // frameRule returns a full-width horizontal rule in the muted color. | |
| @@ -30,7 +30,7 @@ func frameRule(th tui.Theme, width int) string { | |||
| 30 | 30 | if width <= 0 { | |
| 31 | 31 | width = 1 | |
| 32 | 32 | } | |
| 33 | - | return th.FG256(th.Muted, strings.Repeat("─", width)) | |
| 33 | + | return th.FGColor(th.Muted, strings.Repeat("─", width)) | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | // padDialogFrame inserts breathing room between the shared dialog frame | |
| @@ -71,24 +71,24 @@ func isFrameRuleLine(line string) bool { | |||
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | 73 | // frameHeaderColor is like frameHeader but renders in a caller-supplied | |
| 74 | - | // 256-color code. Used by the update-available banner which wants a | |
| 75 | - | // yellow accent on the rules and title. | |
| 76 | - | func frameHeaderColor(th tui.Theme, title string, width, color int) string { | |
| 74 | + | // theme color. Used by the update-available banner which wants a yellow | |
| 75 | + | // accent on the rules and title. | |
| 76 | + | func frameHeaderColor(th tui.Theme, title string, width int, color tui.TerminalColor) string { | |
| 77 | 77 | label := "── " + title + " " | |
| 78 | 78 | if width <= 0 { | |
| 79 | - | return th.FG256(color, label) | |
| 79 | + | return th.FGColor(color, label) | |
| 80 | 80 | } | |
| 81 | 81 | padLen := width - runewidth.StringWidth(label) | |
| 82 | 82 | if padLen < 0 { | |
| 83 | 83 | padLen = 0 | |
| 84 | 84 | } | |
| 85 | - | return th.FG256(color, label+strings.Repeat("─", padLen)) | |
| 85 | + | return th.FGColor(color, label+strings.Repeat("─", padLen)) | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | 88 | // frameRuleColor is like frameRule in an explicit color. | |
| 89 | - | func frameRuleColor(th tui.Theme, width, color int) string { | |
| 89 | + | func frameRuleColor(th tui.Theme, width int, color tui.TerminalColor) string { | |
| 90 | 90 | if width <= 0 { | |
| 91 | 91 | width = 1 | |
| 92 | 92 | } | |
| 93 | - | return th.FG256(color, strings.Repeat("─", width)) | |
| 93 | + | return th.FGColor(color, strings.Repeat("─", width)) | |
| 94 | 94 | } | |
| @@ -16,13 +16,13 @@ import ( | |||||
| 16 | func frameHeader(th tui.Theme, title string, width int) string { | 16 | func frameHeader(th tui.Theme, title string, width int) string { | ||
| 17 | label := "── " + title + " " | 17 | label := "── " + title + " " | ||
| 18 | if width <= 0 { | 18 | if width <= 0 { | ||
| 19 | - | return th.FG256(th.Muted, label) | 19 | + | return th.FGColor(th.Muted, label) |
| 20 | } | 20 | } | ||
| 21 | padLen := width - runewidth.StringWidth(label) | 21 | padLen := width - runewidth.StringWidth(label) | ||
| 22 | if padLen < 0 { | 22 | if padLen < 0 { | ||
| 23 | padLen = 0 | 23 | padLen = 0 | ||
| 24 | } | 24 | } | ||
| 25 | - | return th.FG256(th.Muted, label+strings.Repeat("─", padLen)) | 25 | + | return th.FGColor(th.Muted, label+strings.Repeat("─", padLen)) |
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | // frameRule returns a full-width horizontal rule in the muted color. | 28 | // frameRule returns a full-width horizontal rule in the muted color. | ||
| @@ -30,7 +30,7 @@ func frameRule(th tui.Theme, width int) string { | |||||
| 30 | if width <= 0 { | 30 | if width <= 0 { | ||
| 31 | width = 1 | 31 | width = 1 | ||
| 32 | } | 32 | } | ||
| 33 | - | return th.FG256(th.Muted, strings.Repeat("─", width)) | 33 | + | return th.FGColor(th.Muted, strings.Repeat("─", width)) |
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | // padDialogFrame inserts breathing room between the shared dialog frame | 36 | // padDialogFrame inserts breathing room between the shared dialog frame | ||
| @@ -71,24 +71,24 @@ func isFrameRuleLine(line string) bool { | |||||
| 71 | } | 71 | } | ||
| 72 | 72 | ||||
| 73 | // frameHeaderColor is like frameHeader but renders in a caller-supplied | 73 | // frameHeaderColor is like frameHeader but renders in a caller-supplied | ||
| 74 | - | // 256-color code. Used by the update-available banner which wants a | 74 | + | // theme color. Used by the update-available banner which wants a yellow |
| 75 | - | // yellow accent on the rules and title. | 75 | + | // accent on the rules and title. |
| 76 | - | func frameHeaderColor(th tui.Theme, title string, width, color int) string { | 76 | + | func frameHeaderColor(th tui.Theme, title string, width int, color tui.TerminalColor) string { |
| 77 | label := "── " + title + " " | 77 | label := "── " + title + " " | ||
| 78 | if width <= 0 { | 78 | if width <= 0 { | ||
| 79 | - | return th.FG256(color, label) | 79 | + | return th.FGColor(color, label) |
| 80 | } | 80 | } | ||
| 81 | padLen := width - runewidth.StringWidth(label) | 81 | padLen := width - runewidth.StringWidth(label) | ||
| 82 | if padLen < 0 { | 82 | if padLen < 0 { | ||
| 83 | padLen = 0 | 83 | padLen = 0 | ||
| 84 | } | 84 | } | ||
| 85 | - | return th.FG256(color, label+strings.Repeat("─", padLen)) | 85 | + | return th.FGColor(color, label+strings.Repeat("─", padLen)) |
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | // frameRuleColor is like frameRule in an explicit color. | 88 | // frameRuleColor is like frameRule in an explicit color. | ||
| 89 | - | func frameRuleColor(th tui.Theme, width, color int) string { | 89 | + | func frameRuleColor(th tui.Theme, width int, color tui.TerminalColor) string { |
| 90 | if width <= 0 { | 90 | if width <= 0 { | ||
| 91 | width = 1 | 91 | width = 1 | ||
| 92 | } | 92 | } | ||
| 93 | - | return th.FG256(color, strings.Repeat("─", width)) | 93 | + | return th.FGColor(color, strings.Repeat("─", width)) |
| 94 | } | 94 | } | ||
M packages/agent/modes/ext_notes_test.go +4 -1
| @@ -11,7 +11,10 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | func newNotesTestInteractive() *Interactive { | |
| 13 | 13 | i := &Interactive{dirty: make(chan struct{}, 1)} | |
| 14 | - | i.cfg.Theme = tui.Theme{Muted: 8, Warning: 3, Error: 1, Tool: 2, Accent: 4} | |
| 14 | + | i.cfg.Theme = tui.Theme{ | |
| 15 | + | Muted: tui.Color256(8), Warning: tui.Color256(3), Error: tui.Color256(1), | |
| 16 | + | Tool: tui.Color256(2), Accent: tui.Color256(4), | |
| 17 | + | } | |
| 15 | 18 | return i | |
| 16 | 19 | } | |
| 17 | 20 | ||
| @@ -11,7 +11,10 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | func newNotesTestInteractive() *Interactive { | 12 | func newNotesTestInteractive() *Interactive { | ||
| 13 | i := &Interactive{dirty: make(chan struct{}, 1)} | 13 | i := &Interactive{dirty: make(chan struct{}, 1)} | ||
| 14 | - | i.cfg.Theme = tui.Theme{Muted: 8, Warning: 3, Error: 1, Tool: 2, Accent: 4} | 14 | + | i.cfg.Theme = tui.Theme{ |
| 15 | + | Muted: tui.Color256(8), Warning: tui.Color256(3), Error: tui.Color256(1), | |||
| 16 | + | Tool: tui.Color256(2), Accent: tui.Color256(4), | |||
| 17 | + | } | |||
| 15 | return i | 18 | return i | ||
| 16 | } | 19 | } | ||
| 17 | 20 | ||||
M packages/agent/modes/logout_dialog_test.go +3 -1
| @@ -12,7 +12,9 @@ func TestLogoutDialogSelectionBackgroundSpansFullWidth(t *testing.T) { | |||
| 12 | 12 | d := newLogoutDialog() | |
| 13 | 13 | d.Open([]logoutItem{{label: "Google", target: "google", method: "api key"}}) | |
| 14 | 14 | const width = 60 | |
| 15 | - | lines := d.Render(tui.Theme{SelectionFG: 15, SelectionBG: 4, Muted: 8}, width) | |
| 15 | + | lines := d.Render(tui.Theme{ | |
| 16 | + | SelectionFG: tui.Color256(15), SelectionBG: tui.Color256(4), Muted: tui.Color256(8), | |
| 17 | + | }, width) | |
| 16 | 18 | selected := lines[2] | |
| 17 | 19 | if got := runewidth.StringWidth(stripANSIBytes(selected)); got != width { | |
| 18 | 20 | t.Fatalf("selected row width = %d, want %d", got, width) | |
| @@ -12,7 +12,9 @@ func TestLogoutDialogSelectionBackgroundSpansFullWidth(t *testing.T) { | |||||
| 12 | d := newLogoutDialog() | 12 | d := newLogoutDialog() | ||
| 13 | d.Open([]logoutItem{{label: "Google", target: "google", method: "api key"}}) | 13 | d.Open([]logoutItem{{label: "Google", target: "google", method: "api key"}}) | ||
| 14 | const width = 60 | 14 | const width = 60 | ||
| 15 | - | lines := d.Render(tui.Theme{SelectionFG: 15, SelectionBG: 4, Muted: 8}, width) | 15 | + | lines := d.Render(tui.Theme{ |
| 16 | + | SelectionFG: tui.Color256(15), SelectionBG: tui.Color256(4), Muted: tui.Color256(8), | |||
| 17 | + | }, width) | |||
| 16 | selected := lines[2] | 18 | selected := lines[2] | ||
| 17 | if got := runewidth.StringWidth(stripANSIBytes(selected)); got != width { | 19 | if got := runewidth.StringWidth(stripANSIBytes(selected)); got != width { | ||
| 18 | t.Fatalf("selected row width = %d, want %d", got, width) | 20 | t.Fatalf("selected row width = %d, want %d", got, width) | ||
M packages/agent/modes/reload_ext_test.go +2 -2
| @@ -67,7 +67,7 @@ func TestStartupExtensionFailureAppearsInChatWithoutEnteringContext(t *testing.T | |||
| 67 | 67 | agent := core.NewAgent(nil, "", "", nil) | |
| 68 | 68 | i := NewInteractive(InteractiveConfig{ | |
| 69 | 69 | Agent: agent, | |
| 70 | - | Theme: tui.Theme{Error: 1}, | |
| 70 | + | Theme: tui.Theme{Error: tui.Color256(1)}, | |
| 71 | 71 | StartupExtensionErrors: []string{"startup failed: stderr log: /tmp/startup.log"}, | |
| 72 | 72 | }) | |
| 73 | 73 | ||
| @@ -84,7 +84,7 @@ func TestReloadFailureRemainsInChatWithoutEnteringContext(t *testing.T) { | |||
| 84 | 84 | agent := core.NewAgent(nil, "", "", nil) | |
| 85 | 85 | i := NewInteractive(InteractiveConfig{ | |
| 86 | 86 | Agent: agent, | |
| 87 | - | Theme: tui.Theme{Error: 1}, | |
| 87 | + | Theme: tui.Theme{Error: tui.Color256(1)}, | |
| 88 | 88 | }) | |
| 89 | 89 | seq := i.setReloadStatus("broken extension: stderr log: /tmp/ext.log", true) | |
| 90 | 90 | if !i.clearReloadStatus(seq, i.statusErr, true) { | |
| @@ -67,7 +67,7 @@ func TestStartupExtensionFailureAppearsInChatWithoutEnteringContext(t *testing.T | |||||
| 67 | agent := core.NewAgent(nil, "", "", nil) | 67 | agent := core.NewAgent(nil, "", "", nil) | ||
| 68 | i := NewInteractive(InteractiveConfig{ | 68 | i := NewInteractive(InteractiveConfig{ | ||
| 69 | Agent: agent, | 69 | Agent: agent, | ||
| 70 | - | Theme: tui.Theme{Error: 1}, | 70 | + | Theme: tui.Theme{Error: tui.Color256(1)}, |
| 71 | StartupExtensionErrors: []string{"startup failed: stderr log: /tmp/startup.log"}, | 71 | StartupExtensionErrors: []string{"startup failed: stderr log: /tmp/startup.log"}, | ||
| 72 | }) | 72 | }) | ||
| 73 | 73 | ||||
| @@ -84,7 +84,7 @@ func TestReloadFailureRemainsInChatWithoutEnteringContext(t *testing.T) { | |||||
| 84 | agent := core.NewAgent(nil, "", "", nil) | 84 | agent := core.NewAgent(nil, "", "", nil) | ||
| 85 | i := NewInteractive(InteractiveConfig{ | 85 | i := NewInteractive(InteractiveConfig{ | ||
| 86 | Agent: agent, | 86 | Agent: agent, | ||
| 87 | - | Theme: tui.Theme{Error: 1}, | 87 | + | Theme: tui.Theme{Error: tui.Color256(1)}, |
| 88 | }) | 88 | }) | ||
| 89 | seq := i.setReloadStatus("broken extension: stderr log: /tmp/ext.log", true) | 89 | seq := i.setReloadStatus("broken extension: stderr log: /tmp/ext.log", true) | ||
| 90 | if !i.clearReloadStatus(seq, i.statusErr, true) { | 90 | if !i.clearReloadStatus(seq, i.statusErr, true) { | ||
M packages/agent/modes/theme_setting_test.go +2 -2
| @@ -43,8 +43,8 @@ func TestApplyingInheritedThemeUsesCapturedTerminalProfile(t *testing.T) { | |||
| 43 | 43 | if !interactive.cfg.Theme.Inherited { | |
| 44 | 44 | t.Fatal("applying inherited theme did not switch the live theme") | |
| 45 | 45 | } | |
| 46 | - | if interactive.cfg.Theme.ColorMode != tui.ColorModeTrueColor { | |
| 47 | - | t.Fatalf("live inherited color mode = %v, want truecolor", interactive.cfg.Theme.ColorMode) | |
| 46 | + | if !interactive.cfg.Theme.Terminal.TrueColor { | |
| 47 | + | t.Fatal("live inherited color mode = 256, want truecolor") | |
| 48 | 48 | } | |
| 49 | 49 | if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground { | |
| 50 | 50 | t.Fatal("live inherited theme lost the captured terminal profile") | |
| @@ -43,8 +43,8 @@ func TestApplyingInheritedThemeUsesCapturedTerminalProfile(t *testing.T) { | |||||
| 43 | if !interactive.cfg.Theme.Inherited { | 43 | if !interactive.cfg.Theme.Inherited { | ||
| 44 | t.Fatal("applying inherited theme did not switch the live theme") | 44 | t.Fatal("applying inherited theme did not switch the live theme") | ||
| 45 | } | 45 | } | ||
| 46 | - | if interactive.cfg.Theme.ColorMode != tui.ColorModeTrueColor { | 46 | + | if !interactive.cfg.Theme.Terminal.TrueColor { |
| 47 | - | t.Fatalf("live inherited color mode = %v, want truecolor", interactive.cfg.Theme.ColorMode) | 47 | + | t.Fatal("live inherited color mode = 256, want truecolor") |
| 48 | } | 48 | } | ||
| 49 | if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground { | 49 | if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground { | ||
| 50 | t.Fatal("live inherited theme lost the captured terminal profile") | 50 | t.Fatal("live inherited theme lost the captured terminal profile") | ||
M packages/agent/skills/builtin/write-zot-themes/SKILL.md +13 -5
| @@ -19,8 +19,10 @@ inherit from the built-in detected light/dark default. | |||
| 19 | 19 | ||
| 20 | 20 | The `/settings` picker also provides an **inherited (from terminal)** | |
| 21 | 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 theme | |
| 23 | - | files remain explicit zot palettes and use the color forms documented below. | |
| 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. | |
| 24 | 26 | ||
| 25 | 27 | User themes are discovered from: | |
| 26 | 28 | ||
| @@ -191,6 +193,8 @@ under `colors`, when they should apply to both modes: | |||
| 191 | 193 | - `fg` — default foreground text. | |
| 192 | 194 | - `muted` — secondary text, dividers, gutters, inactive hints. | |
| 193 | 195 | - `accent` — prompt bar, bullets, links, headings, active markers. | |
| 196 | + | - `background` — optional full-row TUI background; missing values leave the | |
| 197 | + | terminal background unchanged. | |
| 194 | 198 | - `user` — user role label color; mostly compatibility. | |
| 195 | 199 | - `user_bubble_bg` — background behind user message rows. | |
| 196 | 200 | - `user_bubble_fg` — foreground inside user message rows. | |
| @@ -203,9 +207,7 @@ under `colors`, when they should apply to both modes: | |||
| 203 | 207 | - `selection_bg` — highlighted row background. | |
| 204 | 208 | - `selection_fg` — highlighted row foreground. | |
| 205 | 209 | ||
| 206 | - | Most color fields are xterm-256 indexes (`0`–`255`). | |
| 207 | - | ||
| 208 | - | `user_bubble_bg` supports richer terminal color forms: | |
| 210 | + | Every color field accepts the following JSON forms: | |
| 209 | 211 | ||
| 210 | 212 | ```json | |
| 211 | 213 | 254 | |
| @@ -215,6 +217,12 @@ Most color fields are xterm-256 indexes (`0`–`255`). | |||
| 215 | 217 | { "mode": "rgb", "r": 66, "g": 69, "b": 75 } | |
| 216 | 218 | ``` | |
| 217 | 219 | ||
| 220 | + | Numeric values are xterm-256 indexes. ANSI values use SGR codes, with | |
| 221 | + | standard foreground/background ranges normalized to the role where necessary. | |
| 222 | + | RGB values are emitted directly when truecolor is advertised and quantized to | |
| 223 | + | the nearest xterm-256 color otherwise. The forms apply to `background`, | |
| 224 | + | `user_bubble_bg`, and all other semantic theme colors. | |
| 225 | + | ||
| 218 | 226 | ## Spinner fields | |
| 219 | 227 | ||
| 220 | 228 | Spinner settings can appear at top level, under `colors`, or under | |
| @@ -19,8 +19,10 @@ inherit from the built-in detected light/dark default. | |||||
| 19 | 19 | ||||
| 20 | The `/settings` picker also provides an **inherited (from terminal)** | 20 | The `/settings` picker also provides an **inherited (from terminal)** | ||
| 21 | built-in theme. It uses the terminal's reported colors and emits truecolor | 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 theme | 22 | + | when advertised, with a best-effort xterm-256 fallback otherwise. JSON themes |
| 23 | - | files remain explicit zot palettes and use the color forms documented below. | 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. | |||
| 24 | 26 | ||||
| 25 | User themes are discovered from: | 27 | User themes are discovered from: | ||
| 26 | 28 | ||||
| @@ -191,6 +193,8 @@ under `colors`, when they should apply to both modes: | |||||
| 191 | - `fg` — default foreground text. | 193 | - `fg` — default foreground text. | ||
| 192 | - `muted` — secondary text, dividers, gutters, inactive hints. | 194 | - `muted` — secondary text, dividers, gutters, inactive hints. | ||
| 193 | - `accent` — prompt bar, bullets, links, headings, active markers. | 195 | - `accent` — prompt bar, bullets, links, headings, active markers. | ||
| 196 | + | - `background` — optional full-row TUI background; missing values leave the | |||
| 197 | + | terminal background unchanged. | |||
| 194 | - `user` — user role label color; mostly compatibility. | 198 | - `user` — user role label color; mostly compatibility. | ||
| 195 | - `user_bubble_bg` — background behind user message rows. | 199 | - `user_bubble_bg` — background behind user message rows. | ||
| 196 | - `user_bubble_fg` — foreground inside user message rows. | 200 | - `user_bubble_fg` — foreground inside user message rows. | ||
| @@ -203,9 +207,7 @@ under `colors`, when they should apply to both modes: | |||||
| 203 | - `selection_bg` — highlighted row background. | 207 | - `selection_bg` — highlighted row background. | ||
| 204 | - `selection_fg` — highlighted row foreground. | 208 | - `selection_fg` — highlighted row foreground. | ||
| 205 | 209 | ||||
| 206 | - | Most color fields are xterm-256 indexes (`0`–`255`). | 210 | + | Every color field accepts the following JSON forms: |
| 207 | - | ||||
| 208 | - | `user_bubble_bg` supports richer terminal color forms: | |||
| 209 | 211 | ||||
| 210 | ```json | 212 | ```json | ||
| 211 | 254 | 213 | 254 | ||
| @@ -215,6 +217,12 @@ Most color fields are xterm-256 indexes (`0`–`255`). | |||||
| 215 | { "mode": "rgb", "r": 66, "g": 69, "b": 75 } | 217 | { "mode": "rgb", "r": 66, "g": 69, "b": 75 } | ||
| 216 | ``` | 218 | ``` | ||
| 217 | 219 | ||||
| 220 | + | Numeric values are xterm-256 indexes. ANSI values use SGR codes, with | |||
| 221 | + | standard foreground/background ranges normalized to the role where necessary. | |||
| 222 | + | RGB values are emitted directly when truecolor is advertised and quantized to | |||
| 223 | + | the nearest xterm-256 color otherwise. The forms apply to `background`, | |||
| 224 | + | `user_bubble_bg`, and all other semantic theme colors. | |||
| 225 | + | ||||
| 218 | ## Spinner fields | 226 | ## Spinner fields | ||
| 219 | 227 | ||||
| 220 | Spinner settings can appear at top level, under `colors`, or under | 228 | Spinner settings can appear at top level, under `colors`, or under | ||
M packages/agent/zotfile.go +5 -5
| @@ -846,11 +846,11 @@ func acceptZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet) (bool, er | |||
| 846 | 846 | var errZotfileConsentInterrupted = errors.New("interrupted") | |
| 847 | 847 | ||
| 848 | 848 | func formatZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet, th tui.Theme, color bool) string { | |
| 849 | - | style := func(c int, text string) string { | |
| 849 | + | style := func(c tui.TerminalColor, text string) string { | |
| 850 | 850 | if !color { | |
| 851 | 851 | return text | |
| 852 | 852 | } | |
| 853 | - | return th.FG256(c, text) | |
| 853 | + | return th.FGColor(c, text) | |
| 854 | 854 | } | |
| 855 | 855 | ||
| 856 | 856 | var sb strings.Builder | |
| @@ -886,7 +886,7 @@ func readZotfileConsent(in *os.File, out io.Writer, th tui.Theme) (bool, error) | |||
| 886 | 886 | fmt.Fprint(out, "\b \b") | |
| 887 | 887 | return | |
| 888 | 888 | } | |
| 889 | - | fmt.Fprint(out, th.FG256(th.FG, string(answer))) | |
| 889 | + | fmt.Fprint(out, th.FGColor(th.FG, string(answer))) | |
| 890 | 890 | }) | |
| 891 | 891 | switch { | |
| 892 | 892 | case errors.Is(err, errZotfileConsentInterrupted): | |
| @@ -938,11 +938,11 @@ func permissionSummary(p tools.PermissionSet) string { | |||
| 938 | 938 | } | |
| 939 | 939 | ||
| 940 | 940 | func permissionSummaryStyled(p tools.PermissionSet, th tui.Theme, color bool) string { | |
| 941 | - | style := func(c int, text string) string { | |
| 941 | + | style := func(c tui.TerminalColor, text string) string { | |
| 942 | 942 | if !color { | |
| 943 | 943 | return text | |
| 944 | 944 | } | |
| 945 | - | return th.FG256(c, text) | |
| 945 | + | return th.FGColor(c, text) | |
| 946 | 946 | } | |
| 947 | 947 | permissionValue := func(value string) string { return style(th.FG, value) } | |
| 948 | 948 | ||
| @@ -846,11 +846,11 @@ func acceptZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet) (bool, er | |||||
| 846 | var errZotfileConsentInterrupted = errors.New("interrupted") | 846 | var errZotfileConsentInterrupted = errors.New("interrupted") | ||
| 847 | 847 | ||||
| 848 | func formatZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet, th tui.Theme, color bool) string { | 848 | func formatZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet, th tui.Theme, color bool) string { | ||
| 849 | - | style := func(c int, text string) string { | 849 | + | style := func(c tui.TerminalColor, text string) string { |
| 850 | if !color { | 850 | if !color { | ||
| 851 | return text | 851 | return text | ||
| 852 | } | 852 | } | ||
| 853 | - | return th.FG256(c, text) | 853 | + | return th.FGColor(c, text) |
| 854 | } | 854 | } | ||
| 855 | 855 | ||||
| 856 | var sb strings.Builder | 856 | var sb strings.Builder | ||
| @@ -886,7 +886,7 @@ func readZotfileConsent(in *os.File, out io.Writer, th tui.Theme) (bool, error) | |||||
| 886 | fmt.Fprint(out, "\b \b") | 886 | fmt.Fprint(out, "\b \b") | ||
| 887 | return | 887 | return | ||
| 888 | } | 888 | } | ||
| 889 | - | fmt.Fprint(out, th.FG256(th.FG, string(answer))) | 889 | + | fmt.Fprint(out, th.FGColor(th.FG, string(answer))) |
| 890 | }) | 890 | }) | ||
| 891 | switch { | 891 | switch { | ||
| 892 | case errors.Is(err, errZotfileConsentInterrupted): | 892 | case errors.Is(err, errZotfileConsentInterrupted): | ||
| @@ -938,11 +938,11 @@ func permissionSummary(p tools.PermissionSet) string { | |||||
| 938 | } | 938 | } | ||
| 939 | 939 | ||||
| 940 | func permissionSummaryStyled(p tools.PermissionSet, th tui.Theme, color bool) string { | 940 | func permissionSummaryStyled(p tools.PermissionSet, th tui.Theme, color bool) string { | ||
| 941 | - | style := func(c int, text string) string { | 941 | + | style := func(c tui.TerminalColor, text string) string { |
| 942 | if !color { | 942 | if !color { | ||
| 943 | return text | 943 | return text | ||
| 944 | } | 944 | } | ||
| 945 | - | return th.FG256(c, text) | 945 | + | return th.FGColor(c, text) |
| 946 | } | 946 | } | ||
| 947 | permissionValue := func(value string) string { return style(th.FG, value) } | 947 | permissionValue := func(value string) string { return style(th.FG, value) } | ||
| 948 | 948 | ||||
M packages/agent/zotfile_test.go +4 -1
| @@ -329,7 +329,10 @@ func TestLoadRemoteZotfileDownloadsTemporaryGitHubArchive(t *testing.T) { | |||
| 329 | 329 | } | |
| 330 | 330 | ||
| 331 | 331 | func TestFormatZotfileConsentUsesThemeColors(t *testing.T) { | |
| 332 | - | th := tui.Theme{FG: 1, Muted: 2, Accent: 3, Assistant: 4, Warning: 5} | |
| 332 | + | th := tui.Theme{ | |
| 333 | + | FG: tui.Color256(1), Muted: tui.Color256(2), Accent: tui.Color256(3), | |
| 334 | + | Assistant: tui.Color256(4), Warning: tui.Color256(5), | |
| 335 | + | } | |
| 333 | 336 | zf := zotfileLoaded{} | |
| 334 | 337 | zf.Manifest.Name = "reviewer" | |
| 335 | 338 | zf.Manifest.Version = "1.2.3" | |
| @@ -329,7 +329,10 @@ func TestLoadRemoteZotfileDownloadsTemporaryGitHubArchive(t *testing.T) { | |||||
| 329 | } | 329 | } | ||
| 330 | 330 | ||||
| 331 | func TestFormatZotfileConsentUsesThemeColors(t *testing.T) { | 331 | func TestFormatZotfileConsentUsesThemeColors(t *testing.T) { | ||
| 332 | - | th := tui.Theme{FG: 1, Muted: 2, Accent: 3, Assistant: 4, Warning: 5} | 332 | + | th := tui.Theme{ |
| 333 | + | FG: tui.Color256(1), Muted: tui.Color256(2), Accent: tui.Color256(3), | |||
| 334 | + | Assistant: tui.Color256(4), Warning: tui.Color256(5), | |||
| 335 | + | } | |||
| 333 | zf := zotfileLoaded{} | 336 | zf := zotfileLoaded{} | ||
| 334 | zf.Manifest.Name = "reviewer" | 337 | zf.Manifest.Name = "reviewer" | ||
| 335 | zf.Manifest.Version = "1.2.3" | 338 | zf.Manifest.Version = "1.2.3" | ||
M packages/tui/highlight.go +2 -2
| @@ -38,7 +38,7 @@ func (th Theme) HighlightCode(src, lang string) []string { | |||
| 38 | 38 | } | |
| 39 | 39 | style := th.chromaStyle() | |
| 40 | 40 | formatterName := "terminal256" | |
| 41 | - | if th.ColorMode == ColorModeTrueColor { | |
| 41 | + | if th.Terminal.TrueColor { | |
| 42 | 42 | formatterName = "terminal16m" | |
| 43 | 43 | } | |
| 44 | 44 | formatter := formatters.Get(formatterName) | |
| @@ -243,7 +243,7 @@ func (th Theme) chromaStyle() *chroma.Style { | |||
| 243 | 243 | func (th Theme) syntaxKey() string { | |
| 244 | 244 | syn := th.Syntax | |
| 245 | 245 | outputMode := "256" | |
| 246 | - | if th.ColorMode == ColorModeTrueColor { | |
| 246 | + | if th.Terminal.TrueColor { | |
| 247 | 247 | outputMode = "truecolor" | |
| 248 | 248 | } | |
| 249 | 249 | return strings.Join([]string{ | |
| @@ -38,7 +38,7 @@ func (th Theme) HighlightCode(src, lang string) []string { | |||||
| 38 | } | 38 | } | ||
| 39 | style := th.chromaStyle() | 39 | style := th.chromaStyle() | ||
| 40 | formatterName := "terminal256" | 40 | formatterName := "terminal256" | ||
| 41 | - | if th.ColorMode == ColorModeTrueColor { | 41 | + | if th.Terminal.TrueColor { |
| 42 | formatterName = "terminal16m" | 42 | formatterName = "terminal16m" | ||
| 43 | } | 43 | } | ||
| 44 | formatter := formatters.Get(formatterName) | 44 | formatter := formatters.Get(formatterName) | ||
| @@ -243,7 +243,7 @@ func (th Theme) chromaStyle() *chroma.Style { | |||||
| 243 | func (th Theme) syntaxKey() string { | 243 | func (th Theme) syntaxKey() string { | ||
| 244 | syn := th.Syntax | 244 | syn := th.Syntax | ||
| 245 | outputMode := "256" | 245 | outputMode := "256" | ||
| 246 | - | if th.ColorMode == ColorModeTrueColor { | 246 | + | if th.Terminal.TrueColor { |
| 247 | outputMode = "truecolor" | 247 | outputMode = "truecolor" | ||
| 248 | } | 248 | } | ||
| 249 | return strings.Join([]string{ | 249 | return strings.Join([]string{ | ||
M packages/tui/layout.go +2 -2
| @@ -63,13 +63,13 @@ func InputBlock(th Theme, lines []string, width int) []string { | |||
| 63 | 63 | if bubbleW < 1 { | |
| 64 | 64 | bubbleW = 1 | |
| 65 | 65 | } | |
| 66 | + | bg := th.sgrBGColor(th.UserBubbleBG) | |
| 67 | + | bar := bg + th.FGColor(th.Accent, "▌ ") | |
| 66 | 68 | row := func(content string) string { | |
| 67 | 69 | visible := visibleWidth(content) | |
| 68 | 70 | if visible < bubbleW { | |
| 69 | 71 | content += strings.Repeat(" ", bubbleW-visible) | |
| 70 | 72 | } | |
| 71 | - | bg := th.sgrBGColor(th.UserBubbleBG) | |
| 72 | - | bar := bg + th.FG256(th.Accent, "▌ ") | |
| 73 | 73 | body := bg + strings.ReplaceAll(content, reset, reset+bg) + reset | |
| 74 | 74 | return bar + body | |
| 75 | 75 | } | |
| @@ -63,13 +63,13 @@ func InputBlock(th Theme, lines []string, width int) []string { | |||||
| 63 | if bubbleW < 1 { | 63 | if bubbleW < 1 { | ||
| 64 | bubbleW = 1 | 64 | bubbleW = 1 | ||
| 65 | } | 65 | } | ||
| 66 | + | bg := th.sgrBGColor(th.UserBubbleBG) | |||
| 67 | + | bar := bg + th.FGColor(th.Accent, "▌ ") | |||
| 66 | row := func(content string) string { | 68 | row := func(content string) string { | ||
| 67 | visible := visibleWidth(content) | 69 | visible := visibleWidth(content) | ||
| 68 | if visible < bubbleW { | 70 | if visible < bubbleW { | ||
| 69 | content += strings.Repeat(" ", bubbleW-visible) | 71 | content += strings.Repeat(" ", bubbleW-visible) | ||
| 70 | } | 72 | } | ||
| 71 | - | bg := th.sgrBGColor(th.UserBubbleBG) | |||
| 72 | - | bar := bg + th.FG256(th.Accent, "▌ ") | |||
| 73 | body := bg + strings.ReplaceAll(content, reset, reset+bg) + reset | 73 | body := bg + strings.ReplaceAll(content, reset, reset+bg) + reset | ||
| 74 | return bar + body | 74 | return bar + body | ||
| 75 | } | 75 | } | ||
M packages/tui/render.go +14 -13
| @@ -73,7 +73,8 @@ type Renderer struct { | |||
| 73 | 73 | // row-writing boundary. In particular, Theme.Background is painted | |
| 74 | 74 | // as a full-width row background without making every View renderer | |
| 75 | 75 | // know about terminal padding and reset semantics. | |
| 76 | - | theme Theme | |
| 76 | + | theme Theme | |
| 77 | + | backgroundStyle string | |
| 77 | 78 | } | |
| 78 | 79 | ||
| 79 | 80 | // NewRenderer returns a renderer that writes to out. | |
| @@ -94,6 +95,7 @@ func NewRenderer(out io.Writer) *Renderer { | |||
| 94 | 95 | // background affects every row, so cached frame state is invalidated. | |
| 95 | 96 | func (r *Renderer) SetTheme(th Theme) { | |
| 96 | 97 | r.theme = th | |
| 98 | + | r.backgroundStyle = th.BackgroundStyle() | |
| 97 | 99 | r.Invalidate() | |
| 98 | 100 | } | |
| 99 | 101 | ||
| @@ -224,16 +226,15 @@ func imageFootprintBlank(s string) bool { | |||
| 224 | 226 | // background reaches the right edge, and re-applies the background | |
| 225 | 227 | // after full SGR resets inside the row so local styling does not punch | |
| 226 | 228 | // transparent holes through the global tint. | |
| 227 | - | func paintBackgroundRow(line string, cols int, th Theme) string { | |
| 228 | - | bg := th.BackgroundStyle() | |
| 229 | - | if bg == "" || cols <= 0 || containsImageEscape(line) { | |
| 229 | + | func paintBackgroundRow(line string, cols int, background string) string { | |
| 230 | + | if background == "" || cols <= 0 || containsImageEscape(line) { | |
| 230 | 231 | return line | |
| 231 | 232 | } | |
| 232 | - | line = strings.ReplaceAll(line, reset, reset+bg) | |
| 233 | + | line = strings.ReplaceAll(line, reset, reset+background) | |
| 233 | 234 | if w := visibleWidth(line); w < cols { | |
| 234 | 235 | line += strings.Repeat(" ", cols-w) | |
| 235 | 236 | } | |
| 236 | - | return bg + line + reset | |
| 237 | + | return background + line + reset | |
| 237 | 238 | } | |
| 238 | 239 | ||
| 239 | 240 | // truncateToWidth clips s so its on-screen width doesn't exceed cols | |
| @@ -322,11 +323,11 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||
| 322 | 323 | frame[i] = "" | |
| 323 | 324 | } | |
| 324 | 325 | for i, line := range visible { | |
| 325 | - | frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme) | |
| 326 | + | frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) | |
| 326 | 327 | } | |
| 327 | 328 | if r.theme.Background != nil { | |
| 328 | 329 | for i := 0; i < top; i++ { | |
| 329 | - | frame[i] = paintBackgroundRow("", r.cols, r.theme) | |
| 330 | + | frame[i] = paintBackgroundRow("", r.cols, r.backgroundStyle) | |
| 330 | 331 | } | |
| 331 | 332 | } | |
| 332 | 333 | ||
| @@ -371,7 +372,7 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||
| 371 | 372 | // overwrites, leaving ghost highlights behind. | |
| 372 | 373 | hasSelection := false | |
| 373 | 374 | if r.theme.Background == nil { | |
| 374 | - | selectionBG := r.theme.bgPrefix(Color256(r.theme.SelectionBG)) | |
| 375 | + | selectionBG := r.theme.bgPrefix(r.theme.SelectionBG) | |
| 375 | 376 | for _, l := range frame { | |
| 376 | 377 | if selectionBG != "" && strings.Contains(l, selectionBG) { | |
| 377 | 378 | hasSelection = true | |
| @@ -431,11 +432,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 431 | 432 | } | |
| 432 | 433 | chatFrame := make([]string, len(chat)) | |
| 433 | 434 | for i, line := range chat { | |
| 434 | - | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme) | |
| 435 | + | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) | |
| 435 | 436 | } | |
| 436 | 437 | bottomFrame := make([]string, len(bottom)) | |
| 437 | 438 | for i, line := range bottom { | |
| 438 | - | bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme) | |
| 439 | + | bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) | |
| 439 | 440 | } | |
| 440 | 441 | ||
| 441 | 442 | // Always reserve one real row below the editor/status band. This is | |
| @@ -447,7 +448,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 447 | 448 | lines = append(lines, chatFrame...) | |
| 448 | 449 | lines = append(lines, bottomFrame...) | |
| 449 | 450 | for range bottomMarginRows { | |
| 450 | - | lines = append(lines, paintBackgroundRow("", r.cols, r.theme)) | |
| 451 | + | lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle)) | |
| 451 | 452 | } | |
| 452 | 453 | // In main-screen flow mode zot normally emits only its logical | |
| 453 | 454 | // content rows and leaves the rest of the terminal viewport alone. | |
| @@ -457,7 +458,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 457 | 458 | // default transparent case. | |
| 458 | 459 | if r.theme.Background != nil { | |
| 459 | 460 | for len(lines) < r.rows { | |
| 460 | - | lines = append(lines, paintBackgroundRow("", r.cols, r.theme)) | |
| 461 | + | lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle)) | |
| 461 | 462 | } | |
| 462 | 463 | } | |
| 463 | 464 | if len(lines) == 0 { | |
| @@ -73,7 +73,8 @@ type Renderer struct { | |||||
| 73 | // row-writing boundary. In particular, Theme.Background is painted | 73 | // row-writing boundary. In particular, Theme.Background is painted | ||
| 74 | // as a full-width row background without making every View renderer | 74 | // as a full-width row background without making every View renderer | ||
| 75 | // know about terminal padding and reset semantics. | 75 | // know about terminal padding and reset semantics. | ||
| 76 | - | theme Theme | 76 | + | theme Theme |
| 77 | + | backgroundStyle string | |||
| 77 | } | 78 | } | ||
| 78 | 79 | ||||
| 79 | // NewRenderer returns a renderer that writes to out. | 80 | // NewRenderer returns a renderer that writes to out. | ||
| @@ -94,6 +95,7 @@ func NewRenderer(out io.Writer) *Renderer { | |||||
| 94 | // background affects every row, so cached frame state is invalidated. | 95 | // background affects every row, so cached frame state is invalidated. | ||
| 95 | func (r *Renderer) SetTheme(th Theme) { | 96 | func (r *Renderer) SetTheme(th Theme) { | ||
| 96 | r.theme = th | 97 | r.theme = th | ||
| 98 | + | r.backgroundStyle = th.BackgroundStyle() | |||
| 97 | r.Invalidate() | 99 | r.Invalidate() | ||
| 98 | } | 100 | } | ||
| 99 | 101 | ||||
| @@ -224,16 +226,15 @@ func imageFootprintBlank(s string) bool { | |||||
| 224 | // background reaches the right edge, and re-applies the background | 226 | // background reaches the right edge, and re-applies the background | ||
| 225 | // after full SGR resets inside the row so local styling does not punch | 227 | // after full SGR resets inside the row so local styling does not punch | ||
| 226 | // transparent holes through the global tint. | 228 | // transparent holes through the global tint. | ||
| 227 | - | func paintBackgroundRow(line string, cols int, th Theme) string { | 229 | + | func paintBackgroundRow(line string, cols int, background string) string { |
| 228 | - | bg := th.BackgroundStyle() | 230 | + | if background == "" || cols <= 0 || containsImageEscape(line) { |
| 229 | - | if bg == "" || cols <= 0 || containsImageEscape(line) { | |||
| 230 | return line | 231 | return line | ||
| 231 | } | 232 | } | ||
| 232 | - | line = strings.ReplaceAll(line, reset, reset+bg) | 233 | + | line = strings.ReplaceAll(line, reset, reset+background) |
| 233 | if w := visibleWidth(line); w < cols { | 234 | if w := visibleWidth(line); w < cols { | ||
| 234 | line += strings.Repeat(" ", cols-w) | 235 | line += strings.Repeat(" ", cols-w) | ||
| 235 | } | 236 | } | ||
| 236 | - | return bg + line + reset | 237 | + | return background + line + reset |
| 237 | } | 238 | } | ||
| 238 | 239 | ||||
| 239 | // truncateToWidth clips s so its on-screen width doesn't exceed cols | 240 | // truncateToWidth clips s so its on-screen width doesn't exceed cols | ||
| @@ -322,11 +323,11 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||||
| 322 | frame[i] = "" | 323 | frame[i] = "" | ||
| 323 | } | 324 | } | ||
| 324 | for i, line := range visible { | 325 | for i, line := range visible { | ||
| 325 | - | frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme) | 326 | + | frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) |
| 326 | } | 327 | } | ||
| 327 | if r.theme.Background != nil { | 328 | if r.theme.Background != nil { | ||
| 328 | for i := 0; i < top; i++ { | 329 | for i := 0; i < top; i++ { | ||
| 329 | - | frame[i] = paintBackgroundRow("", r.cols, r.theme) | 330 | + | frame[i] = paintBackgroundRow("", r.cols, r.backgroundStyle) |
| 330 | } | 331 | } | ||
| 331 | } | 332 | } | ||
| 332 | 333 | ||||
| @@ -371,7 +372,7 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||||
| 371 | // overwrites, leaving ghost highlights behind. | 372 | // overwrites, leaving ghost highlights behind. | ||
| 372 | hasSelection := false | 373 | hasSelection := false | ||
| 373 | if r.theme.Background == nil { | 374 | if r.theme.Background == nil { | ||
| 374 | - | selectionBG := r.theme.bgPrefix(Color256(r.theme.SelectionBG)) | 375 | + | selectionBG := r.theme.bgPrefix(r.theme.SelectionBG) |
| 375 | for _, l := range frame { | 376 | for _, l := range frame { | ||
| 376 | if selectionBG != "" && strings.Contains(l, selectionBG) { | 377 | if selectionBG != "" && strings.Contains(l, selectionBG) { | ||
| 377 | hasSelection = true | 378 | hasSelection = true | ||
| @@ -431,11 +432,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 431 | } | 432 | } | ||
| 432 | chatFrame := make([]string, len(chat)) | 433 | chatFrame := make([]string, len(chat)) | ||
| 433 | for i, line := range chat { | 434 | for i, line := range chat { | ||
| 434 | - | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme) | 435 | + | chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) |
| 435 | } | 436 | } | ||
| 436 | bottomFrame := make([]string, len(bottom)) | 437 | bottomFrame := make([]string, len(bottom)) | ||
| 437 | for i, line := range bottom { | 438 | for i, line := range bottom { | ||
| 438 | - | bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme) | 439 | + | bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle) |
| 439 | } | 440 | } | ||
| 440 | 441 | ||||
| 441 | // Always reserve one real row below the editor/status band. This is | 442 | // Always reserve one real row below the editor/status band. This is | ||
| @@ -447,7 +448,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 447 | lines = append(lines, chatFrame...) | 448 | lines = append(lines, chatFrame...) | ||
| 448 | lines = append(lines, bottomFrame...) | 449 | lines = append(lines, bottomFrame...) | ||
| 449 | for range bottomMarginRows { | 450 | for range bottomMarginRows { | ||
| 450 | - | lines = append(lines, paintBackgroundRow("", r.cols, r.theme)) | 451 | + | lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle)) |
| 451 | } | 452 | } | ||
| 452 | // In main-screen flow mode zot normally emits only its logical | 453 | // In main-screen flow mode zot normally emits only its logical | ||
| 453 | // content rows and leaves the rest of the terminal viewport alone. | 454 | // content rows and leaves the rest of the terminal viewport alone. | ||
| @@ -457,7 +458,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||||
| 457 | // default transparent case. | 458 | // default transparent case. | ||
| 458 | if r.theme.Background != nil { | 459 | if r.theme.Background != nil { | ||
| 459 | for len(lines) < r.rows { | 460 | for len(lines) < r.rows { | ||
| 460 | - | lines = append(lines, paintBackgroundRow("", r.cols, r.theme)) | 461 | + | lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle)) |
| 461 | } | 462 | } | ||
| 462 | } | 463 | } | ||
| 463 | if len(lines) == 0 { | 464 | if len(lines) == 0 { | ||
M packages/tui/rightbar.go +19 -19
| @@ -88,37 +88,37 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||
| 88 | 88 | } | |
| 89 | 89 | return text | |
| 90 | 90 | } | |
| 91 | - | paintLine := func(text string, color int, dim, phase bool) string { | |
| 91 | + | paintLine := func(text string, color *TerminalColor, dim, phase bool) string { | |
| 92 | 92 | text = padLine(text, phase) | |
| 93 | - | if color < 0 { | |
| 93 | + | if color == nil { | |
| 94 | 94 | return text | |
| 95 | 95 | } | |
| 96 | - | paint := func(value string, valueColor int) string { | |
| 96 | + | paint := func(value string, valueColor TerminalColor) string { | |
| 97 | 97 | if dim { | |
| 98 | 98 | value = Dim(value) | |
| 99 | 99 | } | |
| 100 | - | return th.FG256(valueColor, value) | |
| 100 | + | return th.FGColor(valueColor, value) | |
| 101 | 101 | } | |
| 102 | 102 | if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok { | |
| 103 | 103 | if phase { | |
| 104 | 104 | if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok { | |
| 105 | - | return paint(text[:markerStart], color) + | |
| 105 | + | return paint(text[:markerStart], *color) + | |
| 106 | 106 | paint(text[markerStart:markerEnd], th.Muted) + | |
| 107 | - | paint(text[markerEnd:nameStart], color) + | |
| 108 | - | paint(Bold(text[nameStart:nameEnd]), color) + | |
| 109 | - | paint(text[nameEnd:], color) | |
| 107 | + | paint(text[markerEnd:nameStart], *color) + | |
| 108 | + | paint(Bold(text[nameStart:nameEnd]), *color) + | |
| 109 | + | paint(text[nameEnd:], *color) | |
| 110 | 110 | } | |
| 111 | 111 | } | |
| 112 | - | return paint(text[:markerStart], color) + | |
| 112 | + | return paint(text[:markerStart], *color) + | |
| 113 | 113 | paint(text[markerStart:markerEnd], th.Muted) + | |
| 114 | - | paint(text[markerEnd:], color) | |
| 114 | + | paint(text[markerEnd:], *color) | |
| 115 | 115 | } | |
| 116 | 116 | if phase { | |
| 117 | - | return paint(Bold(text), color) | |
| 117 | + | return paint(Bold(text), *color) | |
| 118 | 118 | } | |
| 119 | - | return paint(text, color) | |
| 119 | + | return paint(text, *color) | |
| 120 | 120 | } | |
| 121 | - | appendContent := func(text string, color int, dim, phase bool) bool { | |
| 121 | + | appendContent := func(text string, color *TerminalColor, dim, phase bool) bool { | |
| 122 | 122 | text = strings.ReplaceAll(text, "\r", "") | |
| 123 | 123 | for _, line := range strings.Split(text, "\n") { | |
| 124 | 124 | if len(content) >= height { | |
| @@ -131,17 +131,17 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||
| 131 | 131 | } | |
| 132 | 132 | ||
| 133 | 133 | for idx, widget := range ordered { | |
| 134 | - | if idx > 0 && !appendContent("", -1, false, false) { | |
| 134 | + | if idx > 0 && !appendContent("", nil, false, false) { | |
| 135 | 135 | break | |
| 136 | 136 | } | |
| 137 | 137 | label := "[" + widget.Extension + "]" | |
| 138 | 138 | if widget.Title != "" { | |
| 139 | 139 | label += " " + widget.Title | |
| 140 | 140 | } | |
| 141 | - | if !appendContent(label, th.Accent, false, false) { | |
| 141 | + | if !appendContent(label, &th.Accent, false, false) { | |
| 142 | 142 | break | |
| 143 | 143 | } | |
| 144 | - | if !appendContent("", -1, false, false) { | |
| 144 | + | if !appendContent("", nil, false, false) { | |
| 145 | 145 | break | |
| 146 | 146 | } | |
| 147 | 147 | activePhaseIndent := rightBarActivePhaseIndent(widget.Lines) | |
| @@ -157,7 +157,7 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||
| 157 | 157 | line = " " + line | |
| 158 | 158 | dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase) | |
| 159 | 159 | phase := rightBarChecklistPhaseLine(line, phaseIndent) | |
| 160 | - | if !appendContent(line, th.FG, dim, phase) { | |
| 160 | + | if !appendContent(line, &th.FG, dim, phase) { | |
| 161 | 161 | break | |
| 162 | 162 | } | |
| 163 | 163 | } | |
| @@ -166,7 +166,7 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||
| 166 | 166 | } | |
| 167 | 167 | } | |
| 168 | 168 | if truncated { | |
| 169 | - | marker := paintLine("... right bar ...", th.Muted, false, false) | |
| 169 | + | marker := paintLine("... right bar ...", &th.Muted, false, false) | |
| 170 | 170 | if len(content) == height { | |
| 171 | 171 | content[len(content)-1] = marker | |
| 172 | 172 | } else { | |
| @@ -350,7 +350,7 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) | |||
| 350 | 350 | if visible := visibleWidth(rightBar); visible < rightBarWidth { | |
| 351 | 351 | rightBar += strings.Repeat(" ", rightBarWidth-visible) | |
| 352 | 352 | } | |
| 353 | - | return main + th.FG256(th.Muted, "│") + rightBar | |
| 353 | + | return main + th.FGColor(th.Muted, "│") + rightBar | |
| 354 | 354 | } | |
| 355 | 355 | ||
| 356 | 356 | // DrawRightBar renders an already-laid-out visible chat slice and sticky | |
| @@ -88,37 +88,37 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||||
| 88 | } | 88 | } | ||
| 89 | return text | 89 | return text | ||
| 90 | } | 90 | } | ||
| 91 | - | paintLine := func(text string, color int, dim, phase bool) string { | 91 | + | paintLine := func(text string, color *TerminalColor, dim, phase bool) string { |
| 92 | text = padLine(text, phase) | 92 | text = padLine(text, phase) | ||
| 93 | - | if color < 0 { | 93 | + | if color == nil { |
| 94 | return text | 94 | return text | ||
| 95 | } | 95 | } | ||
| 96 | - | paint := func(value string, valueColor int) string { | 96 | + | paint := func(value string, valueColor TerminalColor) string { |
| 97 | if dim { | 97 | if dim { | ||
| 98 | value = Dim(value) | 98 | value = Dim(value) | ||
| 99 | } | 99 | } | ||
| 100 | - | return th.FG256(valueColor, value) | 100 | + | return th.FGColor(valueColor, value) |
| 101 | } | 101 | } | ||
| 102 | if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok { | 102 | if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok { | ||
| 103 | if phase { | 103 | if phase { | ||
| 104 | if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok { | 104 | if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok { | ||
| 105 | - | return paint(text[:markerStart], color) + | 105 | + | return paint(text[:markerStart], *color) + |
| 106 | paint(text[markerStart:markerEnd], th.Muted) + | 106 | paint(text[markerStart:markerEnd], th.Muted) + | ||
| 107 | - | paint(text[markerEnd:nameStart], color) + | 107 | + | paint(text[markerEnd:nameStart], *color) + |
| 108 | - | paint(Bold(text[nameStart:nameEnd]), color) + | 108 | + | paint(Bold(text[nameStart:nameEnd]), *color) + |
| 109 | - | paint(text[nameEnd:], color) | 109 | + | paint(text[nameEnd:], *color) |
| 110 | } | 110 | } | ||
| 111 | } | 111 | } | ||
| 112 | - | return paint(text[:markerStart], color) + | 112 | + | return paint(text[:markerStart], *color) + |
| 113 | paint(text[markerStart:markerEnd], th.Muted) + | 113 | paint(text[markerStart:markerEnd], th.Muted) + | ||
| 114 | - | paint(text[markerEnd:], color) | 114 | + | paint(text[markerEnd:], *color) |
| 115 | } | 115 | } | ||
| 116 | if phase { | 116 | if phase { | ||
| 117 | - | return paint(Bold(text), color) | 117 | + | return paint(Bold(text), *color) |
| 118 | } | 118 | } | ||
| 119 | - | return paint(text, color) | 119 | + | return paint(text, *color) |
| 120 | } | 120 | } | ||
| 121 | - | appendContent := func(text string, color int, dim, phase bool) bool { | 121 | + | appendContent := func(text string, color *TerminalColor, dim, phase bool) bool { |
| 122 | text = strings.ReplaceAll(text, "\r", "") | 122 | text = strings.ReplaceAll(text, "\r", "") | ||
| 123 | for _, line := range strings.Split(text, "\n") { | 123 | for _, line := range strings.Split(text, "\n") { | ||
| 124 | if len(content) >= height { | 124 | if len(content) >= height { | ||
| @@ -131,17 +131,17 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||||
| 131 | } | 131 | } | ||
| 132 | 132 | ||||
| 133 | for idx, widget := range ordered { | 133 | for idx, widget := range ordered { | ||
| 134 | - | if idx > 0 && !appendContent("", -1, false, false) { | 134 | + | if idx > 0 && !appendContent("", nil, false, false) { |
| 135 | break | 135 | break | ||
| 136 | } | 136 | } | ||
| 137 | label := "[" + widget.Extension + "]" | 137 | label := "[" + widget.Extension + "]" | ||
| 138 | if widget.Title != "" { | 138 | if widget.Title != "" { | ||
| 139 | label += " " + widget.Title | 139 | label += " " + widget.Title | ||
| 140 | } | 140 | } | ||
| 141 | - | if !appendContent(label, th.Accent, false, false) { | 141 | + | if !appendContent(label, &th.Accent, false, false) { |
| 142 | break | 142 | break | ||
| 143 | } | 143 | } | ||
| 144 | - | if !appendContent("", -1, false, false) { | 144 | + | if !appendContent("", nil, false, false) { |
| 145 | break | 145 | break | ||
| 146 | } | 146 | } | ||
| 147 | activePhaseIndent := rightBarActivePhaseIndent(widget.Lines) | 147 | activePhaseIndent := rightBarActivePhaseIndent(widget.Lines) | ||
| @@ -157,7 +157,7 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||||
| 157 | line = " " + line | 157 | line = " " + line | ||
| 158 | dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase) | 158 | dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase) | ||
| 159 | phase := rightBarChecklistPhaseLine(line, phaseIndent) | 159 | phase := rightBarChecklistPhaseLine(line, phaseIndent) | ||
| 160 | - | if !appendContent(line, th.FG, dim, phase) { | 160 | + | if !appendContent(line, &th.FG, dim, phase) { |
| 161 | break | 161 | break | ||
| 162 | } | 162 | } | ||
| 163 | } | 163 | } | ||
| @@ -166,7 +166,7 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str | |||||
| 166 | } | 166 | } | ||
| 167 | } | 167 | } | ||
| 168 | if truncated { | 168 | if truncated { | ||
| 169 | - | marker := paintLine("... right bar ...", th.Muted, false, false) | 169 | + | marker := paintLine("... right bar ...", &th.Muted, false, false) |
| 170 | if len(content) == height { | 170 | if len(content) == height { | ||
| 171 | content[len(content)-1] = marker | 171 | content[len(content)-1] = marker | ||
| 172 | } else { | 172 | } else { | ||
| @@ -350,7 +350,7 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int) | |||||
| 350 | if visible := visibleWidth(rightBar); visible < rightBarWidth { | 350 | if visible := visibleWidth(rightBar); visible < rightBarWidth { | ||
| 351 | rightBar += strings.Repeat(" ", rightBarWidth-visible) | 351 | rightBar += strings.Repeat(" ", rightBarWidth-visible) | ||
| 352 | } | 352 | } | ||
| 353 | - | return main + th.FG256(th.Muted, "│") + rightBar | 353 | + | return main + th.FGColor(th.Muted, "│") + rightBar |
| 354 | } | 354 | } | ||
| 355 | 355 | ||||
| 356 | // DrawRightBar renders an already-laid-out visible chat slice and sticky | 356 | // DrawRightBar renders an already-laid-out visible chat slice and sticky | ||
M packages/tui/statusbar_test.go +1 -1
| @@ -105,7 +105,7 @@ func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) { | |||
| 105 | 105 | ||
| 106 | 106 | func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) { | |
| 107 | 107 | th := Dark | |
| 108 | - | th.ThinkingMax = 201 | |
| 108 | + | th.ThinkingMax = Color256(201) | |
| 109 | 109 | lines := StatusBar(StatusBarParams{ | |
| 110 | 110 | Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200, | |
| 111 | 111 | }) | |
| @@ -105,7 +105,7 @@ func TestStatusBarReasoningLevelBetweenModelAndStats(t *testing.T) { | |||||
| 105 | 105 | ||||
| 106 | func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) { | 106 | func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) { | ||
| 107 | th := Dark | 107 | th := Dark | ||
| 108 | - | th.ThinkingMax = 201 | 108 | + | th.ThinkingMax = Color256(201) |
| 109 | lines := StatusBar(StatusBarParams{ | 109 | lines := StatusBar(StatusBarParams{ | ||
| 110 | Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200, | 110 | Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200, | ||
| 111 | }) | 111 | }) | ||
M packages/tui/theme.go +144 -112
| @@ -3,14 +3,14 @@ | |||
| 3 | 3 | // framework; just ANSI escape codes. | |
| 4 | 4 | package tui | |
| 5 | 5 | ||
| 6 | - | import "strings" | |
| 7 | - | ||
| 8 | - | // TerminalColor describes a terminal colour in one of the colour | |
| 9 | - | // spaces terminals commonly support. Most of zot's theme still uses | |
| 10 | - | // xterm-256 indexes, but user-bubble backgrounds can also use ANSI | |
| 11 | - | // theme slots (for example SGR 100 / bright-black background) so they | |
| 12 | - | // match the user's terminal theme rather than the fixed 256-colour | |
| 13 | - | // cube. | |
| 6 | + | import ( | |
| 7 | + | "strings" | |
| 8 | + | "sync/atomic" | |
| 9 | + | ) | |
| 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. | |
| 14 | 14 | type TerminalColor struct { | |
| 15 | 15 | Mode terminalColorMode | |
| 16 | 16 | Index int | |
| @@ -33,14 +33,6 @@ func ColorRGB(r, g, b int) TerminalColor { | |||
| 33 | 33 | return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b} | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | - | // ColorMode is the output color depth used by an inherited theme. | |
| 37 | - | type ColorMode uint8 | |
| 38 | - | ||
| 39 | - | const ( | |
| 40 | - | ColorMode256 ColorMode = iota | |
| 41 | - | ColorModeTrueColor | |
| 42 | - | ) | |
| 43 | - | ||
| 44 | 36 | // TerminalProfile is the best-effort color snapshot reported by the | |
| 45 | 37 | // controlling terminal. The ANSI palette is optional; unknown slots are | |
| 46 | 38 | // resolved through the classic xterm-256 palette. | |
| @@ -66,31 +58,31 @@ func (p TerminalProfile) PaletteColor(index int) (TerminalColor, bool) { | |||
| 66 | 58 | return p.Palette[index], true | |
| 67 | 59 | } | |
| 68 | 60 | ||
| 69 | - | // ANSI 256-color palette used by zot. Defined as numeric codes so we | |
| 70 | - | // can swap themes without changing any render code. | |
| 61 | + | // Semantic palette used by zot. Each role is a TerminalColor so themes can | |
| 62 | + | // use indexed, ANSI, or RGB values without changing render code. | |
| 71 | 63 | type Theme struct { | |
| 72 | - | FG int | |
| 73 | - | Muted int | |
| 74 | - | Accent int | |
| 64 | + | FG TerminalColor | |
| 65 | + | Muted TerminalColor | |
| 66 | + | Accent TerminalColor | |
| 75 | 67 | Background *TerminalColor // optional full-row TUI background; nil keeps terminal default | |
| 76 | - | User int // label color for the user role | |
| 68 | + | User TerminalColor // label color for the user role | |
| 77 | 69 | UserBubbleBG TerminalColor // background tint behind user message rows | |
| 78 | - | UserBubbleFG int // foreground colour for user message rows | |
| 79 | - | Assistant int // label color for the zot role | |
| 80 | - | Tool int | |
| 81 | - | ToolOut int | |
| 82 | - | Error int | |
| 83 | - | Warning int | |
| 84 | - | Spinner int // spinner + funny working line | |
| 85 | - | ThinkingMax int // status color for the opt-in max reasoning level | |
| 86 | - | SelectionBG int // background for highlighted rows | |
| 87 | - | SelectionFG int // foreground for highlighted rows | |
| 88 | - | ||
| 89 | - | // Inherited selects terminal-owned colors and uses ColorMode to choose | |
| 90 | - | // truecolor or a best-effort xterm-256 fallback. Explicit zot themes keep | |
| 91 | - | // the historical indexed output because their values are already fixed. | |
| 70 | + | UserBubbleFG TerminalColor // foreground colour for user message rows | |
| 71 | + | Assistant TerminalColor // label color for the zot role | |
| 72 | + | Tool TerminalColor | |
| 73 | + | ToolOut TerminalColor | |
| 74 | + | Error TerminalColor | |
| 75 | + | Warning TerminalColor | |
| 76 | + | Spinner TerminalColor // spinner + funny working line | |
| 77 | + | ThinkingMax TerminalColor // status color for the opt-in max reasoning level | |
| 78 | + | SelectionBG TerminalColor // background for highlighted rows | |
| 79 | + | SelectionFG TerminalColor // foreground for highlighted rows | |
| 80 | + | ||
| 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. | |
| 92 | 85 | Inherited bool | |
| 93 | - | ColorMode ColorMode | |
| 94 | 86 | Terminal TerminalProfile | |
| 95 | 87 | ||
| 96 | 88 | SpinnerFrames []string | |
| @@ -188,21 +180,21 @@ var defaultSpinnerMessages = []string{ | |||
| 188 | 180 | } | |
| 189 | 181 | ||
| 190 | 182 | var Dark = Theme{ | |
| 191 | - | FG: 253, | |
| 192 | - | Muted: 244, | |
| 193 | - | Accent: 111, // soft blue | |
| 194 | - | User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat) | |
| 183 | + | FG: Color256(253), | |
| 184 | + | Muted: Color256(244), | |
| 185 | + | Accent: Color256(111), // soft blue | |
| 186 | + | User: Color256(180), // warm tan (unused now that the speaker label is gone, kept for skin compat) | |
| 195 | 187 | UserBubbleBG: ColorRGB(66, 69, 75), // #42454B | |
| 196 | - | UserBubbleFG: 248, // slightly lighter grey for readability on #42454B | |
| 197 | - | Assistant: 117, // bright cyan — the zot label color | |
| 198 | - | Tool: 114, // green | |
| 199 | - | ToolOut: 245, | |
| 200 | - | Error: 203, | |
| 201 | - | Warning: 214, | |
| 202 | - | Spinner: 183, // soft purple | |
| 203 | - | ThinkingMax: 207, // vivid magenta | |
| 204 | - | SelectionBG: 24, // deep blue background | |
| 205 | - | SelectionFG: 231, // near-white foreground | |
| 188 | + | UserBubbleFG: Color256(248), // slightly lighter grey for readability on #42454B | |
| 189 | + | Assistant: Color256(117), // bright cyan — the zot label color | |
| 190 | + | Tool: Color256(114), // green | |
| 191 | + | ToolOut: Color256(245), | |
| 192 | + | Error: Color256(203), | |
| 193 | + | Warning: Color256(214), | |
| 194 | + | Spinner: Color256(183), // soft purple | |
| 195 | + | ThinkingMax: Color256(207), // vivid magenta | |
| 196 | + | SelectionBG: Color256(24), // deep blue background | |
| 197 | + | SelectionFG: Color256(231), // near-white foreground | |
| 206 | 198 | SpinnerFrames: defaultSpinnerFrames, | |
| 207 | 199 | SpinnerMessages: defaultSpinnerMessages, | |
| 208 | 200 | SpinnerIntervalMS: 80, | |
| @@ -211,21 +203,21 @@ var Dark = Theme{ | |||
| 211 | 203 | } | |
| 212 | 204 | ||
| 213 | 205 | var Light = Theme{ | |
| 214 | - | FG: 236, | |
| 215 | - | Muted: 244, | |
| 216 | - | Accent: 33, | |
| 217 | - | User: 94, | |
| 206 | + | FG: Color256(236), | |
| 207 | + | Muted: Color256(244), | |
| 208 | + | Accent: Color256(33), | |
| 209 | + | User: Color256(94), | |
| 218 | 210 | UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme | |
| 219 | - | UserBubbleFG: 240, // dark grey text, legible on the pale panel | |
| 220 | - | Assistant: 31, // deep cyan | |
| 221 | - | Tool: 28, | |
| 222 | - | ToolOut: 240, | |
| 223 | - | Error: 160, | |
| 224 | - | Warning: 166, | |
| 225 | - | Spinner: 91, // purple | |
| 226 | - | ThinkingMax: 127, // deep magenta | |
| 227 | - | SelectionBG: 153, // light blue | |
| 228 | - | SelectionFG: 232, // near-black | |
| 211 | + | UserBubbleFG: Color256(240), // dark grey text, legible on the pale panel | |
| 212 | + | Assistant: Color256(31), // deep cyan | |
| 213 | + | Tool: Color256(28), | |
| 214 | + | ToolOut: Color256(240), | |
| 215 | + | Error: Color256(160), | |
| 216 | + | Warning: Color256(166), | |
| 217 | + | Spinner: Color256(91), // purple | |
| 218 | + | ThinkingMax: Color256(127), // deep magenta | |
| 219 | + | SelectionBG: Color256(153), // light blue | |
| 220 | + | SelectionFG: Color256(232), // near-black | |
| 229 | 221 | SpinnerFrames: defaultSpinnerFrames, | |
| 230 | 222 | SpinnerMessages: defaultSpinnerMessages, | |
| 231 | 223 | SpinnerIntervalMS: 80, | |
| @@ -233,43 +225,48 @@ var Light = Theme{ | |||
| 233 | 225 | Syntax: nordSyntax, | |
| 234 | 226 | } | |
| 235 | 227 | ||
| 236 | - | // FG256 wraps s in a foreground color. Explicit themes retain the | |
| 237 | - | // historical ANSI 256-color sequence; inherited themes resolve terminal | |
| 238 | - | // palette entries and emit truecolor when the terminal advertises it. | |
| 239 | - | func (t Theme) FG256(c int, s string) string { | |
| 228 | + | // FGColor wraps s in a foreground color. The name is intentionally explicit | |
| 229 | + | // because the color may be xterm-256, ANSI, or RGB. | |
| 230 | + | func (t Theme) FGColor(c TerminalColor, s string) string { | |
| 240 | 231 | return t.fgPrefix(c) + s + reset | |
| 241 | 232 | } | |
| 242 | 233 | ||
| 243 | - | // BG256 wraps s in a background color using the theme's output capability. | |
| 244 | - | // Useful when the visible cell needs a coloured fill but the underlying | |
| 245 | - | // character should be a regular space (so mouse-copy yields whitespace). | |
| 246 | - | func (t Theme) BG256(c int, s string) string { | |
| 247 | - | return t.bgPrefix(Color256(c)) + s + reset | |
| 234 | + | // FG256 retains the historical helper name while accepting the full | |
| 235 | + | // TerminalColor model. | |
| 236 | + | // | |
| 237 | + | // Deprecated: use FGColor. | |
| 238 | + | func (t Theme) FG256(c TerminalColor, s string) string { | |
| 239 | + | return t.FGColor(c, s) | |
| 248 | 240 | } | |
| 249 | 241 | ||
| 250 | - | // BG wraps s in a terminal background color. Inherited themes quantize RGB | |
| 251 | - | // values on terminals without truecolor support. | |
| 242 | + | // BG256 retains the historical helper name while accepting the full | |
| 243 | + | // TerminalColor model. | |
| 244 | + | // | |
| 245 | + | // Deprecated: use BG. | |
| 246 | + | func (t Theme) BG256(c TerminalColor, s string) string { | |
| 247 | + | return t.bgPrefix(c) + s + reset | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // BG wraps s in a terminal background color. RGB values are quantized when | |
| 251 | + | // the active terminal does not advertise truecolor support. | |
| 252 | 252 | func (t Theme) BG(c TerminalColor, s string) string { | |
| 253 | 253 | return t.bgPrefix(c) + s + reset | |
| 254 | 254 | } | |
| 255 | 255 | ||
| 256 | - | func (t Theme) fgPrefix(index int) string { | |
| 257 | - | if t.Inherited && index == t.FG && t.Terminal.HasForeground { | |
| 258 | - | return sgrFGColor(t.resolveColor(t.Terminal.Foreground)) | |
| 259 | - | } | |
| 260 | - | return sgrFGColor(t.resolveColor(Color256(index))) | |
| 256 | + | func (t Theme) fgPrefix(color TerminalColor) string { | |
| 257 | + | return sgrFGColor(t.resolveColor(color)) | |
| 261 | 258 | } | |
| 262 | 259 | ||
| 263 | 260 | func (t Theme) bgPrefix(color TerminalColor) string { | |
| 264 | 261 | return sgrBGColor(t.resolveColor(color)) | |
| 265 | 262 | } | |
| 266 | 263 | ||
| 267 | - | // DimColor fades an indexed color toward the terminal background. It is used | |
| 268 | - | // by inherited theme construction for muted surfaces and is also available to | |
| 269 | - | // callers that need terminal-aware dimming for overlays. | |
| 270 | - | func (t Theme) DimColor(index, percent int) TerminalColor { | |
| 264 | + | // DimColor fades a theme color toward the terminal background. It is used | |
| 265 | + | // by inherited theme construction and is also available to callers that need | |
| 266 | + | // terminal-aware dimming for overlays. | |
| 267 | + | func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor { | |
| 271 | 268 | percent = clampPercent(percent) | |
| 272 | - | foreground := t.resolveForeground(index) | |
| 269 | + | foreground := t.resolveColor(color) | |
| 273 | 270 | background := t.Terminal.Background | |
| 274 | 271 | if !t.Terminal.HasBackground { | |
| 275 | 272 | background = Color256(0) | |
| @@ -283,36 +280,32 @@ func (t Theme) DimColor(index, percent int) TerminalColor { | |||
| 283 | 280 | )) | |
| 284 | 281 | } | |
| 285 | 282 | ||
| 286 | - | func (t Theme) resolveForeground(index int) TerminalColor { | |
| 287 | - | if t.Inherited && index == t.FG && t.Terminal.HasForeground { | |
| 288 | - | return t.resolveColor(t.Terminal.Foreground) | |
| 289 | - | } | |
| 290 | - | return t.resolveColor(Color256(index)) | |
| 291 | - | } | |
| 292 | - | ||
| 293 | 283 | func (t Theme) resolveColor(color TerminalColor) TerminalColor { | |
| 294 | 284 | switch color.Mode { | |
| 295 | 285 | case terminalColor256: | |
| 296 | 286 | index := clampXtermIndex(color.Index) | |
| 297 | - | if t.Inherited { | |
| 298 | - | if palette, ok := t.Terminal.PaletteColor(index); ok { | |
| 299 | - | if t.ColorMode == ColorModeTrueColor { | |
| 287 | + | if t.Terminal.TrueColor { | |
| 288 | + | if t.Inherited { | |
| 289 | + | if palette, ok := t.Terminal.PaletteColor(index); ok { | |
| 300 | 290 | return palette | |
| 301 | 291 | } | |
| 302 | - | return Color256(index) | |
| 303 | - | } | |
| 304 | - | if t.ColorMode == ColorModeTrueColor { | |
| 305 | - | r, g, b := xterm256RGB(index) | |
| 306 | - | return ColorRGB(r, g, b) | |
| 307 | 292 | } | |
| 308 | - | } | |
| 309 | - | if t.ColorMode == ColorModeTrueColor { | |
| 310 | 293 | r, g, b := xterm256RGB(index) | |
| 311 | 294 | return ColorRGB(r, g, b) | |
| 312 | 295 | } | |
| 313 | 296 | return Color256(index) | |
| 297 | + | case terminalColorANSI: | |
| 298 | + | if t.Inherited && t.Terminal.TrueColor { | |
| 299 | + | if index, ok := ansiSGRToXtermIndex(color.Index); ok { | |
| 300 | + | if palette, ok := t.Terminal.PaletteColor(index); ok { | |
| 301 | + | return palette | |
| 302 | + | } | |
| 303 | + | r, g, b := xterm256RGB(index) | |
| 304 | + | return ColorRGB(r, g, b) | |
| 305 | + | } | |
| 306 | + | } | |
| 314 | 307 | case terminalColorRGB: | |
| 315 | - | if t.Inherited && t.ColorMode == ColorMode256 { | |
| 308 | + | if !t.Terminal.TrueColor { | |
| 316 | 309 | return Color256(nearestXtermColor(color.R, color.G, color.B)) | |
| 317 | 310 | } | |
| 318 | 311 | } | |
| @@ -322,7 +315,7 @@ func (t Theme) resolveColor(color TerminalColor) TerminalColor { | |||
| 322 | 315 | func sgrFGColor(color TerminalColor) string { | |
| 323 | 316 | switch color.Mode { | |
| 324 | 317 | case terminalColorANSI: | |
| 325 | - | return "\x1b[" + itoa(color.Index) + "m" | |
| 318 | + | return "\x1b[" + itoa(ansiForegroundSGR(color.Index)) + "m" | |
| 326 | 319 | case terminalColorRGB: | |
| 327 | 320 | return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m" | |
| 328 | 321 | default: | |
| @@ -338,8 +331,8 @@ func (t Theme) sgrBGColor(color TerminalColor) string { | |||
| 338 | 331 | // glyph followed by a plain space gutter. Used as the speaker-label | |
| 339 | 332 | // prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so | |
| 340 | 333 | // the bar reads consistently across the UI. | |
| 341 | - | func (t Theme) AccentBar(c int) string { | |
| 342 | - | return t.FG256(c, "▌ ") | |
| 334 | + | func (t Theme) AccentBar(c TerminalColor) string { | |
| 335 | + | return t.FGColor(c, "▌ ") | |
| 343 | 336 | } | |
| 344 | 337 | ||
| 345 | 338 | // Highlight paints s with the theme's selection colors (foreground + | |
| @@ -351,7 +344,7 @@ func (t Theme) Highlight(s string) string { | |||
| 351 | 344 | ||
| 352 | 345 | // SelectionStyle returns the SGR prefix for the theme's selected row. | |
| 353 | 346 | func (t Theme) SelectionStyle() string { | |
| 354 | - | return t.fgPrefix(t.SelectionFG) + t.bgPrefix(Color256(t.SelectionBG)) | |
| 347 | + | return t.fgPrefix(t.SelectionFG) + t.bgPrefix(t.SelectionBG) | |
| 355 | 348 | } | |
| 356 | 349 | ||
| 357 | 350 | // BackgroundStyle returns the SGR prefix for the optional full-row | |
| @@ -367,8 +360,8 @@ func (t Theme) BackgroundStyle() string { | |||
| 367 | 360 | // SelectionStyleFG returns the SGR prefix for selected-row text with | |
| 368 | 361 | // a custom foreground. Useful for preserving semantic marks inside a | |
| 369 | 362 | // highlighted row without hardcoding escape sequences outside theme.go. | |
| 370 | - | func (t Theme) SelectionStyleFG(fg int) string { | |
| 371 | - | return t.fgPrefix(fg) + t.bgPrefix(Color256(t.SelectionBG)) | |
| 363 | + | func (t Theme) SelectionStyleFG(fg TerminalColor) string { | |
| 364 | + | return t.fgPrefix(fg) + t.bgPrefix(t.SelectionBG) | |
| 372 | 365 | } | |
| 373 | 366 | ||
| 374 | 367 | // PadHighlight styles s and extends the selection background to the | |
| @@ -404,7 +397,7 @@ func (t Theme) UserBubbleRow(content string, width int) string { | |||
| 404 | 397 | // Bar plus a single space gutter, in the assistant accent colour | |
| 405 | 398 | // so it matches the tool-box / app accent and reads as zot's voice | |
| 406 | 399 | // marker. Two cells wide. | |
| 407 | - | bar := t.FG256(t.Assistant, "▌ ") | |
| 400 | + | bar := t.FGColor(t.Assistant, "▌ ") | |
| 408 | 401 | bubbleW := width - 2 | |
| 409 | 402 | if bubbleW < 1 { | |
| 410 | 403 | bubbleW = 1 | |
| @@ -428,7 +421,7 @@ func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" | |||
| 428 | 421 | func sgrBGColor(c TerminalColor) string { | |
| 429 | 422 | switch c.Mode { | |
| 430 | 423 | case terminalColorANSI: | |
| 431 | - | return "\x1b[" + itoa(c.Index) + "m" | |
| 424 | + | return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m" | |
| 432 | 425 | case terminalColorRGB: | |
| 433 | 426 | return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m" | |
| 434 | 427 | default: | |
| @@ -497,14 +490,52 @@ func ansiSGRToXtermIndex(sgr int) (int, bool) { | |||
| 497 | 490 | } | |
| 498 | 491 | } | |
| 499 | 492 | ||
| 493 | + | func ansiForegroundSGR(sgr int) int { | |
| 494 | + | switch { | |
| 495 | + | case sgr >= 40 && sgr <= 47: | |
| 496 | + | return sgr - 10 | |
| 497 | + | case sgr >= 100 && sgr <= 107: | |
| 498 | + | return sgr - 10 | |
| 499 | + | default: | |
| 500 | + | return sgr | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | func ansiBackgroundSGR(sgr int) int { | |
| 505 | + | switch { | |
| 506 | + | case sgr >= 30 && sgr <= 37: | |
| 507 | + | return sgr + 10 | |
| 508 | + | case sgr >= 90 && sgr <= 97: | |
| 509 | + | return sgr + 10 | |
| 510 | + | default: | |
| 511 | + | return sgr | |
| 512 | + | } | |
| 513 | + | } | |
| 514 | + | ||
| 500 | 515 | func blendChannel(from, to, percent int) int { | |
| 501 | 516 | return clampByte((from*(100-percent) + to*percent + 50) / 100) | |
| 502 | 517 | } | |
| 503 | 518 | ||
| 519 | + | const xtermColorCacheSize = 256 | |
| 520 | + | ||
| 521 | + | type xtermColorCacheEntry struct { | |
| 522 | + | value atomic.Uint64 | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | var xtermColorCache [xtermColorCacheSize]xtermColorCacheEntry | |
| 526 | + | ||
| 504 | 527 | func nearestXtermColor(r, g, b int) int { | |
| 505 | 528 | r = clampByte(r) | |
| 506 | 529 | g = clampByte(g) | |
| 507 | 530 | b = clampByte(b) | |
| 531 | + | key := uint64(r)<<16 | uint64(g)<<8 | uint64(b) | |
| 532 | + | entry := &xtermColorCache[(key*2654435761)&(xtermColorCacheSize-1)] | |
| 533 | + | cached := entry.value.Load() | |
| 534 | + | keyPrefix := (key + 1) << 8 | |
| 535 | + | if cached&^uint64(0xff) == keyPrefix { | |
| 536 | + | return int(cached & 0xff) | |
| 537 | + | } | |
| 538 | + | ||
| 508 | 539 | best := 0 | |
| 509 | 540 | bestDistance := int(^uint(0) >> 1) | |
| 510 | 541 | for index := 0; index < 256; index++ { | |
| @@ -518,6 +549,7 @@ func nearestXtermColor(r, g, b int) int { | |||
| 518 | 549 | bestDistance = distance | |
| 519 | 550 | } | |
| 520 | 551 | } | |
| 552 | + | entry.value.Store(keyPrefix | uint64(best)) | |
| 521 | 553 | return best | |
| 522 | 554 | } | |
| 523 | 555 | ||
| @@ -3,14 +3,14 @@ | |||||
| 3 | // framework; just ANSI escape codes. | 3 | // framework; just ANSI escape codes. | ||
| 4 | package tui | 4 | package tui | ||
| 5 | 5 | ||||
| 6 | - | import "strings" | 6 | + | import ( |
| 7 | - | 7 | + | "strings" | |
| 8 | - | // TerminalColor describes a terminal colour in one of the colour | 8 | + | "sync/atomic" |
| 9 | - | // spaces terminals commonly support. Most of zot's theme still uses | 9 | + | ) |
| 10 | - | // xterm-256 indexes, but user-bubble backgrounds can also use ANSI | 10 | + | |
| 11 | - | // theme slots (for example SGR 100 / bright-black background) so they | 11 | + | // TerminalColor describes a theme colour in one of the colour spaces |
| 12 | - | // match the user's terminal theme rather than the fixed 256-colour | 12 | + | // terminals commonly support. Every semantic theme colour uses this type; |
| 13 | - | // cube. | 13 | + | // xterm-256, ANSI, and RGB values are rendered according to terminal capability. |
| 14 | type TerminalColor struct { | 14 | type TerminalColor struct { | ||
| 15 | Mode terminalColorMode | 15 | Mode terminalColorMode | ||
| 16 | Index int | 16 | Index int | ||
| @@ -33,14 +33,6 @@ func ColorRGB(r, g, b int) TerminalColor { | |||||
| 33 | return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b} | 33 | return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b} | ||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | - | // ColorMode is the output color depth used by an inherited theme. | |||
| 37 | - | type ColorMode uint8 | |||
| 38 | - | ||||
| 39 | - | const ( | |||
| 40 | - | ColorMode256 ColorMode = iota | |||
| 41 | - | ColorModeTrueColor | |||
| 42 | - | ) | |||
| 43 | - | ||||
| 44 | // TerminalProfile is the best-effort color snapshot reported by the | 36 | // TerminalProfile is the best-effort color snapshot reported by the | ||
| 45 | // controlling terminal. The ANSI palette is optional; unknown slots are | 37 | // controlling terminal. The ANSI palette is optional; unknown slots are | ||
| 46 | // resolved through the classic xterm-256 palette. | 38 | // resolved through the classic xterm-256 palette. | ||
| @@ -66,31 +58,31 @@ func (p TerminalProfile) PaletteColor(index int) (TerminalColor, bool) { | |||||
| 66 | return p.Palette[index], true | 58 | return p.Palette[index], true | ||
| 67 | } | 59 | } | ||
| 68 | 60 | ||||
| 69 | - | // ANSI 256-color palette used by zot. Defined as numeric codes so we | 61 | + | // Semantic palette used by zot. Each role is a TerminalColor so themes can |
| 70 | - | // can swap themes without changing any render code. | 62 | + | // use indexed, ANSI, or RGB values without changing render code. |
| 71 | type Theme struct { | 63 | type Theme struct { | ||
| 72 | - | FG int | 64 | + | FG TerminalColor |
| 73 | - | Muted int | 65 | + | Muted TerminalColor |
| 74 | - | Accent int | 66 | + | Accent TerminalColor |
| 75 | Background *TerminalColor // optional full-row TUI background; nil keeps terminal default | 67 | Background *TerminalColor // optional full-row TUI background; nil keeps terminal default | ||
| 76 | - | User int // label color for the user role | 68 | + | User TerminalColor // label color for the user role |
| 77 | UserBubbleBG TerminalColor // background tint behind user message rows | 69 | UserBubbleBG TerminalColor // background tint behind user message rows | ||
| 78 | - | UserBubbleFG int // foreground colour for user message rows | 70 | + | UserBubbleFG TerminalColor // foreground colour for user message rows |
| 79 | - | Assistant int // label color for the zot role | 71 | + | Assistant TerminalColor // label color for the zot role |
| 80 | - | Tool int | 72 | + | Tool TerminalColor |
| 81 | - | ToolOut int | 73 | + | ToolOut TerminalColor |
| 82 | - | Error int | 74 | + | Error TerminalColor |
| 83 | - | Warning int | 75 | + | Warning TerminalColor |
| 84 | - | Spinner int // spinner + funny working line | 76 | + | Spinner TerminalColor // spinner + funny working line |
| 85 | - | ThinkingMax int // status color for the opt-in max reasoning level | 77 | + | ThinkingMax TerminalColor // status color for the opt-in max reasoning level |
| 86 | - | SelectionBG int // background for highlighted rows | 78 | + | SelectionBG TerminalColor // background for highlighted rows |
| 87 | - | SelectionFG int // foreground for highlighted rows | 79 | + | SelectionFG TerminalColor // foreground for highlighted rows |
| 88 | - | 80 | + | ||
| 89 | - | // Inherited selects terminal-owned colors and uses ColorMode to choose | 81 | + | // Inherited selects terminal-owned colors. Terminal.TrueColor controls |
| 90 | - | // truecolor or a best-effort xterm-256 fallback. Explicit zot themes keep | 82 | + | // whether fixed indexed values are emitted as xterm-256 or truecolor and |
| 91 | - | // the historical indexed output because their values are already fixed. | 83 | + | // whether RGB values are quantized for terminals without direct-color |
| 84 | + | // support. | |||
| 92 | Inherited bool | 85 | Inherited bool | ||
| 93 | - | ColorMode ColorMode | |||
| 94 | Terminal TerminalProfile | 86 | Terminal TerminalProfile | ||
| 95 | 87 | ||||
| 96 | SpinnerFrames []string | 88 | SpinnerFrames []string | ||
| @@ -188,21 +180,21 @@ var defaultSpinnerMessages = []string{ | |||||
| 188 | } | 180 | } | ||
| 189 | 181 | ||||
| 190 | var Dark = Theme{ | 182 | var Dark = Theme{ | ||
| 191 | - | FG: 253, | 183 | + | FG: Color256(253), |
| 192 | - | Muted: 244, | 184 | + | Muted: Color256(244), |
| 193 | - | Accent: 111, // soft blue | 185 | + | Accent: Color256(111), // soft blue |
| 194 | - | User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat) | 186 | + | User: Color256(180), // warm tan (unused now that the speaker label is gone, kept for skin compat) |
| 195 | UserBubbleBG: ColorRGB(66, 69, 75), // #42454B | 187 | UserBubbleBG: ColorRGB(66, 69, 75), // #42454B | ||
| 196 | - | UserBubbleFG: 248, // slightly lighter grey for readability on #42454B | 188 | + | UserBubbleFG: Color256(248), // slightly lighter grey for readability on #42454B |
| 197 | - | Assistant: 117, // bright cyan — the zot label color | 189 | + | Assistant: Color256(117), // bright cyan — the zot label color |
| 198 | - | Tool: 114, // green | 190 | + | Tool: Color256(114), // green |
| 199 | - | ToolOut: 245, | 191 | + | ToolOut: Color256(245), |
| 200 | - | Error: 203, | 192 | + | Error: Color256(203), |
| 201 | - | Warning: 214, | 193 | + | Warning: Color256(214), |
| 202 | - | Spinner: 183, // soft purple | 194 | + | Spinner: Color256(183), // soft purple |
| 203 | - | ThinkingMax: 207, // vivid magenta | 195 | + | ThinkingMax: Color256(207), // vivid magenta |
| 204 | - | SelectionBG: 24, // deep blue background | 196 | + | SelectionBG: Color256(24), // deep blue background |
| 205 | - | SelectionFG: 231, // near-white foreground | 197 | + | SelectionFG: Color256(231), // near-white foreground |
| 206 | SpinnerFrames: defaultSpinnerFrames, | 198 | SpinnerFrames: defaultSpinnerFrames, | ||
| 207 | SpinnerMessages: defaultSpinnerMessages, | 199 | SpinnerMessages: defaultSpinnerMessages, | ||
| 208 | SpinnerIntervalMS: 80, | 200 | SpinnerIntervalMS: 80, | ||
| @@ -211,21 +203,21 @@ var Dark = Theme{ | |||||
| 211 | } | 203 | } | ||
| 212 | 204 | ||||
| 213 | var Light = Theme{ | 205 | var Light = Theme{ | ||
| 214 | - | FG: 236, | 206 | + | FG: Color256(236), |
| 215 | - | Muted: 244, | 207 | + | Muted: Color256(244), |
| 216 | - | Accent: 33, | 208 | + | Accent: Color256(33), |
| 217 | - | User: 94, | 209 | + | User: Color256(94), |
| 218 | UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme | 210 | UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme | ||
| 219 | - | UserBubbleFG: 240, // dark grey text, legible on the pale panel | 211 | + | UserBubbleFG: Color256(240), // dark grey text, legible on the pale panel |
| 220 | - | Assistant: 31, // deep cyan | 212 | + | Assistant: Color256(31), // deep cyan |
| 221 | - | Tool: 28, | 213 | + | Tool: Color256(28), |
| 222 | - | ToolOut: 240, | 214 | + | ToolOut: Color256(240), |
| 223 | - | Error: 160, | 215 | + | Error: Color256(160), |
| 224 | - | Warning: 166, | 216 | + | Warning: Color256(166), |
| 225 | - | Spinner: 91, // purple | 217 | + | Spinner: Color256(91), // purple |
| 226 | - | ThinkingMax: 127, // deep magenta | 218 | + | ThinkingMax: Color256(127), // deep magenta |
| 227 | - | SelectionBG: 153, // light blue | 219 | + | SelectionBG: Color256(153), // light blue |
| 228 | - | SelectionFG: 232, // near-black | 220 | + | SelectionFG: Color256(232), // near-black |
| 229 | SpinnerFrames: defaultSpinnerFrames, | 221 | SpinnerFrames: defaultSpinnerFrames, | ||
| 230 | SpinnerMessages: defaultSpinnerMessages, | 222 | SpinnerMessages: defaultSpinnerMessages, | ||
| 231 | SpinnerIntervalMS: 80, | 223 | SpinnerIntervalMS: 80, | ||
| @@ -233,43 +225,48 @@ var Light = Theme{ | |||||
| 233 | Syntax: nordSyntax, | 225 | Syntax: nordSyntax, | ||
| 234 | } | 226 | } | ||
| 235 | 227 | ||||
| 236 | - | // FG256 wraps s in a foreground color. Explicit themes retain the | 228 | + | // FGColor wraps s in a foreground color. The name is intentionally explicit |
| 237 | - | // historical ANSI 256-color sequence; inherited themes resolve terminal | 229 | + | // because the color may be xterm-256, ANSI, or RGB. |
| 238 | - | // palette entries and emit truecolor when the terminal advertises it. | 230 | + | func (t Theme) FGColor(c TerminalColor, s string) string { |
| 239 | - | func (t Theme) FG256(c int, s string) string { | |||
| 240 | return t.fgPrefix(c) + s + reset | 231 | return t.fgPrefix(c) + s + reset | ||
| 241 | } | 232 | } | ||
| 242 | 233 | ||||
| 243 | - | // BG256 wraps s in a background color using the theme's output capability. | 234 | + | // FG256 retains the historical helper name while accepting the full |
| 244 | - | // Useful when the visible cell needs a coloured fill but the underlying | 235 | + | // TerminalColor model. |
| 245 | - | // character should be a regular space (so mouse-copy yields whitespace). | 236 | + | // |
| 246 | - | func (t Theme) BG256(c int, s string) string { | 237 | + | // Deprecated: use FGColor. |
| 247 | - | return t.bgPrefix(Color256(c)) + s + reset | 238 | + | func (t Theme) FG256(c TerminalColor, s string) string { |
| 239 | + | return t.FGColor(c, s) | |||
| 248 | } | 240 | } | ||
| 249 | 241 | ||||
| 250 | - | // BG wraps s in a terminal background color. Inherited themes quantize RGB | 242 | + | // BG256 retains the historical helper name while accepting the full |
| 251 | - | // values on terminals without truecolor support. | 243 | + | // TerminalColor model. |
| 244 | + | // | |||
| 245 | + | // Deprecated: use BG. | |||
| 246 | + | func (t Theme) BG256(c TerminalColor, s string) string { | |||
| 247 | + | return t.bgPrefix(c) + s + reset | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | // BG wraps s in a terminal background color. RGB values are quantized when | |||
| 251 | + | // the active terminal does not advertise truecolor support. | |||
| 252 | func (t Theme) BG(c TerminalColor, s string) string { | 252 | func (t Theme) BG(c TerminalColor, s string) string { | ||
| 253 | return t.bgPrefix(c) + s + reset | 253 | return t.bgPrefix(c) + s + reset | ||
| 254 | } | 254 | } | ||
| 255 | 255 | ||||
| 256 | - | func (t Theme) fgPrefix(index int) string { | 256 | + | func (t Theme) fgPrefix(color TerminalColor) string { |
| 257 | - | if t.Inherited && index == t.FG && t.Terminal.HasForeground { | 257 | + | return sgrFGColor(t.resolveColor(color)) |
| 258 | - | return sgrFGColor(t.resolveColor(t.Terminal.Foreground)) | |||
| 259 | - | } | |||
| 260 | - | return sgrFGColor(t.resolveColor(Color256(index))) | |||
| 261 | } | 258 | } | ||
| 262 | 259 | ||||
| 263 | func (t Theme) bgPrefix(color TerminalColor) string { | 260 | func (t Theme) bgPrefix(color TerminalColor) string { | ||
| 264 | return sgrBGColor(t.resolveColor(color)) | 261 | return sgrBGColor(t.resolveColor(color)) | ||
| 265 | } | 262 | } | ||
| 266 | 263 | ||||
| 267 | - | // DimColor fades an indexed color toward the terminal background. It is used | 264 | + | // DimColor fades a theme color toward the terminal background. It is used |
| 268 | - | // by inherited theme construction for muted surfaces and is also available to | 265 | + | // by inherited theme construction and is also available to callers that need |
| 269 | - | // callers that need terminal-aware dimming for overlays. | 266 | + | // terminal-aware dimming for overlays. |
| 270 | - | func (t Theme) DimColor(index, percent int) TerminalColor { | 267 | + | func (t Theme) DimColor(color TerminalColor, percent int) TerminalColor { |
| 271 | percent = clampPercent(percent) | 268 | percent = clampPercent(percent) | ||
| 272 | - | foreground := t.resolveForeground(index) | 269 | + | foreground := t.resolveColor(color) |
| 273 | background := t.Terminal.Background | 270 | background := t.Terminal.Background | ||
| 274 | if !t.Terminal.HasBackground { | 271 | if !t.Terminal.HasBackground { | ||
| 275 | background = Color256(0) | 272 | background = Color256(0) | ||
| @@ -283,36 +280,32 @@ func (t Theme) DimColor(index, percent int) TerminalColor { | |||||
| 283 | )) | 280 | )) | ||
| 284 | } | 281 | } | ||
| 285 | 282 | ||||
| 286 | - | func (t Theme) resolveForeground(index int) TerminalColor { | |||
| 287 | - | if t.Inherited && index == t.FG && t.Terminal.HasForeground { | |||
| 288 | - | return t.resolveColor(t.Terminal.Foreground) | |||
| 289 | - | } | |||
| 290 | - | return t.resolveColor(Color256(index)) | |||
| 291 | - | } | |||
| 292 | - | ||||
| 293 | func (t Theme) resolveColor(color TerminalColor) TerminalColor { | 283 | func (t Theme) resolveColor(color TerminalColor) TerminalColor { | ||
| 294 | switch color.Mode { | 284 | switch color.Mode { | ||
| 295 | case terminalColor256: | 285 | case terminalColor256: | ||
| 296 | index := clampXtermIndex(color.Index) | 286 | index := clampXtermIndex(color.Index) | ||
| 297 | - | if t.Inherited { | 287 | + | if t.Terminal.TrueColor { |
| 298 | - | if palette, ok := t.Terminal.PaletteColor(index); ok { | 288 | + | if t.Inherited { |
| 299 | - | if t.ColorMode == ColorModeTrueColor { | 289 | + | if palette, ok := t.Terminal.PaletteColor(index); ok { |
| 300 | return palette | 290 | return palette | ||
| 301 | } | 291 | } | ||
| 302 | - | return Color256(index) | |||
| 303 | - | } | |||
| 304 | - | if t.ColorMode == ColorModeTrueColor { | |||
| 305 | - | r, g, b := xterm256RGB(index) | |||
| 306 | - | return ColorRGB(r, g, b) | |||
| 307 | } | 292 | } | ||
| 308 | - | } | |||
| 309 | - | if t.ColorMode == ColorModeTrueColor { | |||
| 310 | r, g, b := xterm256RGB(index) | 293 | r, g, b := xterm256RGB(index) | ||
| 311 | return ColorRGB(r, g, b) | 294 | return ColorRGB(r, g, b) | ||
| 312 | } | 295 | } | ||
| 313 | return Color256(index) | 296 | return Color256(index) | ||
| 297 | + | case terminalColorANSI: | |||
| 298 | + | if t.Inherited && t.Terminal.TrueColor { | |||
| 299 | + | if index, ok := ansiSGRToXtermIndex(color.Index); ok { | |||
| 300 | + | if palette, ok := t.Terminal.PaletteColor(index); ok { | |||
| 301 | + | return palette | |||
| 302 | + | } | |||
| 303 | + | r, g, b := xterm256RGB(index) | |||
| 304 | + | return ColorRGB(r, g, b) | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 314 | case terminalColorRGB: | 307 | case terminalColorRGB: | ||
| 315 | - | if t.Inherited && t.ColorMode == ColorMode256 { | 308 | + | if !t.Terminal.TrueColor { |
| 316 | return Color256(nearestXtermColor(color.R, color.G, color.B)) | 309 | return Color256(nearestXtermColor(color.R, color.G, color.B)) | ||
| 317 | } | 310 | } | ||
| 318 | } | 311 | } | ||
| @@ -322,7 +315,7 @@ func (t Theme) resolveColor(color TerminalColor) TerminalColor { | |||||
| 322 | func sgrFGColor(color TerminalColor) string { | 315 | func sgrFGColor(color TerminalColor) string { | ||
| 323 | switch color.Mode { | 316 | switch color.Mode { | ||
| 324 | case terminalColorANSI: | 317 | case terminalColorANSI: | ||
| 325 | - | return "\x1b[" + itoa(color.Index) + "m" | 318 | + | return "\x1b[" + itoa(ansiForegroundSGR(color.Index)) + "m" |
| 326 | case terminalColorRGB: | 319 | case terminalColorRGB: | ||
| 327 | return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m" | 320 | return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m" | ||
| 328 | default: | 321 | default: | ||
| @@ -338,8 +331,8 @@ func (t Theme) sgrBGColor(color TerminalColor) string { | |||||
| 338 | // glyph followed by a plain space gutter. Used as the speaker-label | 331 | // glyph followed by a plain space gutter. Used as the speaker-label | ||
| 339 | // prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so | 332 | // prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so | ||
| 340 | // the bar reads consistently across the UI. | 333 | // the bar reads consistently across the UI. | ||
| 341 | - | func (t Theme) AccentBar(c int) string { | 334 | + | func (t Theme) AccentBar(c TerminalColor) string { |
| 342 | - | return t.FG256(c, "▌ ") | 335 | + | return t.FGColor(c, "▌ ") |
| 343 | } | 336 | } | ||
| 344 | 337 | ||||
| 345 | // Highlight paints s with the theme's selection colors (foreground + | 338 | // Highlight paints s with the theme's selection colors (foreground + | ||
| @@ -351,7 +344,7 @@ func (t Theme) Highlight(s string) string { | |||||
| 351 | 344 | ||||
| 352 | // SelectionStyle returns the SGR prefix for the theme's selected row. | 345 | // SelectionStyle returns the SGR prefix for the theme's selected row. | ||
| 353 | func (t Theme) SelectionStyle() string { | 346 | func (t Theme) SelectionStyle() string { | ||
| 354 | - | return t.fgPrefix(t.SelectionFG) + t.bgPrefix(Color256(t.SelectionBG)) | 347 | + | return t.fgPrefix(t.SelectionFG) + t.bgPrefix(t.SelectionBG) |
| 355 | } | 348 | } | ||
| 356 | 349 | ||||
| 357 | // BackgroundStyle returns the SGR prefix for the optional full-row | 350 | // BackgroundStyle returns the SGR prefix for the optional full-row | ||
| @@ -367,8 +360,8 @@ func (t Theme) BackgroundStyle() string { | |||||
| 367 | // SelectionStyleFG returns the SGR prefix for selected-row text with | 360 | // SelectionStyleFG returns the SGR prefix for selected-row text with | ||
| 368 | // a custom foreground. Useful for preserving semantic marks inside a | 361 | // a custom foreground. Useful for preserving semantic marks inside a | ||
| 369 | // highlighted row without hardcoding escape sequences outside theme.go. | 362 | // highlighted row without hardcoding escape sequences outside theme.go. | ||
| 370 | - | func (t Theme) SelectionStyleFG(fg int) string { | 363 | + | func (t Theme) SelectionStyleFG(fg TerminalColor) string { |
| 371 | - | return t.fgPrefix(fg) + t.bgPrefix(Color256(t.SelectionBG)) | 364 | + | return t.fgPrefix(fg) + t.bgPrefix(t.SelectionBG) |
| 372 | } | 365 | } | ||
| 373 | 366 | ||||
| 374 | // PadHighlight styles s and extends the selection background to the | 367 | // PadHighlight styles s and extends the selection background to the | ||
| @@ -404,7 +397,7 @@ func (t Theme) UserBubbleRow(content string, width int) string { | |||||
| 404 | // Bar plus a single space gutter, in the assistant accent colour | 397 | // Bar plus a single space gutter, in the assistant accent colour | ||
| 405 | // so it matches the tool-box / app accent and reads as zot's voice | 398 | // so it matches the tool-box / app accent and reads as zot's voice | ||
| 406 | // marker. Two cells wide. | 399 | // marker. Two cells wide. | ||
| 407 | - | bar := t.FG256(t.Assistant, "▌ ") | 400 | + | bar := t.FGColor(t.Assistant, "▌ ") |
| 408 | bubbleW := width - 2 | 401 | bubbleW := width - 2 | ||
| 409 | if bubbleW < 1 { | 402 | if bubbleW < 1 { | ||
| 410 | bubbleW = 1 | 403 | bubbleW = 1 | ||
| @@ -428,7 +421,7 @@ func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" | |||||
| 428 | func sgrBGColor(c TerminalColor) string { | 421 | func sgrBGColor(c TerminalColor) string { | ||
| 429 | switch c.Mode { | 422 | switch c.Mode { | ||
| 430 | case terminalColorANSI: | 423 | case terminalColorANSI: | ||
| 431 | - | return "\x1b[" + itoa(c.Index) + "m" | 424 | + | return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m" |
| 432 | case terminalColorRGB: | 425 | case terminalColorRGB: | ||
| 433 | return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m" | 426 | return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m" | ||
| 434 | default: | 427 | default: | ||
| @@ -497,14 +490,52 @@ func ansiSGRToXtermIndex(sgr int) (int, bool) { | |||||
| 497 | } | 490 | } | ||
| 498 | } | 491 | } | ||
| 499 | 492 | ||||
| 493 | + | func ansiForegroundSGR(sgr int) int { | |||
| 494 | + | switch { | |||
| 495 | + | case sgr >= 40 && sgr <= 47: | |||
| 496 | + | return sgr - 10 | |||
| 497 | + | case sgr >= 100 && sgr <= 107: | |||
| 498 | + | return sgr - 10 | |||
| 499 | + | default: | |||
| 500 | + | return sgr | |||
| 501 | + | } | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | func ansiBackgroundSGR(sgr int) int { | |||
| 505 | + | switch { | |||
| 506 | + | case sgr >= 30 && sgr <= 37: | |||
| 507 | + | return sgr + 10 | |||
| 508 | + | case sgr >= 90 && sgr <= 97: | |||
| 509 | + | return sgr + 10 | |||
| 510 | + | default: | |||
| 511 | + | return sgr | |||
| 512 | + | } | |||
| 513 | + | } | |||
| 514 | + | ||||
| 500 | func blendChannel(from, to, percent int) int { | 515 | func blendChannel(from, to, percent int) int { | ||
| 501 | return clampByte((from*(100-percent) + to*percent + 50) / 100) | 516 | return clampByte((from*(100-percent) + to*percent + 50) / 100) | ||
| 502 | } | 517 | } | ||
| 503 | 518 | ||||
| 519 | + | const xtermColorCacheSize = 256 | |||
| 520 | + | ||||
| 521 | + | type xtermColorCacheEntry struct { | |||
| 522 | + | value atomic.Uint64 | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | var xtermColorCache [xtermColorCacheSize]xtermColorCacheEntry | |||
| 526 | + | ||||
| 504 | func nearestXtermColor(r, g, b int) int { | 527 | func nearestXtermColor(r, g, b int) int { | ||
| 505 | r = clampByte(r) | 528 | r = clampByte(r) | ||
| 506 | g = clampByte(g) | 529 | g = clampByte(g) | ||
| 507 | b = clampByte(b) | 530 | b = clampByte(b) | ||
| 531 | + | key := uint64(r)<<16 | uint64(g)<<8 | uint64(b) | |||
| 532 | + | entry := &xtermColorCache[(key*2654435761)&(xtermColorCacheSize-1)] | |||
| 533 | + | cached := entry.value.Load() | |||
| 534 | + | keyPrefix := (key + 1) << 8 | |||
| 535 | + | if cached&^uint64(0xff) == keyPrefix { | |||
| 536 | + | return int(cached & 0xff) | |||
| 537 | + | } | |||
| 538 | + | ||||
| 508 | best := 0 | 539 | best := 0 | ||
| 509 | bestDistance := int(^uint(0) >> 1) | 540 | bestDistance := int(^uint(0) >> 1) | ||
| 510 | for index := 0; index < 256; index++ { | 541 | for index := 0; index < 256; index++ { | ||
| @@ -518,6 +549,7 @@ func nearestXtermColor(r, g, b int) int { | |||||
| 518 | bestDistance = distance | 549 | bestDistance = distance | ||
| 519 | } | 550 | } | ||
| 520 | } | 551 | } | ||
| 552 | + | entry.value.Store(keyPrefix | uint64(best)) | |||
| 521 | return best | 553 | return best | ||
| 522 | } | 554 | } | ||
| 523 | 555 | ||||
M packages/tui/theme_inherited_test.go +43 -7
| @@ -70,6 +70,34 @@ func TestParseOSCColorResponse(t *testing.T) { | |||
| 70 | 70 | } | |
| 71 | 71 | } | |
| 72 | 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 | + | } | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 73 | 101 | func inheritedTestTheme(trueColor bool) Theme { | |
| 74 | 102 | detected := Dark | |
| 75 | 103 | detected.Terminal = TerminalProfile{ | |
| @@ -88,8 +116,8 @@ func inheritedTestTheme(trueColor bool) Theme { | |||
| 88 | 116 | ||
| 89 | 117 | func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) { | |
| 90 | 118 | th := inheritedTestTheme(true) | |
| 91 | - | if !th.Inherited || th.ColorMode != ColorModeTrueColor { | |
| 92 | - | t.Fatalf("inherited theme mode = (inherited=%v, colorMode=%v), want truecolor", th.Inherited, th.ColorMode) | |
| 119 | + | if !th.Inherited || !th.Terminal.TrueColor { | |
| 120 | + | t.Fatalf("inherited theme mode = (inherited=%v, trueColor=%v), want truecolor", th.Inherited, th.Terminal.TrueColor) | |
| 93 | 121 | } | |
| 94 | 122 | if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") { | |
| 95 | 123 | t.Fatalf("accent did not use the reported terminal palette: %q", got) | |
| @@ -110,8 +138,8 @@ func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) { | |||
| 110 | 138 | ||
| 111 | 139 | func TestInheritedThemeFallsBackTo256Colors(t *testing.T) { | |
| 112 | 140 | th := inheritedTestTheme(false) | |
| 113 | - | if !th.Inherited || th.ColorMode != ColorMode256 { | |
| 114 | - | t.Fatalf("inherited theme mode = (inherited=%v, colorMode=%v), want 256", th.Inherited, th.ColorMode) | |
| 141 | + | if !th.Inherited || th.Terminal.TrueColor { | |
| 142 | + | t.Fatalf("inherited theme mode = (inherited=%v, trueColor=%v), want 256", th.Inherited, th.Terminal.TrueColor) | |
| 115 | 143 | } | |
| 116 | 144 | if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;5;12m") || strings.Contains(got, "\x1b[38;2;") { | |
| 117 | 145 | t.Fatalf("accent did not use 256-color fallback: %q", got) | |
| @@ -127,12 +155,20 @@ func TestInheritedThemeFallsBackTo256Colors(t *testing.T) { | |||
| 127 | 155 | } | |
| 128 | 156 | } | |
| 129 | 157 | ||
| 130 | - | func TestExplicitThemesRemainIndexed(t *testing.T) { | |
| 158 | + | func TestExplicitThemesFollowTerminalCapability(t *testing.T) { | |
| 131 | 159 | if got, want := Dark.FG256(Dark.Accent, "x"), "\x1b[38;5;111mx\x1b[0m"; got != want { | |
| 132 | 160 | t.Fatalf("explicit theme output = %q, want %q", got, want) | |
| 133 | 161 | } | |
| 134 | - | if got := Dark.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;2;66;69;75m") { | |
| 135 | - | t.Fatalf("explicit RGB bubble changed output mode: %q", got) | |
| 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.FG256(trueColor.Accent, "x"); !strings.Contains(got, "\x1b[38;2;") { | |
| 168 | + | t.Fatalf("truecolor explicit theme did not emit RGB: %q", got) | |
| 169 | + | } | |
| 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) | |
| 136 | 172 | } | |
| 137 | 173 | } | |
| 138 | 174 | ||
| @@ -70,6 +70,34 @@ func TestParseOSCColorResponse(t *testing.T) { | |||||
| 70 | } | 70 | } | ||
| 71 | } | 71 | } | ||
| 72 | 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 | + | } | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 73 | func inheritedTestTheme(trueColor bool) Theme { | 101 | func inheritedTestTheme(trueColor bool) Theme { | ||
| 74 | detected := Dark | 102 | detected := Dark | ||
| 75 | detected.Terminal = TerminalProfile{ | 103 | detected.Terminal = TerminalProfile{ | ||
| @@ -88,8 +116,8 @@ func inheritedTestTheme(trueColor bool) Theme { | |||||
| 88 | 116 | ||||
| 89 | func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) { | 117 | func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) { | ||
| 90 | th := inheritedTestTheme(true) | 118 | th := inheritedTestTheme(true) | ||
| 91 | - | if !th.Inherited || th.ColorMode != ColorModeTrueColor { | 119 | + | if !th.Inherited || !th.Terminal.TrueColor { |
| 92 | - | t.Fatalf("inherited theme mode = (inherited=%v, colorMode=%v), want truecolor", th.Inherited, th.ColorMode) | 120 | + | t.Fatalf("inherited theme mode = (inherited=%v, trueColor=%v), want truecolor", th.Inherited, th.Terminal.TrueColor) |
| 93 | } | 121 | } | ||
| 94 | if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") { | 122 | if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") { | ||
| 95 | t.Fatalf("accent did not use the reported terminal palette: %q", got) | 123 | t.Fatalf("accent did not use the reported terminal palette: %q", got) | ||
| @@ -110,8 +138,8 @@ func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) { | |||||
| 110 | 138 | ||||
| 111 | func TestInheritedThemeFallsBackTo256Colors(t *testing.T) { | 139 | func TestInheritedThemeFallsBackTo256Colors(t *testing.T) { | ||
| 112 | th := inheritedTestTheme(false) | 140 | th := inheritedTestTheme(false) | ||
| 113 | - | if !th.Inherited || th.ColorMode != ColorMode256 { | 141 | + | if !th.Inherited || th.Terminal.TrueColor { |
| 114 | - | t.Fatalf("inherited theme mode = (inherited=%v, colorMode=%v), want 256", th.Inherited, th.ColorMode) | 142 | + | t.Fatalf("inherited theme mode = (inherited=%v, trueColor=%v), want 256", th.Inherited, th.Terminal.TrueColor) |
| 115 | } | 143 | } | ||
| 116 | if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;5;12m") || strings.Contains(got, "\x1b[38;2;") { | 144 | if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;5;12m") || strings.Contains(got, "\x1b[38;2;") { | ||
| 117 | t.Fatalf("accent did not use 256-color fallback: %q", got) | 145 | t.Fatalf("accent did not use 256-color fallback: %q", got) | ||
| @@ -127,12 +155,20 @@ func TestInheritedThemeFallsBackTo256Colors(t *testing.T) { | |||||
| 127 | } | 155 | } | ||
| 128 | } | 156 | } | ||
| 129 | 157 | ||||
| 130 | - | func TestExplicitThemesRemainIndexed(t *testing.T) { | 158 | + | func TestExplicitThemesFollowTerminalCapability(t *testing.T) { |
| 131 | if got, want := Dark.FG256(Dark.Accent, "x"), "\x1b[38;5;111mx\x1b[0m"; got != want { | 159 | if got, want := Dark.FG256(Dark.Accent, "x"), "\x1b[38;5;111mx\x1b[0m"; got != want { | ||
| 132 | t.Fatalf("explicit theme output = %q, want %q", got, want) | 160 | t.Fatalf("explicit theme output = %q, want %q", got, want) | ||
| 133 | } | 161 | } | ||
| 134 | - | if got := Dark.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;2;66;69;75m") { | 162 | + | if got := Dark.UserBubble("x", 1); !strings.Contains(got, "\x1b[48;5;") || strings.Contains(got, "\x1b[48;2;") { |
| 135 | - | t.Fatalf("explicit RGB bubble changed output mode: %q", got) | 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.FG256(trueColor.Accent, "x"); !strings.Contains(got, "\x1b[38;2;") { | |||
| 168 | + | t.Fatalf("truecolor explicit theme did not emit RGB: %q", got) | |||
| 169 | + | } | |||
| 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) | |||
| 136 | } | 172 | } | ||
| 137 | } | 173 | } | ||
| 138 | 174 | ||||
M packages/tui/theme_loader.go +63 -94
| @@ -79,23 +79,23 @@ func (m *ThemeFileColorModes) UnmarshalJSON(data []byte) error { | |||
| 79 | 79 | // override only the colors it cares about and inherit the built-in | |
| 80 | 80 | // dark/light defaults for everything else. | |
| 81 | 81 | type ThemeOverrides struct { | |
| 82 | - | FG *int `json:"fg,omitempty"` | |
| 83 | - | Muted *int `json:"muted,omitempty"` | |
| 84 | - | Accent *int `json:"accent,omitempty"` | |
| 82 | + | FG *TerminalColorValue `json:"fg,omitempty"` | |
| 83 | + | Muted *TerminalColorValue `json:"muted,omitempty"` | |
| 84 | + | Accent *TerminalColorValue `json:"accent,omitempty"` | |
| 85 | 85 | Background *TerminalColorValue `json:"background,omitempty"` | |
| 86 | - | User *int `json:"user,omitempty"` | |
| 86 | + | User *TerminalColorValue `json:"user,omitempty"` | |
| 87 | 87 | UserBubbleBG *TerminalColorValue `json:"user_bubble_bg,omitempty"` | |
| 88 | - | UserBubbleFG *int `json:"user_bubble_fg,omitempty"` | |
| 89 | - | Assistant *int `json:"assistant,omitempty"` | |
| 90 | - | Tool *int `json:"tool,omitempty"` | |
| 91 | - | ToolOut *int `json:"tool_out,omitempty"` | |
| 92 | - | Error *int `json:"error,omitempty"` | |
| 93 | - | Warning *int `json:"warning,omitempty"` | |
| 94 | - | Spinner *int `json:"spinner,omitempty"` | |
| 95 | - | ThinkingMax *int `json:"thinking_max,omitempty"` | |
| 96 | - | ThinkingMaxCamel *int `json:"thinkingMax,omitempty"` | |
| 97 | - | SelectionBG *int `json:"selection_bg,omitempty"` | |
| 98 | - | SelectionFG *int `json:"selection_fg,omitempty"` | |
| 88 | + | UserBubbleFG *TerminalColorValue `json:"user_bubble_fg,omitempty"` | |
| 89 | + | Assistant *TerminalColorValue `json:"assistant,omitempty"` | |
| 90 | + | Tool *TerminalColorValue `json:"tool,omitempty"` | |
| 91 | + | ToolOut *TerminalColorValue `json:"tool_out,omitempty"` | |
| 92 | + | Error *TerminalColorValue `json:"error,omitempty"` | |
| 93 | + | Warning *TerminalColorValue `json:"warning,omitempty"` | |
| 94 | + | Spinner *TerminalColorValue `json:"spinner,omitempty"` | |
| 95 | + | ThinkingMax *TerminalColorValue `json:"thinking_max,omitempty"` | |
| 96 | + | ThinkingMaxCamel *TerminalColorValue `json:"thinkingMax,omitempty"` | |
| 97 | + | SelectionBG *TerminalColorValue `json:"selection_bg,omitempty"` | |
| 98 | + | SelectionFG *TerminalColorValue `json:"selection_fg,omitempty"` | |
| 99 | 99 | SpinnerFrames []string `json:"spinner_frames,omitempty"` | |
| 100 | 100 | SpinnerMessages []string `json:"spinner_messages,omitempty"` | |
| 101 | 101 | SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"` | |
| @@ -187,51 +187,34 @@ func InheritedTheme(detected Theme) Theme { | |||
| 187 | 187 | } | |
| 188 | 188 | t := withTerminalProfile(base, detected) | |
| 189 | 189 | t.Inherited = true | |
| 190 | - | t.ColorMode = ColorMode256 | |
| 191 | - | if t.Terminal.TrueColor { | |
| 192 | - | t.ColorMode = ColorModeTrueColor | |
| 193 | - | } | |
| 194 | 190 | ||
| 195 | 191 | if t.Terminal.HasForeground { | |
| 196 | - | t.FG = terminalColorIndex(t.Terminal.Foreground) | |
| 192 | + | t.FG = t.Terminal.Foreground | |
| 197 | 193 | } | |
| 198 | 194 | ||
| 199 | 195 | accentFallback := t.Accent | |
| 200 | 196 | if IsLightTheme(t) { | |
| 201 | 197 | accentFallback = Light.Accent | |
| 202 | 198 | } | |
| 203 | - | t.Accent = terminalPaletteIndex(t.Terminal, accentFallback, 12, 4, 14, 6, 13, 5) | |
| 199 | + | t.Accent = terminalPaletteColor(t.Terminal, accentFallback, | |
| 200 | + | ansiBrightBlue, ansiBlue, ansiBrightCyan, ansiCyan, ansiBrightMagenta, ansiMagenta) | |
| 204 | 201 | t.Assistant = t.Accent | |
| 205 | - | t.Tool = terminalPaletteIndex(t.Terminal, t.Tool, 10, 2) | |
| 206 | - | t.Error = terminalPaletteIndex(t.Terminal, t.Error, 9, 1) | |
| 207 | - | t.Warning = terminalPaletteIndex(t.Terminal, t.Warning, 11, 3) | |
| 208 | - | t.Spinner = terminalPaletteIndex(t.Terminal, t.Spinner, 13, 5) | |
| 209 | - | t.ThinkingMax = terminalPaletteIndex(t.Terminal, t.ThinkingMax, 13, 5) | |
| 210 | - | t.User = terminalPaletteIndex(t.Terminal, t.User, 11, 3) | |
| 202 | + | t.Tool = terminalPaletteColor(t.Terminal, t.Tool, ansiBrightGreen, ansiGreen) | |
| 203 | + | t.Error = terminalPaletteColor(t.Terminal, t.Error, ansiBrightRed, ansiRed) | |
| 204 | + | t.Warning = terminalPaletteColor(t.Terminal, t.Warning, ansiBrightYellow, ansiYellow) | |
| 205 | + | t.Spinner = terminalPaletteColor(t.Terminal, t.Spinner, ansiBrightMagenta, ansiMagenta) | |
| 206 | + | t.ThinkingMax = terminalPaletteColor(t.Terminal, t.ThinkingMax, ansiBrightMagenta, ansiMagenta) | |
| 207 | + | t.User = terminalPaletteColor(t.Terminal, t.User, ansiBrightYellow, ansiYellow) | |
| 211 | 208 | ||
| 212 | 209 | if t.Terminal.HasForeground && t.Terminal.HasBackground { | |
| 213 | - | t.Muted = terminalColorIndex(t.DimColor(t.FG, 45)) | |
| 210 | + | t.Muted = t.DimColor(t.FG, 45) | |
| 214 | 211 | t.ToolOut = t.Muted | |
| 215 | 212 | background := t.Terminal.Background | |
| 216 | 213 | foreground := t.Terminal.Foreground | |
| 217 | 214 | t.UserBubbleBG = blendTerminalColors(background, foreground, 12) | |
| 218 | - | t.SelectionBG = terminalColorIndex(blendTerminalColors(background, foreground, 25)) | |
| 215 | + | t.SelectionBG = blendTerminalColors(background, foreground, 25) | |
| 219 | 216 | } | |
| 220 | 217 | ||
| 221 | - | // FG256 receives an index rather than a semantic role. Keep the inherited | |
| 222 | - | // foreground index distinct from accent/status roles so a terminal whose | |
| 223 | - | // default foreground happens to be ANSI blue does not recolor every blue | |
| 224 | - | // accent as the terminal default foreground. | |
| 225 | - | if t.Terminal.HasForeground { | |
| 226 | - | used := map[int]bool{ | |
| 227 | - | t.Muted: true, t.Accent: true, t.Assistant: true, t.Tool: true, | |
| 228 | - | t.ToolOut: true, t.Error: true, t.Warning: true, t.Spinner: true, | |
| 229 | - | t.ThinkingMax: true, t.User: true, t.SelectionBG: true, | |
| 230 | - | } | |
| 231 | - | if used[t.FG] { | |
| 232 | - | t.FG = nearestXtermColorExcluding(t.Terminal.Foreground, used) | |
| 233 | - | } | |
| 234 | - | } | |
| 235 | 218 | t.UserBubbleFG = t.FG | |
| 236 | 219 | t.SelectionFG = t.FG | |
| 237 | 220 | ||
| @@ -247,28 +230,35 @@ func withTerminalProfile(base, detected Theme) Theme { | |||
| 247 | 230 | return base | |
| 248 | 231 | } | |
| 249 | 232 | ||
| 250 | - | func terminalPaletteIndex(profile TerminalProfile, fallback int, preferred ...int) int { | |
| 233 | + | // Standard ANSI palette slots used as inherited role preferences. | |
| 234 | + | const ( | |
| 235 | + | ansiRed = 1 | |
| 236 | + | ansiGreen = 2 | |
| 237 | + | ansiYellow = 3 | |
| 238 | + | ansiBlue = 4 | |
| 239 | + | ansiMagenta = 5 | |
| 240 | + | ansiCyan = 6 | |
| 241 | + | ansiBrightRed = 9 | |
| 242 | + | ansiBrightGreen = 10 | |
| 243 | + | ansiBrightYellow = 11 | |
| 244 | + | ansiBrightBlue = 12 | |
| 245 | + | ansiBrightMagenta = 13 | |
| 246 | + | ansiBrightCyan = 14 | |
| 247 | + | ) | |
| 248 | + | ||
| 249 | + | func terminalPaletteColor(profile TerminalProfile, fallback TerminalColor, preferred ...int) TerminalColor { | |
| 251 | 250 | for _, index := range preferred { | |
| 252 | 251 | if _, ok := profile.PaletteColor(index); ok { | |
| 253 | - | return index | |
| 252 | + | // Keep the palette slot as the semantic value. Truecolor | |
| 253 | + | // resolution expands it back to the terminal-reported RGB; | |
| 254 | + | // 256-color output preserves the slot rather than re-quantizing | |
| 255 | + | // the reported RGB to a nearby cube entry. | |
| 256 | + | return Color256(index) | |
| 254 | 257 | } | |
| 255 | 258 | } | |
| 256 | 259 | return fallback | |
| 257 | 260 | } | |
| 258 | 261 | ||
| 259 | - | func terminalColorIndex(color TerminalColor) int { | |
| 260 | - | if color.Mode == terminalColor256 { | |
| 261 | - | return clampXtermIndex(color.Index) | |
| 262 | - | } | |
| 263 | - | if color.Mode == terminalColorANSI { | |
| 264 | - | if index, ok := ansiSGRToXtermIndex(color.Index); ok { | |
| 265 | - | return index | |
| 266 | - | } | |
| 267 | - | return 0 | |
| 268 | - | } | |
| 269 | - | return nearestXtermColor(color.R, color.G, color.B) | |
| 270 | - | } | |
| 271 | - | ||
| 272 | 262 | func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor { | |
| 273 | 263 | fromRGB, _ := rgbForTerminalColor(from) | |
| 274 | 264 | toRGB, _ := rgbForTerminalColor(to) | |
| @@ -280,27 +270,6 @@ func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor { | |||
| 280 | 270 | ) | |
| 281 | 271 | } | |
| 282 | 272 | ||
| 283 | - | func nearestXtermColorExcluding(color TerminalColor, excluded map[int]bool) int { | |
| 284 | - | rgb, _ := rgbForTerminalColor(color) | |
| 285 | - | best := 0 | |
| 286 | - | bestDistance := int(^uint(0) >> 1) | |
| 287 | - | for index := 0; index < 256; index++ { | |
| 288 | - | if excluded[index] { | |
| 289 | - | continue | |
| 290 | - | } | |
| 291 | - | cr, cg, cb := xterm256RGB(index) | |
| 292 | - | dr := rgb[0] - cr | |
| 293 | - | dg := rgb[1] - cg | |
| 294 | - | db := rgb[2] - cb | |
| 295 | - | distance := dr*dr + dg*dg + db*db | |
| 296 | - | if distance < bestDistance { | |
| 297 | - | best = index | |
| 298 | - | bestDistance = distance | |
| 299 | - | } | |
| 300 | - | } | |
| 301 | - | return best | |
| 302 | - | } | |
| 303 | - | ||
| 304 | 273 | // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json | |
| 305 | 274 | // to detected. Empty/auto/default keeps the built-in detected theme. | |
| 306 | 275 | // If preferred is set, it may be a theme name, a basename without | |
| @@ -480,55 +449,55 @@ func themeFilesIn(dir string) ([]string, error) { | |||
| 480 | 449 | ||
| 481 | 450 | func applyThemeOverrides(th Theme, o ThemeOverrides) Theme { | |
| 482 | 451 | if o.FG != nil { | |
| 483 | - | th.FG = *o.FG | |
| 452 | + | th.FG = o.FG.TerminalColor | |
| 484 | 453 | } | |
| 485 | 454 | if o.Muted != nil { | |
| 486 | - | th.Muted = *o.Muted | |
| 455 | + | th.Muted = o.Muted.TerminalColor | |
| 487 | 456 | } | |
| 488 | 457 | if o.Accent != nil { | |
| 489 | - | th.Accent = *o.Accent | |
| 458 | + | th.Accent = o.Accent.TerminalColor | |
| 490 | 459 | } | |
| 491 | 460 | if o.Background != nil { | |
| 492 | 461 | bg := o.Background.TerminalColor | |
| 493 | 462 | th.Background = &bg | |
| 494 | 463 | } | |
| 495 | 464 | if o.User != nil { | |
| 496 | - | th.User = *o.User | |
| 465 | + | th.User = o.User.TerminalColor | |
| 497 | 466 | } | |
| 498 | 467 | if o.UserBubbleBG != nil { | |
| 499 | 468 | th.UserBubbleBG = o.UserBubbleBG.TerminalColor | |
| 500 | 469 | } | |
| 501 | 470 | if o.UserBubbleFG != nil { | |
| 502 | - | th.UserBubbleFG = *o.UserBubbleFG | |
| 471 | + | th.UserBubbleFG = o.UserBubbleFG.TerminalColor | |
| 503 | 472 | } | |
| 504 | 473 | if o.Assistant != nil { | |
| 505 | - | th.Assistant = *o.Assistant | |
| 474 | + | th.Assistant = o.Assistant.TerminalColor | |
| 506 | 475 | } | |
| 507 | 476 | if o.Tool != nil { | |
| 508 | - | th.Tool = *o.Tool | |
| 477 | + | th.Tool = o.Tool.TerminalColor | |
| 509 | 478 | } | |
| 510 | 479 | if o.ToolOut != nil { | |
| 511 | - | th.ToolOut = *o.ToolOut | |
| 480 | + | th.ToolOut = o.ToolOut.TerminalColor | |
| 512 | 481 | } | |
| 513 | 482 | if o.Error != nil { | |
| 514 | - | th.Error = *o.Error | |
| 483 | + | th.Error = o.Error.TerminalColor | |
| 515 | 484 | } | |
| 516 | 485 | if o.Warning != nil { | |
| 517 | - | th.Warning = *o.Warning | |
| 486 | + | th.Warning = o.Warning.TerminalColor | |
| 518 | 487 | } | |
| 519 | 488 | if o.Spinner != nil { | |
| 520 | - | th.Spinner = *o.Spinner | |
| 489 | + | th.Spinner = o.Spinner.TerminalColor | |
| 521 | 490 | } | |
| 522 | 491 | if o.ThinkingMax != nil { | |
| 523 | - | th.ThinkingMax = *o.ThinkingMax | |
| 492 | + | th.ThinkingMax = o.ThinkingMax.TerminalColor | |
| 524 | 493 | } else if o.ThinkingMaxCamel != nil { | |
| 525 | - | th.ThinkingMax = *o.ThinkingMaxCamel | |
| 494 | + | th.ThinkingMax = o.ThinkingMaxCamel.TerminalColor | |
| 526 | 495 | } | |
| 527 | 496 | if o.SelectionBG != nil { | |
| 528 | - | th.SelectionBG = *o.SelectionBG | |
| 497 | + | th.SelectionBG = o.SelectionBG.TerminalColor | |
| 529 | 498 | } | |
| 530 | 499 | if o.SelectionFG != nil { | |
| 531 | - | th.SelectionFG = *o.SelectionFG | |
| 500 | + | th.SelectionFG = o.SelectionFG.TerminalColor | |
| 532 | 501 | } | |
| 533 | 502 | if len(o.SpinnerFrames) > 0 { | |
| 534 | 503 | th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...) | |
| @@ -79,23 +79,23 @@ func (m *ThemeFileColorModes) UnmarshalJSON(data []byte) error { | |||||
| 79 | // override only the colors it cares about and inherit the built-in | 79 | // override only the colors it cares about and inherit the built-in | ||
| 80 | // dark/light defaults for everything else. | 80 | // dark/light defaults for everything else. | ||
| 81 | type ThemeOverrides struct { | 81 | type ThemeOverrides struct { | ||
| 82 | - | FG *int `json:"fg,omitempty"` | 82 | + | FG *TerminalColorValue `json:"fg,omitempty"` |
| 83 | - | Muted *int `json:"muted,omitempty"` | 83 | + | Muted *TerminalColorValue `json:"muted,omitempty"` |
| 84 | - | Accent *int `json:"accent,omitempty"` | 84 | + | Accent *TerminalColorValue `json:"accent,omitempty"` |
| 85 | Background *TerminalColorValue `json:"background,omitempty"` | 85 | Background *TerminalColorValue `json:"background,omitempty"` | ||
| 86 | - | User *int `json:"user,omitempty"` | 86 | + | User *TerminalColorValue `json:"user,omitempty"` |
| 87 | UserBubbleBG *TerminalColorValue `json:"user_bubble_bg,omitempty"` | 87 | UserBubbleBG *TerminalColorValue `json:"user_bubble_bg,omitempty"` | ||
| 88 | - | UserBubbleFG *int `json:"user_bubble_fg,omitempty"` | 88 | + | UserBubbleFG *TerminalColorValue `json:"user_bubble_fg,omitempty"` |
| 89 | - | Assistant *int `json:"assistant,omitempty"` | 89 | + | Assistant *TerminalColorValue `json:"assistant,omitempty"` |
| 90 | - | Tool *int `json:"tool,omitempty"` | 90 | + | Tool *TerminalColorValue `json:"tool,omitempty"` |
| 91 | - | ToolOut *int `json:"tool_out,omitempty"` | 91 | + | ToolOut *TerminalColorValue `json:"tool_out,omitempty"` |
| 92 | - | Error *int `json:"error,omitempty"` | 92 | + | Error *TerminalColorValue `json:"error,omitempty"` |
| 93 | - | Warning *int `json:"warning,omitempty"` | 93 | + | Warning *TerminalColorValue `json:"warning,omitempty"` |
| 94 | - | Spinner *int `json:"spinner,omitempty"` | 94 | + | Spinner *TerminalColorValue `json:"spinner,omitempty"` |
| 95 | - | ThinkingMax *int `json:"thinking_max,omitempty"` | 95 | + | ThinkingMax *TerminalColorValue `json:"thinking_max,omitempty"` |
| 96 | - | ThinkingMaxCamel *int `json:"thinkingMax,omitempty"` | 96 | + | ThinkingMaxCamel *TerminalColorValue `json:"thinkingMax,omitempty"` |
| 97 | - | SelectionBG *int `json:"selection_bg,omitempty"` | 97 | + | SelectionBG *TerminalColorValue `json:"selection_bg,omitempty"` |
| 98 | - | SelectionFG *int `json:"selection_fg,omitempty"` | 98 | + | SelectionFG *TerminalColorValue `json:"selection_fg,omitempty"` |
| 99 | SpinnerFrames []string `json:"spinner_frames,omitempty"` | 99 | SpinnerFrames []string `json:"spinner_frames,omitempty"` | ||
| 100 | SpinnerMessages []string `json:"spinner_messages,omitempty"` | 100 | SpinnerMessages []string `json:"spinner_messages,omitempty"` | ||
| 101 | SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"` | 101 | SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"` | ||
| @@ -187,51 +187,34 @@ func InheritedTheme(detected Theme) Theme { | |||||
| 187 | } | 187 | } | ||
| 188 | t := withTerminalProfile(base, detected) | 188 | t := withTerminalProfile(base, detected) | ||
| 189 | t.Inherited = true | 189 | t.Inherited = true | ||
| 190 | - | t.ColorMode = ColorMode256 | |||
| 191 | - | if t.Terminal.TrueColor { | |||
| 192 | - | t.ColorMode = ColorModeTrueColor | |||
| 193 | - | } | |||
| 194 | 190 | ||||
| 195 | if t.Terminal.HasForeground { | 191 | if t.Terminal.HasForeground { | ||
| 196 | - | t.FG = terminalColorIndex(t.Terminal.Foreground) | 192 | + | t.FG = t.Terminal.Foreground |
| 197 | } | 193 | } | ||
| 198 | 194 | ||||
| 199 | accentFallback := t.Accent | 195 | accentFallback := t.Accent | ||
| 200 | if IsLightTheme(t) { | 196 | if IsLightTheme(t) { | ||
| 201 | accentFallback = Light.Accent | 197 | accentFallback = Light.Accent | ||
| 202 | } | 198 | } | ||
| 203 | - | t.Accent = terminalPaletteIndex(t.Terminal, accentFallback, 12, 4, 14, 6, 13, 5) | 199 | + | t.Accent = terminalPaletteColor(t.Terminal, accentFallback, |
| 200 | + | ansiBrightBlue, ansiBlue, ansiBrightCyan, ansiCyan, ansiBrightMagenta, ansiMagenta) | |||
| 204 | t.Assistant = t.Accent | 201 | t.Assistant = t.Accent | ||
| 205 | - | t.Tool = terminalPaletteIndex(t.Terminal, t.Tool, 10, 2) | 202 | + | t.Tool = terminalPaletteColor(t.Terminal, t.Tool, ansiBrightGreen, ansiGreen) |
| 206 | - | t.Error = terminalPaletteIndex(t.Terminal, t.Error, 9, 1) | 203 | + | t.Error = terminalPaletteColor(t.Terminal, t.Error, ansiBrightRed, ansiRed) |
| 207 | - | t.Warning = terminalPaletteIndex(t.Terminal, t.Warning, 11, 3) | 204 | + | t.Warning = terminalPaletteColor(t.Terminal, t.Warning, ansiBrightYellow, ansiYellow) |
| 208 | - | t.Spinner = terminalPaletteIndex(t.Terminal, t.Spinner, 13, 5) | 205 | + | t.Spinner = terminalPaletteColor(t.Terminal, t.Spinner, ansiBrightMagenta, ansiMagenta) |
| 209 | - | t.ThinkingMax = terminalPaletteIndex(t.Terminal, t.ThinkingMax, 13, 5) | 206 | + | t.ThinkingMax = terminalPaletteColor(t.Terminal, t.ThinkingMax, ansiBrightMagenta, ansiMagenta) |
| 210 | - | t.User = terminalPaletteIndex(t.Terminal, t.User, 11, 3) | 207 | + | t.User = terminalPaletteColor(t.Terminal, t.User, ansiBrightYellow, ansiYellow) |
| 211 | 208 | ||||
| 212 | if t.Terminal.HasForeground && t.Terminal.HasBackground { | 209 | if t.Terminal.HasForeground && t.Terminal.HasBackground { | ||
| 213 | - | t.Muted = terminalColorIndex(t.DimColor(t.FG, 45)) | 210 | + | t.Muted = t.DimColor(t.FG, 45) |
| 214 | t.ToolOut = t.Muted | 211 | t.ToolOut = t.Muted | ||
| 215 | background := t.Terminal.Background | 212 | background := t.Terminal.Background | ||
| 216 | foreground := t.Terminal.Foreground | 213 | foreground := t.Terminal.Foreground | ||
| 217 | t.UserBubbleBG = blendTerminalColors(background, foreground, 12) | 214 | t.UserBubbleBG = blendTerminalColors(background, foreground, 12) | ||
| 218 | - | t.SelectionBG = terminalColorIndex(blendTerminalColors(background, foreground, 25)) | 215 | + | t.SelectionBG = blendTerminalColors(background, foreground, 25) |
| 219 | } | 216 | } | ||
| 220 | 217 | ||||
| 221 | - | // FG256 receives an index rather than a semantic role. Keep the inherited | |||
| 222 | - | // foreground index distinct from accent/status roles so a terminal whose | |||
| 223 | - | // default foreground happens to be ANSI blue does not recolor every blue | |||
| 224 | - | // accent as the terminal default foreground. | |||
| 225 | - | if t.Terminal.HasForeground { | |||
| 226 | - | used := map[int]bool{ | |||
| 227 | - | t.Muted: true, t.Accent: true, t.Assistant: true, t.Tool: true, | |||
| 228 | - | t.ToolOut: true, t.Error: true, t.Warning: true, t.Spinner: true, | |||
| 229 | - | t.ThinkingMax: true, t.User: true, t.SelectionBG: true, | |||
| 230 | - | } | |||
| 231 | - | if used[t.FG] { | |||
| 232 | - | t.FG = nearestXtermColorExcluding(t.Terminal.Foreground, used) | |||
| 233 | - | } | |||
| 234 | - | } | |||
| 235 | t.UserBubbleFG = t.FG | 218 | t.UserBubbleFG = t.FG | ||
| 236 | t.SelectionFG = t.FG | 219 | t.SelectionFG = t.FG | ||
| 237 | 220 | ||||
| @@ -247,28 +230,35 @@ func withTerminalProfile(base, detected Theme) Theme { | |||||
| 247 | return base | 230 | return base | ||
| 248 | } | 231 | } | ||
| 249 | 232 | ||||
| 250 | - | func terminalPaletteIndex(profile TerminalProfile, fallback int, preferred ...int) int { | 233 | + | // Standard ANSI palette slots used as inherited role preferences. |
| 234 | + | const ( | |||
| 235 | + | ansiRed = 1 | |||
| 236 | + | ansiGreen = 2 | |||
| 237 | + | ansiYellow = 3 | |||
| 238 | + | ansiBlue = 4 | |||
| 239 | + | ansiMagenta = 5 | |||
| 240 | + | ansiCyan = 6 | |||
| 241 | + | ansiBrightRed = 9 | |||
| 242 | + | ansiBrightGreen = 10 | |||
| 243 | + | ansiBrightYellow = 11 | |||
| 244 | + | ansiBrightBlue = 12 | |||
| 245 | + | ansiBrightMagenta = 13 | |||
| 246 | + | ansiBrightCyan = 14 | |||
| 247 | + | ) | |||
| 248 | + | ||||
| 249 | + | func terminalPaletteColor(profile TerminalProfile, fallback TerminalColor, preferred ...int) TerminalColor { | |||
| 251 | for _, index := range preferred { | 250 | for _, index := range preferred { | ||
| 252 | if _, ok := profile.PaletteColor(index); ok { | 251 | if _, ok := profile.PaletteColor(index); ok { | ||
| 253 | - | return index | 252 | + | // Keep the palette slot as the semantic value. Truecolor |
| 253 | + | // resolution expands it back to the terminal-reported RGB; | |||
| 254 | + | // 256-color output preserves the slot rather than re-quantizing | |||
| 255 | + | // the reported RGB to a nearby cube entry. | |||
| 256 | + | return Color256(index) | |||
| 254 | } | 257 | } | ||
| 255 | } | 258 | } | ||
| 256 | return fallback | 259 | return fallback | ||
| 257 | } | 260 | } | ||
| 258 | 261 | ||||
| 259 | - | func terminalColorIndex(color TerminalColor) int { | |||
| 260 | - | if color.Mode == terminalColor256 { | |||
| 261 | - | return clampXtermIndex(color.Index) | |||
| 262 | - | } | |||
| 263 | - | if color.Mode == terminalColorANSI { | |||
| 264 | - | if index, ok := ansiSGRToXtermIndex(color.Index); ok { | |||
| 265 | - | return index | |||
| 266 | - | } | |||
| 267 | - | return 0 | |||
| 268 | - | } | |||
| 269 | - | return nearestXtermColor(color.R, color.G, color.B) | |||
| 270 | - | } | |||
| 271 | - | ||||
| 272 | func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor { | 262 | func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor { | ||
| 273 | fromRGB, _ := rgbForTerminalColor(from) | 263 | fromRGB, _ := rgbForTerminalColor(from) | ||
| 274 | toRGB, _ := rgbForTerminalColor(to) | 264 | toRGB, _ := rgbForTerminalColor(to) | ||
| @@ -280,27 +270,6 @@ func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor { | |||||
| 280 | ) | 270 | ) | ||
| 281 | } | 271 | } | ||
| 282 | 272 | ||||
| 283 | - | func nearestXtermColorExcluding(color TerminalColor, excluded map[int]bool) int { | |||
| 284 | - | rgb, _ := rgbForTerminalColor(color) | |||
| 285 | - | best := 0 | |||
| 286 | - | bestDistance := int(^uint(0) >> 1) | |||
| 287 | - | for index := 0; index < 256; index++ { | |||
| 288 | - | if excluded[index] { | |||
| 289 | - | continue | |||
| 290 | - | } | |||
| 291 | - | cr, cg, cb := xterm256RGB(index) | |||
| 292 | - | dr := rgb[0] - cr | |||
| 293 | - | dg := rgb[1] - cg | |||
| 294 | - | db := rgb[2] - cb | |||
| 295 | - | distance := dr*dr + dg*dg + db*db | |||
| 296 | - | if distance < bestDistance { | |||
| 297 | - | best = index | |||
| 298 | - | bestDistance = distance | |||
| 299 | - | } | |||
| 300 | - | } | |||
| 301 | - | return best | |||
| 302 | - | } | |||
| 303 | - | ||||
| 304 | // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json | 273 | // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json | ||
| 305 | // to detected. Empty/auto/default keeps the built-in detected theme. | 274 | // to detected. Empty/auto/default keeps the built-in detected theme. | ||
| 306 | // If preferred is set, it may be a theme name, a basename without | 275 | // If preferred is set, it may be a theme name, a basename without | ||
| @@ -480,55 +449,55 @@ func themeFilesIn(dir string) ([]string, error) { | |||||
| 480 | 449 | ||||
| 481 | func applyThemeOverrides(th Theme, o ThemeOverrides) Theme { | 450 | func applyThemeOverrides(th Theme, o ThemeOverrides) Theme { | ||
| 482 | if o.FG != nil { | 451 | if o.FG != nil { | ||
| 483 | - | th.FG = *o.FG | 452 | + | th.FG = o.FG.TerminalColor |
| 484 | } | 453 | } | ||
| 485 | if o.Muted != nil { | 454 | if o.Muted != nil { | ||
| 486 | - | th.Muted = *o.Muted | 455 | + | th.Muted = o.Muted.TerminalColor |
| 487 | } | 456 | } | ||
| 488 | if o.Accent != nil { | 457 | if o.Accent != nil { | ||
| 489 | - | th.Accent = *o.Accent | 458 | + | th.Accent = o.Accent.TerminalColor |
| 490 | } | 459 | } | ||
| 491 | if o.Background != nil { | 460 | if o.Background != nil { | ||
| 492 | bg := o.Background.TerminalColor | 461 | bg := o.Background.TerminalColor | ||
| 493 | th.Background = &bg | 462 | th.Background = &bg | ||
| 494 | } | 463 | } | ||
| 495 | if o.User != nil { | 464 | if o.User != nil { | ||
| 496 | - | th.User = *o.User | 465 | + | th.User = o.User.TerminalColor |
| 497 | } | 466 | } | ||
| 498 | if o.UserBubbleBG != nil { | 467 | if o.UserBubbleBG != nil { | ||
| 499 | th.UserBubbleBG = o.UserBubbleBG.TerminalColor | 468 | th.UserBubbleBG = o.UserBubbleBG.TerminalColor | ||
| 500 | } | 469 | } | ||
| 501 | if o.UserBubbleFG != nil { | 470 | if o.UserBubbleFG != nil { | ||
| 502 | - | th.UserBubbleFG = *o.UserBubbleFG | 471 | + | th.UserBubbleFG = o.UserBubbleFG.TerminalColor |
| 503 | } | 472 | } | ||
| 504 | if o.Assistant != nil { | 473 | if o.Assistant != nil { | ||
| 505 | - | th.Assistant = *o.Assistant | 474 | + | th.Assistant = o.Assistant.TerminalColor |
| 506 | } | 475 | } | ||
| 507 | if o.Tool != nil { | 476 | if o.Tool != nil { | ||
| 508 | - | th.Tool = *o.Tool | 477 | + | th.Tool = o.Tool.TerminalColor |
| 509 | } | 478 | } | ||
| 510 | if o.ToolOut != nil { | 479 | if o.ToolOut != nil { | ||
| 511 | - | th.ToolOut = *o.ToolOut | 480 | + | th.ToolOut = o.ToolOut.TerminalColor |
| 512 | } | 481 | } | ||
| 513 | if o.Error != nil { | 482 | if o.Error != nil { | ||
| 514 | - | th.Error = *o.Error | 483 | + | th.Error = o.Error.TerminalColor |
| 515 | } | 484 | } | ||
| 516 | if o.Warning != nil { | 485 | if o.Warning != nil { | ||
| 517 | - | th.Warning = *o.Warning | 486 | + | th.Warning = o.Warning.TerminalColor |
| 518 | } | 487 | } | ||
| 519 | if o.Spinner != nil { | 488 | if o.Spinner != nil { | ||
| 520 | - | th.Spinner = *o.Spinner | 489 | + | th.Spinner = o.Spinner.TerminalColor |
| 521 | } | 490 | } | ||
| 522 | if o.ThinkingMax != nil { | 491 | if o.ThinkingMax != nil { | ||
| 523 | - | th.ThinkingMax = *o.ThinkingMax | 492 | + | th.ThinkingMax = o.ThinkingMax.TerminalColor |
| 524 | } else if o.ThinkingMaxCamel != nil { | 493 | } else if o.ThinkingMaxCamel != nil { | ||
| 525 | - | th.ThinkingMax = *o.ThinkingMaxCamel | 494 | + | th.ThinkingMax = o.ThinkingMaxCamel.TerminalColor |
| 526 | } | 495 | } | ||
| 527 | if o.SelectionBG != nil { | 496 | if o.SelectionBG != nil { | ||
| 528 | - | th.SelectionBG = *o.SelectionBG | 497 | + | th.SelectionBG = o.SelectionBG.TerminalColor |
| 529 | } | 498 | } | ||
| 530 | if o.SelectionFG != nil { | 499 | if o.SelectionFG != nil { | ||
| 531 | - | th.SelectionFG = *o.SelectionFG | 500 | + | th.SelectionFG = o.SelectionFG.TerminalColor |
| 532 | } | 501 | } | ||
| 533 | if len(o.SpinnerFrames) > 0 { | 502 | if len(o.SpinnerFrames) > 0 { | ||
| 534 | th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...) | 503 | th.SpinnerFrames = append([]string(nil), o.SpinnerFrames...) | ||
M packages/tui/theme_loader_test.go +63 -45
| @@ -3,18 +3,25 @@ package tui | |||
| 3 | 3 | import ( | |
| 4 | 4 | "os" | |
| 5 | 5 | "path/filepath" | |
| 6 | + | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | - | func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |
| 10 | + | func writeThemeFile(t *testing.T, name, body string) string { | |
| 11 | + | t.Helper() | |
| 10 | 12 | home := t.TempDir() | |
| 11 | 13 | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | |
| 12 | 14 | t.Fatal(err) | |
| 13 | 15 | } | |
| 14 | - | path := filepath.Join(home, "themes", "partial.json") | |
| 15 | - | if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"accent":204}}}`), 0o644); err != nil { | |
| 16 | + | path := filepath.Join(home, "themes", name+".json") | |
| 17 | + | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { | |
| 16 | 18 | t.Fatal(err) | |
| 17 | 19 | } | |
| 20 | + | return home | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |
| 24 | + | home := writeThemeFile(t, "partial", `{"colors":{"dark":{"accent":204}}}`) | |
| 18 | 25 | ||
| 19 | 26 | th, name, err := LoadThemeFromHome(home, "partial", Dark) | |
| 20 | 27 | if err != nil { | |
| @@ -23,11 +30,11 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |||
| 23 | 30 | if name != "partial" { | |
| 24 | 31 | t.Fatalf("name = %q, want partial", name) | |
| 25 | 32 | } | |
| 26 | - | if th.Accent != 204 { | |
| 27 | - | t.Fatalf("accent = %d, want 204", th.Accent) | |
| 33 | + | if th.Accent != Color256(204) { | |
| 34 | + | t.Fatalf("accent = %#v, want 204", th.Accent) | |
| 28 | 35 | } | |
| 29 | 36 | if th.FG != Dark.FG { | |
| 30 | - | t.Fatalf("fg = %d, want inherited %d", th.FG, Dark.FG) | |
| 37 | + | t.Fatalf("fg = %#v, want inherited %#v", th.FG, Dark.FG) | |
| 31 | 38 | } | |
| 32 | 39 | if len(th.SpinnerFrames) == 0 || len(th.SpinnerMessages) == 0 { | |
| 33 | 40 | t.Fatal("spinner defaults should be inherited") | |
| @@ -35,33 +42,19 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |||
| 35 | 42 | } | |
| 36 | 43 | ||
| 37 | 44 | func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) { | |
| 38 | - | home := t.TempDir() | |
| 39 | - | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | |
| 40 | - | t.Fatal(err) | |
| 41 | - | } | |
| 42 | - | path := filepath.Join(home, "themes", "thinking.json") | |
| 43 | - | if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"thinkingMax":201}}}`), 0o644); err != nil { | |
| 44 | - | t.Fatal(err) | |
| 45 | - | } | |
| 45 | + | home := writeThemeFile(t, "thinking", `{"colors":{"dark":{"thinkingMax":201}}}`) | |
| 46 | 46 | ||
| 47 | 47 | th, _, err := LoadThemeFromHome(home, "thinking", Dark) | |
| 48 | 48 | if err != nil { | |
| 49 | 49 | t.Fatal(err) | |
| 50 | 50 | } | |
| 51 | - | if th.ThinkingMax != 201 { | |
| 52 | - | t.Fatalf("thinking max = %d, want 201", th.ThinkingMax) | |
| 51 | + | if th.ThinkingMax != Color256(201) { | |
| 52 | + | t.Fatalf("thinking max = %#v, want 201", th.ThinkingMax) | |
| 53 | 53 | } | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | 56 | func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) { | |
| 57 | - | home := t.TempDir() | |
| 58 | - | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | |
| 59 | - | t.Fatal(err) | |
| 60 | - | } | |
| 61 | - | path := filepath.Join(home, "themes", "spinner.json") | |
| 62 | - | if err := os.WriteFile(path, []byte(`{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`), 0o644); err != nil { | |
| 63 | - | t.Fatal(err) | |
| 64 | - | } | |
| 57 | + | home := writeThemeFile(t, "spinner", `{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`) | |
| 65 | 58 | ||
| 66 | 59 | th, _, err := LoadThemeFromHome(home, "spinner", Dark) | |
| 67 | 60 | if err != nil { | |
| @@ -74,19 +67,12 @@ func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) { | |||
| 74 | 67 | t.Fatalf("spinner overrides not applied: %#v %#v %d", th.SpinnerFrames, th.SpinnerMessages, th.SpinnerIntervalMS) | |
| 75 | 68 | } | |
| 76 | 69 | if th.Accent != Dark.Accent { | |
| 77 | - | t.Fatalf("accent = %d, want inherited %d", th.Accent, Dark.Accent) | |
| 70 | + | t.Fatalf("accent = %#v, want inherited %#v", th.Accent, Dark.Accent) | |
| 78 | 71 | } | |
| 79 | 72 | } | |
| 80 | 73 | ||
| 81 | 74 | func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | |
| 82 | - | home := t.TempDir() | |
| 83 | - | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | |
| 84 | - | t.Fatal(err) | |
| 85 | - | } | |
| 86 | - | path := filepath.Join(home, "themes", "darkonly.json") | |
| 87 | - | if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`), 0o644); err != nil { | |
| 88 | - | t.Fatal(err) | |
| 89 | - | } | |
| 75 | + | home := writeThemeFile(t, "darkonly", `{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`) | |
| 90 | 76 | ||
| 91 | 77 | th, _, err := LoadThemeFromHome(home, "darkonly", Light) | |
| 92 | 78 | if err != nil { | |
| @@ -102,31 +88,63 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | |||
| 102 | 88 | t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS) | |
| 103 | 89 | } | |
| 104 | 90 | if th.FG != Light.FG { | |
| 105 | - | t.Fatalf("fg = %d, want inherited light fg %d", th.FG, Light.FG) | |
| 91 | + | t.Fatalf("fg = %#v, want inherited light fg %#v", th.FG, Light.FG) | |
| 106 | 92 | } | |
| 107 | 93 | } | |
| 108 | 94 | ||
| 109 | 95 | func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) { | |
| 110 | - | home := t.TempDir() | |
| 111 | - | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | |
| 112 | - | t.Fatal(err) | |
| 113 | - | } | |
| 114 | - | path := filepath.Join(home, "themes", "shared.json") | |
| 115 | - | if err := os.WriteFile(path, []byte(`{"colors":{"accent":204,"spinner_messages":["ship"]}}`), 0o644); err != nil { | |
| 116 | - | t.Fatal(err) | |
| 117 | - | } | |
| 96 | + | home := writeThemeFile(t, "shared", `{"colors":{"accent":204,"spinner_messages":["ship"]}}`) | |
| 118 | 97 | ||
| 119 | 98 | th, _, err := LoadThemeFromHome(home, "shared", Light) | |
| 120 | 99 | if err != nil { | |
| 121 | 100 | t.Fatal(err) | |
| 122 | 101 | } | |
| 123 | - | if th.Accent != 204 { | |
| 124 | - | t.Fatalf("accent = %d, want 204", th.Accent) | |
| 102 | + | if th.Accent != Color256(204) { | |
| 103 | + | t.Fatalf("accent = %#v, want 204", th.Accent) | |
| 125 | 104 | } | |
| 126 | 105 | if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "ship" { | |
| 127 | 106 | t.Fatalf("spinner messages = %#v, want ship", th.SpinnerMessages) | |
| 128 | 107 | } | |
| 129 | 108 | if th.FG != Light.FG { | |
| 130 | - | t.Fatalf("fg = %d, want inherited %d", th.FG, Light.FG) | |
| 109 | + | t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestLoadThemeAcceptsRGBSemanticColors(t *testing.T) { | |
| 114 | + | home := writeThemeFile(t, "rgb", `{"colors":{"dark":{"fg":"#123456","muted":{"mode":"rgb","r":18,"g":52,"b":86},"accent":{"mode":"rgb","r":200,"g":100,"b":50},"user": {"mode":"ansi","index":31},"selection_bg":{"mode":"256","index":237},"selection_fg":250}}}`) | |
| 115 | + | ||
| 116 | + | detected := Dark | |
| 117 | + | detected.Terminal.TrueColor = true | |
| 118 | + | th, _, err := LoadThemeFromHome(home, "rgb", detected) | |
| 119 | + | if err != nil { | |
| 120 | + | t.Fatal(err) | |
| 121 | + | } | |
| 122 | + | if !th.Terminal.TrueColor { | |
| 123 | + | t.Fatal("terminal color mode = 256, want truecolor") | |
| 124 | + | } | |
| 125 | + | if th.FG != ColorRGB(0x12, 0x34, 0x56) { | |
| 126 | + | t.Fatalf("foreground = %#v, want RGB", th.FG) | |
| 127 | + | } | |
| 128 | + | if th.Accent != ColorRGB(200, 100, 50) { | |
| 129 | + | t.Fatalf("accent = %#v, want RGB", th.Accent) | |
| 130 | + | } | |
| 131 | + | if th.SelectionBG != Color256(237) || th.SelectionFG != Color256(250) { | |
| 132 | + | t.Fatalf("indexed semantic colors were not preserved: bg=%#v fg=%#v", th.SelectionBG, th.SelectionFG) | |
| 133 | + | } | |
| 134 | + | if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;200;100;50m") { | |
| 135 | + | t.Fatalf("RGB accent was not rendered as truecolor: %q", got) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestLoadThemeQuantizesRGBSemanticColorsWithoutTrueColor(t *testing.T) { | |
| 140 | + | home := writeThemeFile(t, "rgb-fallback", `{"colors":{"dark":{"accent":"#c86432"}}}`) | |
| 141 | + | ||
| 142 | + | th, _, err := LoadThemeFromHome(home, "rgb-fallback", Dark) | |
| 143 | + | if err != nil { | |
| 144 | + | t.Fatal(err) | |
| 145 | + | } | |
| 146 | + | want := nearestXtermColor(200, 100, 50) | |
| 147 | + | if got := th.FG256(th.Accent, "x"); got != sgrFG(want)+"x\x1b[0m" { | |
| 148 | + | t.Fatalf("RGB accent fallback = %q, want xterm-256 index %d", got, want) | |
| 131 | 149 | } | |
| 132 | 150 | } | |
| @@ -3,18 +3,25 @@ package tui | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "os" | 4 | "os" | ||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | + | "strings" | |||
| 6 | "testing" | 7 | "testing" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | - | func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | 10 | + | func writeThemeFile(t *testing.T, name, body string) string { |
| 11 | + | t.Helper() | |||
| 10 | home := t.TempDir() | 12 | home := t.TempDir() | ||
| 11 | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | 13 | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | ||
| 12 | t.Fatal(err) | 14 | t.Fatal(err) | ||
| 13 | } | 15 | } | ||
| 14 | - | path := filepath.Join(home, "themes", "partial.json") | 16 | + | path := filepath.Join(home, "themes", name+".json") |
| 15 | - | if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"accent":204}}}`), 0o644); err != nil { | 17 | + | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| 16 | t.Fatal(err) | 18 | t.Fatal(err) | ||
| 17 | } | 19 | } | ||
| 20 | + | return home | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |||
| 24 | + | home := writeThemeFile(t, "partial", `{"colors":{"dark":{"accent":204}}}`) | |||
| 18 | 25 | ||||
| 19 | th, name, err := LoadThemeFromHome(home, "partial", Dark) | 26 | th, name, err := LoadThemeFromHome(home, "partial", Dark) | ||
| 20 | if err != nil { | 27 | if err != nil { | ||
| @@ -23,11 +30,11 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |||||
| 23 | if name != "partial" { | 30 | if name != "partial" { | ||
| 24 | t.Fatalf("name = %q, want partial", name) | 31 | t.Fatalf("name = %q, want partial", name) | ||
| 25 | } | 32 | } | ||
| 26 | - | if th.Accent != 204 { | 33 | + | if th.Accent != Color256(204) { |
| 27 | - | t.Fatalf("accent = %d, want 204", th.Accent) | 34 | + | t.Fatalf("accent = %#v, want 204", th.Accent) |
| 28 | } | 35 | } | ||
| 29 | if th.FG != Dark.FG { | 36 | if th.FG != Dark.FG { | ||
| 30 | - | t.Fatalf("fg = %d, want inherited %d", th.FG, Dark.FG) | 37 | + | t.Fatalf("fg = %#v, want inherited %#v", th.FG, Dark.FG) |
| 31 | } | 38 | } | ||
| 32 | if len(th.SpinnerFrames) == 0 || len(th.SpinnerMessages) == 0 { | 39 | if len(th.SpinnerFrames) == 0 || len(th.SpinnerMessages) == 0 { | ||
| 33 | t.Fatal("spinner defaults should be inherited") | 40 | t.Fatal("spinner defaults should be inherited") | ||
| @@ -35,33 +42,19 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) { | |||||
| 35 | } | 42 | } | ||
| 36 | 43 | ||||
| 37 | func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) { | 44 | func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) { | ||
| 38 | - | home := t.TempDir() | 45 | + | home := writeThemeFile(t, "thinking", `{"colors":{"dark":{"thinkingMax":201}}}`) |
| 39 | - | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | |||
| 40 | - | t.Fatal(err) | |||
| 41 | - | } | |||
| 42 | - | path := filepath.Join(home, "themes", "thinking.json") | |||
| 43 | - | if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"thinkingMax":201}}}`), 0o644); err != nil { | |||
| 44 | - | t.Fatal(err) | |||
| 45 | - | } | |||
| 46 | 46 | ||||
| 47 | th, _, err := LoadThemeFromHome(home, "thinking", Dark) | 47 | th, _, err := LoadThemeFromHome(home, "thinking", Dark) | ||
| 48 | if err != nil { | 48 | if err != nil { | ||
| 49 | t.Fatal(err) | 49 | t.Fatal(err) | ||
| 50 | } | 50 | } | ||
| 51 | - | if th.ThinkingMax != 201 { | 51 | + | if th.ThinkingMax != Color256(201) { |
| 52 | - | t.Fatalf("thinking max = %d, want 201", th.ThinkingMax) | 52 | + | t.Fatalf("thinking max = %#v, want 201", th.ThinkingMax) |
| 53 | } | 53 | } | ||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) { | 56 | func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) { | ||
| 57 | - | home := t.TempDir() | 57 | + | home := writeThemeFile(t, "spinner", `{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`) |
| 58 | - | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | |||
| 59 | - | t.Fatal(err) | |||
| 60 | - | } | |||
| 61 | - | path := filepath.Join(home, "themes", "spinner.json") | |||
| 62 | - | if err := os.WriteFile(path, []byte(`{"spinner_frames":[".","o"],"spinner_messages":["working"],"spinner_interval_ms":200}`), 0o644); err != nil { | |||
| 63 | - | t.Fatal(err) | |||
| 64 | - | } | |||
| 65 | 58 | ||||
| 66 | th, _, err := LoadThemeFromHome(home, "spinner", Dark) | 59 | th, _, err := LoadThemeFromHome(home, "spinner", Dark) | ||
| 67 | if err != nil { | 60 | if err != nil { | ||
| @@ -74,19 +67,12 @@ func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) { | |||||
| 74 | t.Fatalf("spinner overrides not applied: %#v %#v %d", th.SpinnerFrames, th.SpinnerMessages, th.SpinnerIntervalMS) | 67 | t.Fatalf("spinner overrides not applied: %#v %#v %d", th.SpinnerFrames, th.SpinnerMessages, th.SpinnerIntervalMS) | ||
| 75 | } | 68 | } | ||
| 76 | if th.Accent != Dark.Accent { | 69 | if th.Accent != Dark.Accent { | ||
| 77 | - | t.Fatalf("accent = %d, want inherited %d", th.Accent, Dark.Accent) | 70 | + | t.Fatalf("accent = %#v, want inherited %#v", th.Accent, Dark.Accent) |
| 78 | } | 71 | } | ||
| 79 | } | 72 | } | ||
| 80 | 73 | ||||
| 81 | func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | 74 | func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | ||
| 82 | - | home := t.TempDir() | 75 | + | home := writeThemeFile(t, "darkonly", `{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`) |
| 83 | - | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | |||
| 84 | - | t.Fatal(err) | |||
| 85 | - | } | |||
| 86 | - | path := filepath.Join(home, "themes", "darkonly.json") | |||
| 87 | - | if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"spinner_frames":["◢","◣","◤","◥"],"spinner_messages":["working"],"spinner_interval_ms":120}}}`), 0o644); err != nil { | |||
| 88 | - | t.Fatal(err) | |||
| 89 | - | } | |||
| 90 | 76 | ||||
| 91 | th, _, err := LoadThemeFromHome(home, "darkonly", Light) | 77 | th, _, err := LoadThemeFromHome(home, "darkonly", Light) | ||
| 92 | if err != nil { | 78 | if err != nil { | ||
| @@ -102,31 +88,63 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) { | |||||
| 102 | t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS) | 88 | t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS) | ||
| 103 | } | 89 | } | ||
| 104 | if th.FG != Light.FG { | 90 | if th.FG != Light.FG { | ||
| 105 | - | t.Fatalf("fg = %d, want inherited light fg %d", th.FG, Light.FG) | 91 | + | t.Fatalf("fg = %#v, want inherited light fg %#v", th.FG, Light.FG) |
| 106 | } | 92 | } | ||
| 107 | } | 93 | } | ||
| 108 | 94 | ||||
| 109 | func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) { | 95 | func TestLoadThemeAllowsSharedColorsOverrides(t *testing.T) { | ||
| 110 | - | home := t.TempDir() | 96 | + | home := writeThemeFile(t, "shared", `{"colors":{"accent":204,"spinner_messages":["ship"]}}`) |
| 111 | - | if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil { | |||
| 112 | - | t.Fatal(err) | |||
| 113 | - | } | |||
| 114 | - | path := filepath.Join(home, "themes", "shared.json") | |||
| 115 | - | if err := os.WriteFile(path, []byte(`{"colors":{"accent":204,"spinner_messages":["ship"]}}`), 0o644); err != nil { | |||
| 116 | - | t.Fatal(err) | |||
| 117 | - | } | |||
| 118 | 97 | ||||
| 119 | th, _, err := LoadThemeFromHome(home, "shared", Light) | 98 | th, _, err := LoadThemeFromHome(home, "shared", Light) | ||
| 120 | if err != nil { | 99 | if err != nil { | ||
| 121 | t.Fatal(err) | 100 | t.Fatal(err) | ||
| 122 | } | 101 | } | ||
| 123 | - | if th.Accent != 204 { | 102 | + | if th.Accent != Color256(204) { |
| 124 | - | t.Fatalf("accent = %d, want 204", th.Accent) | 103 | + | t.Fatalf("accent = %#v, want 204", th.Accent) |
| 125 | } | 104 | } | ||
| 126 | if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "ship" { | 105 | if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "ship" { | ||
| 127 | t.Fatalf("spinner messages = %#v, want ship", th.SpinnerMessages) | 106 | t.Fatalf("spinner messages = %#v, want ship", th.SpinnerMessages) | ||
| 128 | } | 107 | } | ||
| 129 | if th.FG != Light.FG { | 108 | if th.FG != Light.FG { | ||
| 130 | - | t.Fatalf("fg = %d, want inherited %d", th.FG, Light.FG) | 109 | + | t.Fatalf("fg = %#v, want inherited %#v", th.FG, Light.FG) |
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestLoadThemeAcceptsRGBSemanticColors(t *testing.T) { | |||
| 114 | + | home := writeThemeFile(t, "rgb", `{"colors":{"dark":{"fg":"#123456","muted":{"mode":"rgb","r":18,"g":52,"b":86},"accent":{"mode":"rgb","r":200,"g":100,"b":50},"user": {"mode":"ansi","index":31},"selection_bg":{"mode":"256","index":237},"selection_fg":250}}}`) | |||
| 115 | + | ||||
| 116 | + | detected := Dark | |||
| 117 | + | detected.Terminal.TrueColor = true | |||
| 118 | + | th, _, err := LoadThemeFromHome(home, "rgb", detected) | |||
| 119 | + | if err != nil { | |||
| 120 | + | t.Fatal(err) | |||
| 121 | + | } | |||
| 122 | + | if !th.Terminal.TrueColor { | |||
| 123 | + | t.Fatal("terminal color mode = 256, want truecolor") | |||
| 124 | + | } | |||
| 125 | + | if th.FG != ColorRGB(0x12, 0x34, 0x56) { | |||
| 126 | + | t.Fatalf("foreground = %#v, want RGB", th.FG) | |||
| 127 | + | } | |||
| 128 | + | if th.Accent != ColorRGB(200, 100, 50) { | |||
| 129 | + | t.Fatalf("accent = %#v, want RGB", th.Accent) | |||
| 130 | + | } | |||
| 131 | + | if th.SelectionBG != Color256(237) || th.SelectionFG != Color256(250) { | |||
| 132 | + | t.Fatalf("indexed semantic colors were not preserved: bg=%#v fg=%#v", th.SelectionBG, th.SelectionFG) | |||
| 133 | + | } | |||
| 134 | + | if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;200;100;50m") { | |||
| 135 | + | t.Fatalf("RGB accent was not rendered as truecolor: %q", got) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestLoadThemeQuantizesRGBSemanticColorsWithoutTrueColor(t *testing.T) { | |||
| 140 | + | home := writeThemeFile(t, "rgb-fallback", `{"colors":{"dark":{"accent":"#c86432"}}}`) | |||
| 141 | + | ||||
| 142 | + | th, _, err := LoadThemeFromHome(home, "rgb-fallback", Dark) | |||
| 143 | + | if err != nil { | |||
| 144 | + | t.Fatal(err) | |||
| 145 | + | } | |||
| 146 | + | want := nearestXtermColor(200, 100, 50) | |||
| 147 | + | if got := th.FG256(th.Accent, "x"); got != sgrFG(want)+"x\x1b[0m" { | |||
| 148 | + | t.Fatalf("RGB accent fallback = %q, want xterm-256 index %d", got, want) | |||
| 131 | } | 149 | } | ||
| 132 | } | 150 | } | ||
M packages/tui/view.go +16 -14
| @@ -691,10 +691,10 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 691 | 691 | if innerWidth < 1 { | |
| 692 | 692 | innerWidth = 1 | |
| 693 | 693 | } | |
| 694 | + | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FGColor(v.Theme.Accent, "▌ ")) | |
| 694 | 695 | row := func(content string) string { | |
| 695 | 696 | inner := strings.Repeat(" ", leftGutter) + content | |
| 696 | 697 | padded := v.Theme.UserBubble(inner, width-2) | |
| 697 | - | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ ")) | |
| 698 | 698 | return bar + padded | |
| 699 | 699 | } | |
| 700 | 700 | if v.CompactUser || v.CompactMode { | |
| @@ -708,9 +708,9 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 708 | 708 | } | |
| 709 | 709 | row = func(content string) string { | |
| 710 | 710 | if v.CompactMode { | |
| 711 | - | content = v.Theme.FG256(v.Theme.Muted, content) | |
| 711 | + | content = v.Theme.FGColor(v.Theme.Muted, content) | |
| 712 | 712 | } | |
| 713 | - | return v.Theme.FG256(v.Theme.Accent, "▌ ") + content | |
| 713 | + | return v.Theme.FGColor(v.Theme.Accent, "▌ ") + content | |
| 714 | 714 | } | |
| 715 | 715 | } | |
| 716 | 716 | var bubble []string | |
| @@ -978,7 +978,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 978 | 978 | // renderLiveToolResult uses the transcript's text renderer so every rich | |
| 979 | 979 | // confirmation preview immediately gets the same colors, numbered gutters, | |
| 980 | 980 | // and syntax highlighting as its final tool result. | |
| 981 | - | func (v *View) renderLiveToolResult(text string, width, color int, sourcePath string) []string { | |
| 981 | + | func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string { | |
| 982 | 982 | return v.renderToolText(text, width, color, sourcePath, 1) | |
| 983 | 983 | } | |
| 984 | 984 | ||
| @@ -1368,8 +1368,9 @@ func toolBoxSide(th Theme, line string, width int) string { | |||
| 1368 | 1368 | w = 12 | |
| 1369 | 1369 | } | |
| 1370 | 1370 | margin := strings.Repeat(" ", toolBoxOuterMargin) | |
| 1371 | - | left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad) | |
| 1372 | - | right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│") | |
| 1371 | + | mutedEdge := th.FGColor(th.Muted, "│") | |
| 1372 | + | left := mutedEdge + strings.Repeat(" ", toolBoxInnerPad) | |
| 1373 | + | right := strings.Repeat(" ", toolBoxInnerPad) + mutedEdge | |
| 1373 | 1374 | inner := w - 2 - 2*toolBoxInnerPad // available between the two pads | |
| 1374 | 1375 | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | |
| 1375 | 1376 | ||
| @@ -1404,8 +1405,9 @@ func toolBoxSideWithImage(th Theme, line string, imgCells, width int) string { | |||
| 1404 | 1405 | w = 12 | |
| 1405 | 1406 | } | |
| 1406 | 1407 | margin := strings.Repeat(" ", toolBoxOuterMargin) | |
| 1407 | - | left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad) | |
| 1408 | - | right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│") | |
| 1408 | + | mutedEdge := th.FGColor(th.Muted, "│") | |
| 1409 | + | left := mutedEdge + strings.Repeat(" ", toolBoxInnerPad) | |
| 1410 | + | right := strings.Repeat(" ", toolBoxInnerPad) + mutedEdge | |
| 1409 | 1411 | inner := w - 2 - 2*toolBoxInnerPad | |
| 1410 | 1412 | ||
| 1411 | 1413 | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | |
| @@ -1467,7 +1469,7 @@ func trimLeadingSpaces(s string, n int) string { | |||
| 1467 | 1469 | // like a unified diff gets +/- coloring. Image blocks are rendered | |
| 1468 | 1470 | // inline when the terminal supports a protocol, else as a text | |
| 1469 | 1471 | // placeholder with dimensions. | |
| 1470 | - | func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string, startLine int) []string { | |
| 1472 | + | func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string { | |
| 1471 | 1473 | var body []string | |
| 1472 | 1474 | hasImage := false | |
| 1473 | 1475 | for _, b := range blocks { | |
| @@ -1511,7 +1513,7 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string { | |||
| 1511 | 1513 | // text contains a unified-diff section (lines starting with "--- " / | |
| 1512 | 1514 | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | |
| 1513 | 1515 | // colors matching git diff conventions. | |
| 1514 | - | func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string { | |
| 1516 | + | func (v *View) renderToolText(text string, width int, defaultColor TerminalColor, sourcePath string, startLine int) []string { | |
| 1515 | 1517 | // Legacy path: transcripts saved before we dropped line numbers | |
| 1516 | 1518 | // from the read tool still carry " 1\t..." prefixes. Detect and | |
| 1517 | 1519 | // strip them, then fall through to the highlighter. | |
| @@ -1647,7 +1649,7 @@ func parseHunkHeader(l string) (oldStart, newStart int, ok bool) { | |||
| 1647 | 1649 | // rows use a blank gutter so wrapped content cannot be mistaken for another | |
| 1648 | 1650 | // source line. Code is syntax-highlighted if sourcePath hints at a known | |
| 1649 | 1651 | // language and otherwise uses the diff color. | |
| 1650 | - | func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) []string { | |
| 1652 | + | func (v *View) renderDiffRow(line string, width int, color TerminalColor, lineNo int, mark byte, sourcePath string) []string { | |
| 1651 | 1653 | if len(line) == 0 { | |
| 1652 | 1654 | return []string{""} | |
| 1653 | 1655 | } | |
| @@ -1901,7 +1903,7 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { | |||
| 1901 | 1903 | // line in the accent color, the trailing "[exit N] Took X.Ys" line | |
| 1902 | 1904 | // in muted type, everything else on the default tool-output color. | |
| 1903 | 1905 | // Called from renderToolText when the first line starts with "$ ". | |
| 1904 | - | func (v *View) renderBashResult(lines []string, width, defaultColor int) []string { | |
| 1906 | + | func (v *View) renderBashResult(lines []string, width int, defaultColor TerminalColor) []string { | |
| 1905 | 1907 | lines = normalizeBashOutputLines(lines) | |
| 1906 | 1908 | // Identify the footer line (exit + timing). The bash tool writes | |
| 1907 | 1909 | // it as the last non-empty line of the result. | |
| @@ -2607,7 +2609,7 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText | |||
| 2607 | 2609 | return lines | |
| 2608 | 2610 | } | |
| 2609 | 2611 | ||
| 2610 | - | func reasoningStatusColor(th Theme, reasoningText string) int { | |
| 2612 | + | func reasoningStatusColor(th Theme, reasoningText string) TerminalColor { | |
| 2611 | 2613 | if strings.HasSuffix(reasoningText, ": max") { | |
| 2612 | 2614 | return th.ThinkingMax | |
| 2613 | 2615 | } | |
| @@ -2631,7 +2633,7 @@ func reasoningLevelLabel(level string) string { | |||
| 2631 | 2633 | ||
| 2632 | 2634 | // contextUsage renders the "N%/ctxMax" fragment, returning the | |
| 2633 | 2635 | // rendered string plus the colour to wrap it in. | |
| 2634 | - | func contextUsage(th Theme, used, max int) (string, int) { | |
| 2636 | + | func contextUsage(th Theme, used, max int) (string, TerminalColor) { | |
| 2635 | 2637 | if max <= 0 { | |
| 2636 | 2638 | if used <= 0 { | |
| 2637 | 2639 | return "", th.Muted | |
| @@ -691,10 +691,10 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 691 | if innerWidth < 1 { | 691 | if innerWidth < 1 { | ||
| 692 | innerWidth = 1 | 692 | innerWidth = 1 | ||
| 693 | } | 693 | } | ||
| 694 | + | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FGColor(v.Theme.Accent, "▌ ")) | |||
| 694 | row := func(content string) string { | 695 | row := func(content string) string { | ||
| 695 | inner := strings.Repeat(" ", leftGutter) + content | 696 | inner := strings.Repeat(" ", leftGutter) + content | ||
| 696 | padded := v.Theme.UserBubble(inner, width-2) | 697 | padded := v.Theme.UserBubble(inner, width-2) | ||
| 697 | - | bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ ")) | |||
| 698 | return bar + padded | 698 | return bar + padded | ||
| 699 | } | 699 | } | ||
| 700 | if v.CompactUser || v.CompactMode { | 700 | if v.CompactUser || v.CompactMode { | ||
| @@ -708,9 +708,9 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 708 | } | 708 | } | ||
| 709 | row = func(content string) string { | 709 | row = func(content string) string { | ||
| 710 | if v.CompactMode { | 710 | if v.CompactMode { | ||
| 711 | - | content = v.Theme.FG256(v.Theme.Muted, content) | 711 | + | content = v.Theme.FGColor(v.Theme.Muted, content) |
| 712 | } | 712 | } | ||
| 713 | - | return v.Theme.FG256(v.Theme.Accent, "▌ ") + content | 713 | + | return v.Theme.FGColor(v.Theme.Accent, "▌ ") + content |
| 714 | } | 714 | } | ||
| 715 | } | 715 | } | ||
| 716 | var bubble []string | 716 | var bubble []string | ||
| @@ -978,7 +978,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||||
| 978 | // renderLiveToolResult uses the transcript's text renderer so every rich | 978 | // renderLiveToolResult uses the transcript's text renderer so every rich | ||
| 979 | // confirmation preview immediately gets the same colors, numbered gutters, | 979 | // confirmation preview immediately gets the same colors, numbered gutters, | ||
| 980 | // and syntax highlighting as its final tool result. | 980 | // and syntax highlighting as its final tool result. | ||
| 981 | - | func (v *View) renderLiveToolResult(text string, width, color int, sourcePath string) []string { | 981 | + | func (v *View) renderLiveToolResult(text string, width int, color TerminalColor, sourcePath string) []string { |
| 982 | return v.renderToolText(text, width, color, sourcePath, 1) | 982 | return v.renderToolText(text, width, color, sourcePath, 1) | ||
| 983 | } | 983 | } | ||
| 984 | 984 | ||||
| @@ -1368,8 +1368,9 @@ func toolBoxSide(th Theme, line string, width int) string { | |||||
| 1368 | w = 12 | 1368 | w = 12 | ||
| 1369 | } | 1369 | } | ||
| 1370 | margin := strings.Repeat(" ", toolBoxOuterMargin) | 1370 | margin := strings.Repeat(" ", toolBoxOuterMargin) | ||
| 1371 | - | left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad) | 1371 | + | mutedEdge := th.FGColor(th.Muted, "│") |
| 1372 | - | right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│") | 1372 | + | left := mutedEdge + strings.Repeat(" ", toolBoxInnerPad) |
| 1373 | + | right := strings.Repeat(" ", toolBoxInnerPad) + mutedEdge | |||
| 1373 | inner := w - 2 - 2*toolBoxInnerPad // available between the two pads | 1374 | inner := w - 2 - 2*toolBoxInnerPad // available between the two pads | ||
| 1374 | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | 1375 | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | ||
| 1375 | 1376 | ||||
| @@ -1404,8 +1405,9 @@ func toolBoxSideWithImage(th Theme, line string, imgCells, width int) string { | |||||
| 1404 | w = 12 | 1405 | w = 12 | ||
| 1405 | } | 1406 | } | ||
| 1406 | margin := strings.Repeat(" ", toolBoxOuterMargin) | 1407 | margin := strings.Repeat(" ", toolBoxOuterMargin) | ||
| 1407 | - | left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad) | 1408 | + | mutedEdge := th.FGColor(th.Muted, "│") |
| 1408 | - | right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│") | 1409 | + | left := mutedEdge + strings.Repeat(" ", toolBoxInnerPad) |
| 1410 | + | right := strings.Repeat(" ", toolBoxInnerPad) + mutedEdge | |||
| 1409 | inner := w - 2 - 2*toolBoxInnerPad | 1411 | inner := w - 2 - 2*toolBoxInnerPad | ||
| 1410 | 1412 | ||||
| 1411 | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | 1413 | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | ||
| @@ -1467,7 +1469,7 @@ func trimLeadingSpaces(s string, n int) string { | |||||
| 1467 | // like a unified diff gets +/- coloring. Image blocks are rendered | 1469 | // like a unified diff gets +/- coloring. Image blocks are rendered | ||
| 1468 | // inline when the terminal supports a protocol, else as a text | 1470 | // inline when the terminal supports a protocol, else as a text | ||
| 1469 | // placeholder with dimensions. | 1471 | // placeholder with dimensions. | ||
| 1470 | - | func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string, startLine int) []string { | 1472 | + | func (v *View) renderToolResultContent(blocks []provider.Content, width int, color TerminalColor, sourcePath string, startLine int) []string { |
| 1471 | var body []string | 1473 | var body []string | ||
| 1472 | hasImage := false | 1474 | hasImage := false | ||
| 1473 | for _, b := range blocks { | 1475 | for _, b := range blocks { | ||
| @@ -1511,7 +1513,7 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string { | |||||
| 1511 | // text contains a unified-diff section (lines starting with "--- " / | 1513 | // text contains a unified-diff section (lines starting with "--- " / | ||
| 1512 | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | 1514 | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | ||
| 1513 | // colors matching git diff conventions. | 1515 | // colors matching git diff conventions. | ||
| 1514 | - | func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string { | 1516 | + | func (v *View) renderToolText(text string, width int, defaultColor TerminalColor, sourcePath string, startLine int) []string { |
| 1515 | // Legacy path: transcripts saved before we dropped line numbers | 1517 | // Legacy path: transcripts saved before we dropped line numbers | ||
| 1516 | // from the read tool still carry " 1\t..." prefixes. Detect and | 1518 | // from the read tool still carry " 1\t..." prefixes. Detect and | ||
| 1517 | // strip them, then fall through to the highlighter. | 1519 | // strip them, then fall through to the highlighter. | ||
| @@ -1647,7 +1649,7 @@ func parseHunkHeader(l string) (oldStart, newStart int, ok bool) { | |||||
| 1647 | // rows use a blank gutter so wrapped content cannot be mistaken for another | 1649 | // rows use a blank gutter so wrapped content cannot be mistaken for another | ||
| 1648 | // source line. Code is syntax-highlighted if sourcePath hints at a known | 1650 | // source line. Code is syntax-highlighted if sourcePath hints at a known | ||
| 1649 | // language and otherwise uses the diff color. | 1651 | // language and otherwise uses the diff color. | ||
| 1650 | - | func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) []string { | 1652 | + | func (v *View) renderDiffRow(line string, width int, color TerminalColor, lineNo int, mark byte, sourcePath string) []string { |
| 1651 | if len(line) == 0 { | 1653 | if len(line) == 0 { | ||
| 1652 | return []string{""} | 1654 | return []string{""} | ||
| 1653 | } | 1655 | } | ||
| @@ -1901,7 +1903,7 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { | |||||
| 1901 | // line in the accent color, the trailing "[exit N] Took X.Ys" line | 1903 | // line in the accent color, the trailing "[exit N] Took X.Ys" line | ||
| 1902 | // in muted type, everything else on the default tool-output color. | 1904 | // in muted type, everything else on the default tool-output color. | ||
| 1903 | // Called from renderToolText when the first line starts with "$ ". | 1905 | // Called from renderToolText when the first line starts with "$ ". | ||
| 1904 | - | func (v *View) renderBashResult(lines []string, width, defaultColor int) []string { | 1906 | + | func (v *View) renderBashResult(lines []string, width int, defaultColor TerminalColor) []string { |
| 1905 | lines = normalizeBashOutputLines(lines) | 1907 | lines = normalizeBashOutputLines(lines) | ||
| 1906 | // Identify the footer line (exit + timing). The bash tool writes | 1908 | // Identify the footer line (exit + timing). The bash tool writes | ||
| 1907 | // it as the last non-empty line of the result. | 1909 | // it as the last non-empty line of the result. | ||
| @@ -2607,7 +2609,7 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText | |||||
| 2607 | return lines | 2609 | return lines | ||
| 2608 | } | 2610 | } | ||
| 2609 | 2611 | ||||
| 2610 | - | func reasoningStatusColor(th Theme, reasoningText string) int { | 2612 | + | func reasoningStatusColor(th Theme, reasoningText string) TerminalColor { |
| 2611 | if strings.HasSuffix(reasoningText, ": max") { | 2613 | if strings.HasSuffix(reasoningText, ": max") { | ||
| 2612 | return th.ThinkingMax | 2614 | return th.ThinkingMax | ||
| 2613 | } | 2615 | } | ||
| @@ -2631,7 +2633,7 @@ func reasoningLevelLabel(level string) string { | |||||
| 2631 | 2633 | ||||
| 2632 | // contextUsage renders the "N%/ctxMax" fragment, returning the | 2634 | // contextUsage renders the "N%/ctxMax" fragment, returning the | ||
| 2633 | // rendered string plus the colour to wrap it in. | 2635 | // rendered string plus the colour to wrap it in. | ||
| 2634 | - | func contextUsage(th Theme, used, max int) (string, int) { | 2636 | + | func contextUsage(th Theme, used, max int) (string, TerminalColor) { |
| 2635 | if max <= 0 { | 2637 | if max <= 0 { | ||
| 2636 | if used <= 0 { | 2638 | if used <= 0 { | ||
| 2637 | return "", th.Muted | 2639 | return "", th.Muted | ||
M packages/tui/view_tool_box_test.go +1 -1
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | func TestToolBoxSideTruncationPreservesANSIColor(t *testing.T) { | |
| 9 | 9 | const width = 32 | |
| 10 | 10 | const contentColor = 196 | |
| 11 | - | line := " " + Dark.FG256(contentColor, strings.Repeat("x", 80)) | |
| 11 | + | line := " " + Dark.FG256(Color256(contentColor), strings.Repeat("x", 80)) | |
| 12 | 12 | ||
| 13 | 13 | got := toolBoxSide(Dark, line, width) | |
| 14 | 14 | if !strings.Contains(got, sgrFG(contentColor)) { | |
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | func TestToolBoxSideTruncationPreservesANSIColor(t *testing.T) { | 8 | func TestToolBoxSideTruncationPreservesANSIColor(t *testing.T) { | ||
| 9 | const width = 32 | 9 | const width = 32 | ||
| 10 | const contentColor = 196 | 10 | const contentColor = 196 | ||
| 11 | - | line := " " + Dark.FG256(contentColor, strings.Repeat("x", 80)) | 11 | + | line := " " + Dark.FG256(Color256(contentColor), strings.Repeat("x", 80)) |
| 12 | 12 | ||||
| 13 | got := toolBoxSide(Dark, line, width) | 13 | got := toolBoxSide(Dark, line, width) | ||
| 14 | if !strings.Contains(got, sgrFG(contentColor)) { | 14 | if !strings.Contains(got, sgrFG(contentColor)) { | ||