detached add512e

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
419419 - **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`.
420420 - **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).
421421 - **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).
423423 - **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.
424424
425425 ### `/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:
3434 The inherited terminal snapshot is captured at startup and reused when the
3535 setting is changed live; restart zot to pick up terminal palette changes.
3636
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+
3751 Theme files bundled with extensions are discovered in-place from loaded
3852 extension directories:
3953
@@ -191,11 +205,22 @@ You may also put overrides directly at the top level or directly under
191205
192206 ## Color fields
193207
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.
199224
200225 - `fg` — default foreground text.
201226 - `muted` — secondary text, dividers, gutters, inactive hints.
@@ -214,15 +239,8 @@ capability.
214239 - `selection_bg` — highlighted row background.
215240 - `selection_fg` — highlighted row foreground.
216241
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.
226244
227245 ## Spinner fields
228246
@@ -34,6 +34,20 @@ The built-in choices are:
34 The inherited terminal snapshot is captured at startup and reused when the34 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 loaded51 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 fields206 ## 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's209+
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 terminal211+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 fields245 ## Spinner fields
228 246
M packages/agent/args.go +5 -2
@@ -370,11 +370,14 @@ func PrintHelp(version string) {
370370 th := tui.Dark
371371 fd := int(os.Stderr.Fd())
372372 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 {
374377 if !useColor {
375378 return s
376379 }
377- return th.FG256(c, s)
380+ return th.FGColor(c, s)
378381 }
379382 assistant := func(s string) string { return style(th.Assistant, s) }
380383 muted := func(s string) string { return style(th.Muted, s) }
@@ -370,11 +370,14 @@ func PrintHelp(version string) {
370 th := tui.Dark370 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 s378 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 (
1616 func frameHeader(th tui.Theme, title string, width int) string {
1717 label := "── " + title + " "
1818 if width <= 0 {
19- return th.FG256(th.Muted, label)
19+ return th.FGColor(th.Muted, label)
2020 }
2121 padLen := width - runewidth.StringWidth(label)
2222 if padLen < 0 {
2323 padLen = 0
2424 }
25- return th.FG256(th.Muted, label+strings.Repeat("─", padLen))
25+ return th.FGColor(th.Muted, label+strings.Repeat("─", padLen))
2626 }
2727
2828 // frameRule returns a full-width horizontal rule in the muted color.
@@ -30,7 +30,7 @@ func frameRule(th tui.Theme, width int) string {
3030 if width <= 0 {
3131 width = 1
3232 }
33- return th.FG256(th.Muted, strings.Repeat("─", width))
33+ return th.FGColor(th.Muted, strings.Repeat("─", width))
3434 }
3535
3636 // padDialogFrame inserts breathing room between the shared dialog frame
@@ -71,24 +71,24 @@ func isFrameRuleLine(line string) bool {
7171 }
7272
7373 // 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 {
7777 label := "── " + title + " "
7878 if width <= 0 {
79- return th.FG256(color, label)
79+ return th.FGColor(color, label)
8080 }
8181 padLen := width - runewidth.StringWidth(label)
8282 if padLen < 0 {
8383 padLen = 0
8484 }
85- return th.FG256(color, label+strings.Repeat("─", padLen))
85+ return th.FGColor(color, label+strings.Repeat("─", padLen))
8686 }
8787
8888 // 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 {
9090 if width <= 0 {
9191 width = 1
9292 }
93- return th.FG256(color, strings.Repeat("─", width))
93+ return th.FGColor(color, strings.Repeat("─", width))
9494 }
@@ -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 = 023 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 = 131 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 frame36 // 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-supplied73 // frameHeaderColor is like frameHeader but renders in a caller-supplied
74-// 256-color code. Used by the update-available banner which wants a74+// 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 = 083 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 = 191 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 (
1111
1212 func newNotesTestInteractive() *Interactive {
1313 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+ }
1518 return i
1619 }
1720
@@ -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 i18 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) {
1212 d := newLogoutDialog()
1313 d.Open([]logoutItem{{label: "Google", target: "google", method: "api key"}})
1414 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)
1618 selected := lines[2]
1719 if got := runewidth.StringWidth(stripANSIBytes(selected)); got != width {
1820 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 = 6014 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
6767 agent := core.NewAgent(nil, "", "", nil)
6868 i := NewInteractive(InteractiveConfig{
6969 Agent: agent,
70- Theme: tui.Theme{Error: 1},
70+ Theme: tui.Theme{Error: tui.Color256(1)},
7171 StartupExtensionErrors: []string{"startup failed: stderr log: /tmp/startup.log"},
7272 })
7373
@@ -84,7 +84,7 @@ func TestReloadFailureRemainsInChatWithoutEnteringContext(t *testing.T) {
8484 agent := core.NewAgent(nil, "", "", nil)
8585 i := NewInteractive(InteractiveConfig{
8686 Agent: agent,
87- Theme: tui.Theme{Error: 1},
87+ Theme: tui.Theme{Error: tui.Color256(1)},
8888 })
8989 seq := i.setReloadStatus("broken extension: stderr log: /tmp/ext.log", true)
9090 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) {
4343 if !interactive.cfg.Theme.Inherited {
4444 t.Fatal("applying inherited theme did not switch the live theme")
4545 }
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")
4848 }
4949 if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground {
5050 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.
1919
2020 The `/settings` picker also provides an **inherited (from terminal)**
2121 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.
2426
2527 User themes are discovered from:
2628
@@ -191,6 +193,8 @@ under `colors`, when they should apply to both modes:
191193 - `fg` — default foreground text.
192194 - `muted` — secondary text, dividers, gutters, inactive hints.
193195 - `accent` — prompt bar, bullets, links, headings, active markers.
196+- `background` — optional full-row TUI background; missing values leave the
197+ terminal background unchanged.
194198 - `user` — user role label color; mostly compatibility.
195199 - `user_bubble_bg` — background behind user message rows.
196200 - `user_bubble_fg` — foreground inside user message rows.
@@ -203,9 +207,7 @@ under `colors`, when they should apply to both modes:
203207 - `selection_bg` — highlighted row background.
204208 - `selection_fg` — highlighted row foreground.
205209
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:
209211
210212 ```json
211213 254
@@ -215,6 +217,12 @@ Most color fields are xterm-256 indexes (`0`–`255`).
215217 { "mode": "rgb", "r": 66, "g": 69, "b": 75 }
216218 ```
217219
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+
218226 ## Spinner fields
219227
220228 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 truecolor21 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 theme22+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 ```json212 ```json
211 254213 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 fields226 ## Spinner fields
219 227
220 Spinner settings can appear at top level, under `colors`, or under228 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
846846 var errZotfileConsentInterrupted = errors.New("interrupted")
847847
848848 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 {
850850 if !color {
851851 return text
852852 }
853- return th.FG256(c, text)
853+ return th.FGColor(c, text)
854854 }
855855
856856 var sb strings.Builder
@@ -886,7 +886,7 @@ func readZotfileConsent(in *os.File, out io.Writer, th tui.Theme) (bool, error)
886886 fmt.Fprint(out, "\b \b")
887887 return
888888 }
889- fmt.Fprint(out, th.FG256(th.FG, string(answer)))
889+ fmt.Fprint(out, th.FGColor(th.FG, string(answer)))
890890 })
891891 switch {
892892 case errors.Is(err, errZotfileConsentInterrupted):
@@ -938,11 +938,11 @@ func permissionSummary(p tools.PermissionSet) string {
938938 }
939939
940940 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 {
942942 if !color {
943943 return text
944944 }
945- return th.FG256(c, text)
945+ return th.FGColor(c, text)
946946 }
947947 permissionValue := func(value string) string { return style(th.FG, value) }
948948
@@ -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 text851 return text
852 }852 }
853- return th.FG256(c, text)853+ return th.FGColor(c, text)
854 }854 }
855 855
856 var sb strings.Builder856 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 return887 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 text943 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) {
329329 }
330330
331331 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+ }
333336 zf := zotfileLoaded{}
334337 zf.Manifest.Name = "reviewer"
335338 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 {
3838 }
3939 style := th.chromaStyle()
4040 formatterName := "terminal256"
41- if th.ColorMode == ColorModeTrueColor {
41+ if th.Terminal.TrueColor {
4242 formatterName = "terminal16m"
4343 }
4444 formatter := formatters.Get(formatterName)
@@ -243,7 +243,7 @@ func (th Theme) chromaStyle() *chroma.Style {
243243 func (th Theme) syntaxKey() string {
244244 syn := th.Syntax
245245 outputMode := "256"
246- if th.ColorMode == ColorModeTrueColor {
246+ if th.Terminal.TrueColor {
247247 outputMode = "truecolor"
248248 }
249249 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.Syntax244 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 {
6363 if bubbleW < 1 {
6464 bubbleW = 1
6565 }
66+ bg := th.sgrBGColor(th.UserBubbleBG)
67+ bar := bg + th.FGColor(th.Accent, "▌ ")
6668 row := func(content string) string {
6769 visible := visibleWidth(content)
6870 if visible < bubbleW {
6971 content += strings.Repeat(" ", bubbleW-visible)
7072 }
71- bg := th.sgrBGColor(th.UserBubbleBG)
72- bar := bg + th.FG256(th.Accent, "▌ ")
7373 body := bg + strings.ReplaceAll(content, reset, reset+bg) + reset
7474 return bar + body
7575 }
@@ -63,13 +63,13 @@ func InputBlock(th Theme, lines []string, width int) []string {
63 if bubbleW < 1 {63 if bubbleW < 1 {
64 bubbleW = 164 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) + reset73 body := bg + strings.ReplaceAll(content, reset, reset+bg) + reset
74 return bar + body74 return bar + body
75 }75 }
M packages/tui/render.go +14 -13
@@ -73,7 +73,8 @@ type Renderer struct {
7373 // row-writing boundary. In particular, Theme.Background is painted
7474 // as a full-width row background without making every View renderer
7575 // know about terminal padding and reset semantics.
76- theme Theme
76+ theme Theme
77+ backgroundStyle string
7778 }
7879
7980 // NewRenderer returns a renderer that writes to out.
@@ -94,6 +95,7 @@ func NewRenderer(out io.Writer) *Renderer {
9495 // background affects every row, so cached frame state is invalidated.
9596 func (r *Renderer) SetTheme(th Theme) {
9697 r.theme = th
98+ r.backgroundStyle = th.BackgroundStyle()
9799 r.Invalidate()
98100 }
99101
@@ -224,16 +226,15 @@ func imageFootprintBlank(s string) bool {
224226 // background reaches the right edge, and re-applies the background
225227 // after full SGR resets inside the row so local styling does not punch
226228 // 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) {
230231 return line
231232 }
232- line = strings.ReplaceAll(line, reset, reset+bg)
233+ line = strings.ReplaceAll(line, reset, reset+background)
233234 if w := visibleWidth(line); w < cols {
234235 line += strings.Repeat(" ", cols-w)
235236 }
236- return bg + line + reset
237+ return background + line + reset
237238 }
238239
239240 // 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) {
322323 frame[i] = ""
323324 }
324325 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)
326327 }
327328 if r.theme.Background != nil {
328329 for i := 0; i < top; i++ {
329- frame[i] = paintBackgroundRow("", r.cols, r.theme)
330+ frame[i] = paintBackgroundRow("", r.cols, r.backgroundStyle)
330331 }
331332 }
332333
@@ -371,7 +372,7 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
371372 // overwrites, leaving ghost highlights behind.
372373 hasSelection := false
373374 if r.theme.Background == nil {
374- selectionBG := r.theme.bgPrefix(Color256(r.theme.SelectionBG))
375+ selectionBG := r.theme.bgPrefix(r.theme.SelectionBG)
375376 for _, l := range frame {
376377 if selectionBG != "" && strings.Contains(l, selectionBG) {
377378 hasSelection = true
@@ -431,11 +432,11 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
431432 }
432433 chatFrame := make([]string, len(chat))
433434 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)
435436 }
436437 bottomFrame := make([]string, len(bottom))
437438 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)
439440 }
440441
441442 // 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
447448 lines = append(lines, chatFrame...)
448449 lines = append(lines, bottomFrame...)
449450 for range bottomMarginRows {
450- lines = append(lines, paintBackgroundRow("", r.cols, r.theme))
451+ lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))
451452 }
452453 // In main-screen flow mode zot normally emits only its logical
453454 // 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
457458 // default transparent case.
458459 if r.theme.Background != nil {
459460 for len(lines) < r.rows {
460- lines = append(lines, paintBackgroundRow("", r.cols, r.theme))
461+ lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))
461462 }
462463 }
463464 if len(lines) == 0 {
@@ -73,7 +73,8 @@ type Renderer struct {
73 // row-writing boundary. In particular, Theme.Background is painted73 // row-writing boundary. In particular, Theme.Background is painted
74 // as a full-width row background without making every View renderer74 // 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 Theme76+ 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 = th97 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 background226 // background reaches the right edge, and re-applies the background
225 // after full SGR resets inside the row so local styling does not punch227 // 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 line231 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 + reset237+ return background + line + reset
237 }238 }
238 239
239 // truncateToWidth clips s so its on-screen width doesn't exceed cols240 // 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 := false373 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 = true378 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 is442 // 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 logical453 // 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
8888 }
8989 return text
9090 }
91- paintLine := func(text string, color int, dim, phase bool) string {
91+ paintLine := func(text string, color *TerminalColor, dim, phase bool) string {
9292 text = padLine(text, phase)
93- if color < 0 {
93+ if color == nil {
9494 return text
9595 }
96- paint := func(value string, valueColor int) string {
96+ paint := func(value string, valueColor TerminalColor) string {
9797 if dim {
9898 value = Dim(value)
9999 }
100- return th.FG256(valueColor, value)
100+ return th.FGColor(valueColor, value)
101101 }
102102 if markerStart, markerEnd, _, ok := rightBarChecklistMarker(text); ok {
103103 if phase {
104104 if nameStart, nameEnd, ok := rightBarChecklistPhaseName(text); ok {
105- return paint(text[:markerStart], color) +
105+ return paint(text[:markerStart], *color) +
106106 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)
110110 }
111111 }
112- return paint(text[:markerStart], color) +
112+ return paint(text[:markerStart], *color) +
113113 paint(text[markerStart:markerEnd], th.Muted) +
114- paint(text[markerEnd:], color)
114+ paint(text[markerEnd:], *color)
115115 }
116116 if phase {
117- return paint(Bold(text), color)
117+ return paint(Bold(text), *color)
118118 }
119- return paint(text, color)
119+ return paint(text, *color)
120120 }
121- appendContent := func(text string, color int, dim, phase bool) bool {
121+ appendContent := func(text string, color *TerminalColor, dim, phase bool) bool {
122122 text = strings.ReplaceAll(text, "\r", "")
123123 for _, line := range strings.Split(text, "\n") {
124124 if len(content) >= height {
@@ -131,17 +131,17 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
131131 }
132132
133133 for idx, widget := range ordered {
134- if idx > 0 && !appendContent("", -1, false, false) {
134+ if idx > 0 && !appendContent("", nil, false, false) {
135135 break
136136 }
137137 label := "[" + widget.Extension + "]"
138138 if widget.Title != "" {
139139 label += " " + widget.Title
140140 }
141- if !appendContent(label, th.Accent, false, false) {
141+ if !appendContent(label, &th.Accent, false, false) {
142142 break
143143 }
144- if !appendContent("", -1, false, false) {
144+ if !appendContent("", nil, false, false) {
145145 break
146146 }
147147 activePhaseIndent := rightBarActivePhaseIndent(widget.Lines)
@@ -157,7 +157,7 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
157157 line = " " + line
158158 dim := rightBarChecklistLineDim(line, activePhaseIndent, &activePhase)
159159 phase := rightBarChecklistPhaseLine(line, phaseIndent)
160- if !appendContent(line, th.FG, dim, phase) {
160+ if !appendContent(line, &th.FG, dim, phase) {
161161 break
162162 }
163163 }
@@ -166,7 +166,7 @@ func RenderRightBar(th Theme, widgets []RightBarWidget, width, height int) []str
166166 }
167167 }
168168 if truncated {
169- marker := paintLine("... right bar ...", th.Muted, false, false)
169+ marker := paintLine("... right bar ...", &th.Muted, false, false)
170170 if len(content) == height {
171171 content[len(content)-1] = marker
172172 } else {
@@ -350,7 +350,7 @@ func JoinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int)
350350 if visible := visibleWidth(rightBar); visible < rightBarWidth {
351351 rightBar += strings.Repeat(" ", rightBarWidth-visible)
352352 }
353- return main + th.FG256(th.Muted, "│") + rightBar
353+ return main + th.FGColor(th.Muted, "│") + rightBar
354354 }
355355
356356 // 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 text89 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 text94 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 break135 break
136 }136 }
137 label := "[" + widget.Extension + "]"137 label := "[" + widget.Extension + "]"
138 if widget.Title != "" {138 if widget.Title != "" {
139 label += " " + widget.Title139 label += " " + widget.Title
140 }140 }
141- if !appendContent(label, th.Accent, false, false) {141+ if !appendContent(label, &th.Accent, false, false) {
142 break142 break
143 }143 }
144- if !appendContent("", -1, false, false) {144+ if !appendContent("", nil, false, false) {
145 break145 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 = " " + line157 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 break161 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] = marker171 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, "│") + rightBar353+ return main + th.FGColor(th.Muted, "│") + rightBar
354 }354 }
355 355
356 // DrawRightBar renders an already-laid-out visible chat slice and sticky356 // 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) {
105105
106106 func TestStatusBarUsesReasoningMaxThemeColor(t *testing.T) {
107107 th := Dark
108- th.ThinkingMax = 201
108+ th.ThinkingMax = Color256(201)
109109 lines := StatusBar(StatusBarParams{
110110 Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200,
111111 })
@@ -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 := Dark107 th := Dark
108- th.ThinkingMax = 201108+ 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 @@
33 // framework; just ANSI escape codes.
44 package tui
55
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.
1414 type TerminalColor struct {
1515 Mode terminalColorMode
1616 Index int
@@ -33,14 +33,6 @@ func ColorRGB(r, g, b int) TerminalColor {
3333 return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b}
3434 }
3535
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-
4436 // TerminalProfile is the best-effort color snapshot reported by the
4537 // controlling terminal. The ANSI palette is optional; unknown slots are
4638 // resolved through the classic xterm-256 palette.
@@ -66,31 +58,31 @@ func (p TerminalProfile) PaletteColor(index int) (TerminalColor, bool) {
6658 return p.Palette[index], true
6759 }
6860
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.
7163 type Theme struct {
72- FG int
73- Muted int
74- Accent int
64+ FG TerminalColor
65+ Muted TerminalColor
66+ Accent TerminalColor
7567 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
7769 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.
9285 Inherited bool
93- ColorMode ColorMode
9486 Terminal TerminalProfile
9587
9688 SpinnerFrames []string
@@ -188,21 +180,21 @@ var defaultSpinnerMessages = []string{
188180 }
189181
190182 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)
195187 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
206198 SpinnerFrames: defaultSpinnerFrames,
207199 SpinnerMessages: defaultSpinnerMessages,
208200 SpinnerIntervalMS: 80,
@@ -211,21 +203,21 @@ var Dark = Theme{
211203 }
212204
213205 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),
218210 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
229221 SpinnerFrames: defaultSpinnerFrames,
230222 SpinnerMessages: defaultSpinnerMessages,
231223 SpinnerIntervalMS: 80,
@@ -233,43 +225,48 @@ var Light = Theme{
233225 Syntax: nordSyntax,
234226 }
235227
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 {
240231 return t.fgPrefix(c) + s + reset
241232 }
242233
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)
248240 }
249241
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.
252252 func (t Theme) BG(c TerminalColor, s string) string {
253253 return t.bgPrefix(c) + s + reset
254254 }
255255
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))
261258 }
262259
263260 func (t Theme) bgPrefix(color TerminalColor) string {
264261 return sgrBGColor(t.resolveColor(color))
265262 }
266263
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 {
271268 percent = clampPercent(percent)
272- foreground := t.resolveForeground(index)
269+ foreground := t.resolveColor(color)
273270 background := t.Terminal.Background
274271 if !t.Terminal.HasBackground {
275272 background = Color256(0)
@@ -283,36 +280,32 @@ func (t Theme) DimColor(index, percent int) TerminalColor {
283280 ))
284281 }
285282
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-
293283 func (t Theme) resolveColor(color TerminalColor) TerminalColor {
294284 switch color.Mode {
295285 case terminalColor256:
296286 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 {
300290 return palette
301291 }
302- return Color256(index)
303- }
304- if t.ColorMode == ColorModeTrueColor {
305- r, g, b := xterm256RGB(index)
306- return ColorRGB(r, g, b)
307292 }
308- }
309- if t.ColorMode == ColorModeTrueColor {
310293 r, g, b := xterm256RGB(index)
311294 return ColorRGB(r, g, b)
312295 }
313296 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+ }
314307 case terminalColorRGB:
315- if t.Inherited && t.ColorMode == ColorMode256 {
308+ if !t.Terminal.TrueColor {
316309 return Color256(nearestXtermColor(color.R, color.G, color.B))
317310 }
318311 }
@@ -322,7 +315,7 @@ func (t Theme) resolveColor(color TerminalColor) TerminalColor {
322315 func sgrFGColor(color TerminalColor) string {
323316 switch color.Mode {
324317 case terminalColorANSI:
325- return "\x1b[" + itoa(color.Index) + "m"
318+ return "\x1b[" + itoa(ansiForegroundSGR(color.Index)) + "m"
326319 case terminalColorRGB:
327320 return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m"
328321 default:
@@ -338,8 +331,8 @@ func (t Theme) sgrBGColor(color TerminalColor) string {
338331 // glyph followed by a plain space gutter. Used as the speaker-label
339332 // prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so
340333 // 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, "▌ ")
343336 }
344337
345338 // Highlight paints s with the theme's selection colors (foreground +
@@ -351,7 +344,7 @@ func (t Theme) Highlight(s string) string {
351344
352345 // SelectionStyle returns the SGR prefix for the theme's selected row.
353346 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)
355348 }
356349
357350 // BackgroundStyle returns the SGR prefix for the optional full-row
@@ -367,8 +360,8 @@ func (t Theme) BackgroundStyle() string {
367360 // SelectionStyleFG returns the SGR prefix for selected-row text with
368361 // a custom foreground. Useful for preserving semantic marks inside a
369362 // 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)
372365 }
373366
374367 // PadHighlight styles s and extends the selection background to the
@@ -404,7 +397,7 @@ func (t Theme) UserBubbleRow(content string, width int) string {
404397 // Bar plus a single space gutter, in the assistant accent colour
405398 // so it matches the tool-box / app accent and reads as zot's voice
406399 // marker. Two cells wide.
407- bar := t.FG256(t.Assistant, "▌ ")
400+ bar := t.FGColor(t.Assistant, "▌ ")
408401 bubbleW := width - 2
409402 if bubbleW < 1 {
410403 bubbleW = 1
@@ -428,7 +421,7 @@ func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m"
428421 func sgrBGColor(c TerminalColor) string {
429422 switch c.Mode {
430423 case terminalColorANSI:
431- return "\x1b[" + itoa(c.Index) + "m"
424+ return "\x1b[" + itoa(ansiBackgroundSGR(c.Index)) + "m"
432425 case terminalColorRGB:
433426 return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m"
434427 default:
@@ -497,14 +490,52 @@ func ansiSGRToXtermIndex(sgr int) (int, bool) {
497490 }
498491 }
499492
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+
500515 func blendChannel(from, to, percent int) int {
501516 return clampByte((from*(100-percent) + to*percent + 50) / 100)
502517 }
503518
519+const xtermColorCacheSize = 256
520+
521+type xtermColorCacheEntry struct {
522+ value atomic.Uint64
523+}
524+
525+var xtermColorCache [xtermColorCacheSize]xtermColorCacheEntry
526+
504527 func nearestXtermColor(r, g, b int) int {
505528 r = clampByte(r)
506529 g = clampByte(g)
507530 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+
508539 best := 0
509540 bestDistance := int(^uint(0) >> 1)
510541 for index := 0; index < 256; index++ {
@@ -518,6 +549,7 @@ func nearestXtermColor(r, g, b int) int {
518549 bestDistance = distance
519550 }
520551 }
552+ entry.value.Store(keyPrefix | uint64(best))
521553 return best
522554 }
523555
@@ -3,14 +3,14 @@
3 // framework; just ANSI escape codes.3 // framework; just ANSI escape codes.
4 package tui4 package tui
5 5
6-import "strings"6+import (
7-7+ "strings"
8-// TerminalColor describes a terminal colour in one of the colour8+ "sync/atomic"
9-// spaces terminals commonly support. Most of zot's theme still uses9+)
10-// xterm-256 indexes, but user-bubble backgrounds can also use ANSI10+
11-// theme slots (for example SGR 100 / bright-black background) so they11+// TerminalColor describes a theme colour in one of the colour spaces
12-// match the user's terminal theme rather than the fixed 256-colour12+// 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 terminalColorMode15 Mode terminalColorMode
16 Index int16 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 the36 // TerminalProfile is the best-effort color snapshot reported by the
45 // controlling terminal. The ANSI palette is optional; unknown slots are37 // 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], true58 return p.Palette[index], true
67 }59 }
68 60
69-// ANSI 256-color palette used by zot. Defined as numeric codes so we61+// 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 int64+ FG TerminalColor
73- Muted int65+ Muted TerminalColor
74- Accent int66+ Accent TerminalColor
75 Background *TerminalColor // optional full-row TUI background; nil keeps terminal default67 Background *TerminalColor // optional full-row TUI background; nil keeps terminal default
76- User int // label color for the user role68+ User TerminalColor // label color for the user role
77 UserBubbleBG TerminalColor // background tint behind user message rows69 UserBubbleBG TerminalColor // background tint behind user message rows
78- UserBubbleFG int // foreground colour for user message rows70+ UserBubbleFG TerminalColor // foreground colour for user message rows
79- Assistant int // label color for the zot role71+ Assistant TerminalColor // label color for the zot role
80- Tool int72+ Tool TerminalColor
81- ToolOut int73+ ToolOut TerminalColor
82- Error int74+ Error TerminalColor
83- Warning int75+ Warning TerminalColor
84- Spinner int // spinner + funny working line76+ Spinner TerminalColor // spinner + funny working line
85- ThinkingMax int // status color for the opt-in max reasoning level77+ ThinkingMax TerminalColor // status color for the opt-in max reasoning level
86- SelectionBG int // background for highlighted rows78+ SelectionBG TerminalColor // background for highlighted rows
87- SelectionFG int // foreground for highlighted rows79+ SelectionFG TerminalColor // foreground for highlighted rows
88-80+
89- // Inherited selects terminal-owned colors and uses ColorMode to choose81+ // Inherited selects terminal-owned colors. Terminal.TrueColor controls
90- // truecolor or a best-effort xterm-256 fallback. Explicit zot themes keep82+ // 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 bool85 Inherited bool
93- ColorMode ColorMode
94 Terminal TerminalProfile86 Terminal TerminalProfile
95 87
96 SpinnerFrames []string88 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 blue185+ 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), // #42454B187 UserBubbleBG: ColorRGB(66, 69, 75), // #42454B
196- UserBubbleFG: 248, // slightly lighter grey for readability on #42454B188+ UserBubbleFG: Color256(248), // slightly lighter grey for readability on #42454B
197- Assistant: 117, // bright cyan — the zot label color189+ Assistant: Color256(117), // bright cyan — the zot label color
198- Tool: 114, // green190+ 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 purple194+ Spinner: Color256(183), // soft purple
203- ThinkingMax: 207, // vivid magenta195+ ThinkingMax: Color256(207), // vivid magenta
204- SelectionBG: 24, // deep blue background196+ SelectionBG: Color256(24), // deep blue background
205- SelectionFG: 231, // near-white foreground197+ 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 theme210 UserBubbleBG: Color256(254), // very pale grey panel behind user rows on light theme
219- UserBubbleFG: 240, // dark grey text, legible on the pale panel211+ UserBubbleFG: Color256(240), // dark grey text, legible on the pale panel
220- Assistant: 31, // deep cyan212+ 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, // purple217+ Spinner: Color256(91), // purple
226- ThinkingMax: 127, // deep magenta218+ ThinkingMax: Color256(127), // deep magenta
227- SelectionBG: 153, // light blue219+ SelectionBG: Color256(153), // light blue
228- SelectionFG: 232, // near-black220+ 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 the228+// FGColor wraps s in a foreground color. The name is intentionally explicit
237-// historical ANSI 256-color sequence; inherited themes resolve terminal229+// 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 + reset231 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 underlying235+// 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 + reset238+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 RGB242+// 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 + reset253 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 used264+// DimColor fades a theme color toward the terminal background. It is used
268-// by inherited theme construction for muted surfaces and is also available to265+// 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.Background270 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 palette290 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-label331 // glyph followed by a plain space gutter. Used as the speaker-label
339 // prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so332 // 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-row350 // 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 with360 // SelectionStyleFG returns the SGR prefix for selected-row text with
368 // a custom foreground. Useful for preserving semantic marks inside a361 // 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 the367 // 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 colour397 // 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 voice398 // 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 - 2401 bubbleW := width - 2
409 if bubbleW < 1 {402 if bubbleW < 1 {
410 bubbleW = 1403 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 := 0539 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 = distance549 bestDistance = distance
519 }550 }
520 }551 }
552+ entry.value.Store(keyPrefix | uint64(best))
521 return best553 return best
522 }554 }
523 555
M packages/tui/theme_inherited_test.go +43 -7
@@ -70,6 +70,34 @@ func TestParseOSCColorResponse(t *testing.T) {
7070 }
7171 }
7272
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+
73101 func inheritedTestTheme(trueColor bool) Theme {
74102 detected := Dark
75103 detected.Terminal = TerminalProfile{
@@ -88,8 +116,8 @@ func inheritedTestTheme(trueColor bool) Theme {
88116
89117 func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) {
90118 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)
93121 }
94122 if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;2;40;100;240m") {
95123 t.Fatalf("accent did not use the reported terminal palette: %q", got)
@@ -110,8 +138,8 @@ func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) {
110138
111139 func TestInheritedThemeFallsBackTo256Colors(t *testing.T) {
112140 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)
115143 }
116144 if got := th.FG256(th.Accent, "x"); !strings.Contains(got, "\x1b[38;5;12m") || strings.Contains(got, "\x1b[38;2;") {
117145 t.Fatalf("accent did not use 256-color fallback: %q", got)
@@ -127,12 +155,20 @@ func TestInheritedThemeFallsBackTo256Colors(t *testing.T) {
127155 }
128156 }
129157
130-func TestExplicitThemesRemainIndexed(t *testing.T) {
158+func TestExplicitThemesFollowTerminalCapability(t *testing.T) {
131159 if got, want := Dark.FG256(Dark.Accent, "x"), "\x1b[38;5;111mx\x1b[0m"; got != want {
132160 t.Fatalf("explicit theme output = %q, want %q", got, want)
133161 }
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)
136172 }
137173 }
138174
@@ -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 := Dark102 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 {
7979 // override only the colors it cares about and inherit the built-in
8080 // dark/light defaults for everything else.
8181 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"`
8585 Background *TerminalColorValue `json:"background,omitempty"`
86- User *int `json:"user,omitempty"`
86+ User *TerminalColorValue `json:"user,omitempty"`
8787 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"`
9999 SpinnerFrames []string `json:"spinner_frames,omitempty"`
100100 SpinnerMessages []string `json:"spinner_messages,omitempty"`
101101 SpinnerIntervalMS *int `json:"spinner_interval_ms,omitempty"`
@@ -187,51 +187,34 @@ func InheritedTheme(detected Theme) Theme {
187187 }
188188 t := withTerminalProfile(base, detected)
189189 t.Inherited = true
190- t.ColorMode = ColorMode256
191- if t.Terminal.TrueColor {
192- t.ColorMode = ColorModeTrueColor
193- }
194190
195191 if t.Terminal.HasForeground {
196- t.FG = terminalColorIndex(t.Terminal.Foreground)
192+ t.FG = t.Terminal.Foreground
197193 }
198194
199195 accentFallback := t.Accent
200196 if IsLightTheme(t) {
201197 accentFallback = Light.Accent
202198 }
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)
204201 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)
211208
212209 if t.Terminal.HasForeground && t.Terminal.HasBackground {
213- t.Muted = terminalColorIndex(t.DimColor(t.FG, 45))
210+ t.Muted = t.DimColor(t.FG, 45)
214211 t.ToolOut = t.Muted
215212 background := t.Terminal.Background
216213 foreground := t.Terminal.Foreground
217214 t.UserBubbleBG = blendTerminalColors(background, foreground, 12)
218- t.SelectionBG = terminalColorIndex(blendTerminalColors(background, foreground, 25))
215+ t.SelectionBG = blendTerminalColors(background, foreground, 25)
219216 }
220217
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- }
235218 t.UserBubbleFG = t.FG
236219 t.SelectionFG = t.FG
237220
@@ -247,28 +230,35 @@ func withTerminalProfile(base, detected Theme) Theme {
247230 return base
248231 }
249232
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 {
251250 for _, index := range preferred {
252251 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)
254257 }
255258 }
256259 return fallback
257260 }
258261
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-
272262 func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
273263 fromRGB, _ := rgbForTerminalColor(from)
274264 toRGB, _ := rgbForTerminalColor(to)
@@ -280,27 +270,6 @@ func blendTerminalColors(from, to TerminalColor, percent int) TerminalColor {
280270 )
281271 }
282272
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-
304273 // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json
305274 // to detected. Empty/auto/default keeps the built-in detected theme.
306275 // If preferred is set, it may be a theme name, a basename without
@@ -480,55 +449,55 @@ func themeFilesIn(dir string) ([]string, error) {
480449
481450 func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {
482451 if o.FG != nil {
483- th.FG = *o.FG
452+ th.FG = o.FG.TerminalColor
484453 }
485454 if o.Muted != nil {
486- th.Muted = *o.Muted
455+ th.Muted = o.Muted.TerminalColor
487456 }
488457 if o.Accent != nil {
489- th.Accent = *o.Accent
458+ th.Accent = o.Accent.TerminalColor
490459 }
491460 if o.Background != nil {
492461 bg := o.Background.TerminalColor
493462 th.Background = &bg
494463 }
495464 if o.User != nil {
496- th.User = *o.User
465+ th.User = o.User.TerminalColor
497466 }
498467 if o.UserBubbleBG != nil {
499468 th.UserBubbleBG = o.UserBubbleBG.TerminalColor
500469 }
501470 if o.UserBubbleFG != nil {
502- th.UserBubbleFG = *o.UserBubbleFG
471+ th.UserBubbleFG = o.UserBubbleFG.TerminalColor
503472 }
504473 if o.Assistant != nil {
505- th.Assistant = *o.Assistant
474+ th.Assistant = o.Assistant.TerminalColor
506475 }
507476 if o.Tool != nil {
508- th.Tool = *o.Tool
477+ th.Tool = o.Tool.TerminalColor
509478 }
510479 if o.ToolOut != nil {
511- th.ToolOut = *o.ToolOut
480+ th.ToolOut = o.ToolOut.TerminalColor
512481 }
513482 if o.Error != nil {
514- th.Error = *o.Error
483+ th.Error = o.Error.TerminalColor
515484 }
516485 if o.Warning != nil {
517- th.Warning = *o.Warning
486+ th.Warning = o.Warning.TerminalColor
518487 }
519488 if o.Spinner != nil {
520- th.Spinner = *o.Spinner
489+ th.Spinner = o.Spinner.TerminalColor
521490 }
522491 if o.ThinkingMax != nil {
523- th.ThinkingMax = *o.ThinkingMax
492+ th.ThinkingMax = o.ThinkingMax.TerminalColor
524493 } else if o.ThinkingMaxCamel != nil {
525- th.ThinkingMax = *o.ThinkingMaxCamel
494+ th.ThinkingMax = o.ThinkingMaxCamel.TerminalColor
526495 }
527496 if o.SelectionBG != nil {
528- th.SelectionBG = *o.SelectionBG
497+ th.SelectionBG = o.SelectionBG.TerminalColor
529498 }
530499 if o.SelectionFG != nil {
531- th.SelectionFG = *o.SelectionFG
500+ th.SelectionFG = o.SelectionFG.TerminalColor
532501 }
533502 if len(o.SpinnerFrames) > 0 {
534503 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-in79 // 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 = true189 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.Accent195 accentFallback := t.Accent
200 if IsLightTheme(t) {196 if IsLightTheme(t) {
201 accentFallback = Light.Accent197 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.Accent201 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.Muted211 t.ToolOut = t.Muted
215 background := t.Terminal.Background212 background := t.Terminal.Background
216 foreground := t.Terminal.Foreground213 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.FG218 t.UserBubbleFG = t.FG
236 t.SelectionFG = t.FG219 t.SelectionFG = t.FG
237 220
@@ -247,28 +230,35 @@ func withTerminalProfile(base, detected Theme) Theme {
247 return base230 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 index252+ // 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 fallback259 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/*.json273 // 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 without275 // 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.FG452+ th.FG = o.FG.TerminalColor
484 }453 }
485 if o.Muted != nil {454 if o.Muted != nil {
486- th.Muted = *o.Muted455+ th.Muted = o.Muted.TerminalColor
487 }456 }
488 if o.Accent != nil {457 if o.Accent != nil {
489- th.Accent = *o.Accent458+ th.Accent = o.Accent.TerminalColor
490 }459 }
491 if o.Background != nil {460 if o.Background != nil {
492 bg := o.Background.TerminalColor461 bg := o.Background.TerminalColor
493 th.Background = &bg462 th.Background = &bg
494 }463 }
495 if o.User != nil {464 if o.User != nil {
496- th.User = *o.User465+ th.User = o.User.TerminalColor
497 }466 }
498 if o.UserBubbleBG != nil {467 if o.UserBubbleBG != nil {
499 th.UserBubbleBG = o.UserBubbleBG.TerminalColor468 th.UserBubbleBG = o.UserBubbleBG.TerminalColor
500 }469 }
501 if o.UserBubbleFG != nil {470 if o.UserBubbleFG != nil {
502- th.UserBubbleFG = *o.UserBubbleFG471+ th.UserBubbleFG = o.UserBubbleFG.TerminalColor
503 }472 }
504 if o.Assistant != nil {473 if o.Assistant != nil {
505- th.Assistant = *o.Assistant474+ th.Assistant = o.Assistant.TerminalColor
506 }475 }
507 if o.Tool != nil {476 if o.Tool != nil {
508- th.Tool = *o.Tool477+ th.Tool = o.Tool.TerminalColor
509 }478 }
510 if o.ToolOut != nil {479 if o.ToolOut != nil {
511- th.ToolOut = *o.ToolOut480+ th.ToolOut = o.ToolOut.TerminalColor
512 }481 }
513 if o.Error != nil {482 if o.Error != nil {
514- th.Error = *o.Error483+ th.Error = o.Error.TerminalColor
515 }484 }
516 if o.Warning != nil {485 if o.Warning != nil {
517- th.Warning = *o.Warning486+ th.Warning = o.Warning.TerminalColor
518 }487 }
519 if o.Spinner != nil {488 if o.Spinner != nil {
520- th.Spinner = *o.Spinner489+ th.Spinner = o.Spinner.TerminalColor
521 }490 }
522 if o.ThinkingMax != nil {491 if o.ThinkingMax != nil {
523- th.ThinkingMax = *o.ThinkingMax492+ th.ThinkingMax = o.ThinkingMax.TerminalColor
524 } else if o.ThinkingMaxCamel != nil {493 } else if o.ThinkingMaxCamel != nil {
525- th.ThinkingMax = *o.ThinkingMaxCamel494+ th.ThinkingMax = o.ThinkingMaxCamel.TerminalColor
526 }495 }
527 if o.SelectionBG != nil {496 if o.SelectionBG != nil {
528- th.SelectionBG = *o.SelectionBG497+ th.SelectionBG = o.SelectionBG.TerminalColor
529 }498 }
530 if o.SelectionFG != nil {499 if o.SelectionFG != nil {
531- th.SelectionFG = *o.SelectionFG500+ 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
33 import (
44 "os"
55 "path/filepath"
6+ "strings"
67 "testing"
78 )
89
9-func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
10+func writeThemeFile(t *testing.T, name, body string) string {
11+ t.Helper()
1012 home := t.TempDir()
1113 if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
1214 t.Fatal(err)
1315 }
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 {
1618 t.Fatal(err)
1719 }
20+ return home
21+}
22+
23+func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
24+ home := writeThemeFile(t, "partial", `{"colors":{"dark":{"accent":204}}}`)
1825
1926 th, name, err := LoadThemeFromHome(home, "partial", Dark)
2027 if err != nil {
@@ -23,11 +30,11 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
2330 if name != "partial" {
2431 t.Fatalf("name = %q, want partial", name)
2532 }
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)
2835 }
2936 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)
3138 }
3239 if len(th.SpinnerFrames) == 0 || len(th.SpinnerMessages) == 0 {
3340 t.Fatal("spinner defaults should be inherited")
@@ -35,33 +42,19 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
3542 }
3643
3744 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}}}`)
4646
4747 th, _, err := LoadThemeFromHome(home, "thinking", Dark)
4848 if err != nil {
4949 t.Fatal(err)
5050 }
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)
5353 }
5454 }
5555
5656 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}`)
6558
6659 th, _, err := LoadThemeFromHome(home, "spinner", Dark)
6760 if err != nil {
@@ -74,19 +67,12 @@ func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {
7467 t.Fatalf("spinner overrides not applied: %#v %#v %d", th.SpinnerFrames, th.SpinnerMessages, th.SpinnerIntervalMS)
7568 }
7669 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)
7871 }
7972 }
8073
8174 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}}}`)
9076
9177 th, _, err := LoadThemeFromHome(home, "darkonly", Light)
9278 if err != nil {
@@ -102,31 +88,63 @@ func TestLoadThemeFallsBackToDarkWhenLightModeMissing(t *testing.T) {
10288 t.Fatalf("spinner interval = %d, want 120", th.SpinnerIntervalMS)
10389 }
10490 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)
10692 }
10793 }
10894
10995 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"]}}`)
11897
11998 th, _, err := LoadThemeFromHome(home, "shared", Light)
12099 if err != nil {
121100 t.Fatal(err)
122101 }
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)
125104 }
126105 if len(th.SpinnerMessages) != 1 || th.SpinnerMessages[0] != "ship" {
127106 t.Fatalf("spinner messages = %#v, want ship", th.SpinnerMessages)
128107 }
129108 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)
131149 }
132150 }
@@ -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
691691 if innerWidth < 1 {
692692 innerWidth = 1
693693 }
694+ bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FGColor(v.Theme.Accent, "▌ "))
694695 row := func(content string) string {
695696 inner := strings.Repeat(" ", leftGutter) + content
696697 padded := v.Theme.UserBubble(inner, width-2)
697- bar := v.Theme.BG(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ "))
698698 return bar + padded
699699 }
700700 if v.CompactUser || v.CompactMode {
@@ -708,9 +708,9 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
708708 }
709709 row = func(content string) string {
710710 if v.CompactMode {
711- content = v.Theme.FG256(v.Theme.Muted, content)
711+ content = v.Theme.FGColor(v.Theme.Muted, content)
712712 }
713- return v.Theme.FG256(v.Theme.Accent, "▌ ") + content
713+ return v.Theme.FGColor(v.Theme.Accent, "▌ ") + content
714714 }
715715 }
716716 var bubble []string
@@ -978,7 +978,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
978978 // renderLiveToolResult uses the transcript's text renderer so every rich
979979 // confirmation preview immediately gets the same colors, numbered gutters,
980980 // 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 {
982982 return v.renderToolText(text, width, color, sourcePath, 1)
983983 }
984984
@@ -1368,8 +1368,9 @@ func toolBoxSide(th Theme, line string, width int) string {
13681368 w = 12
13691369 }
13701370 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
13731374 inner := w - 2 - 2*toolBoxInnerPad // available between the two pads
13741375 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
13751376
@@ -1404,8 +1405,9 @@ func toolBoxSideWithImage(th Theme, line string, imgCells, width int) string {
14041405 w = 12
14051406 }
14061407 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
14091411 inner := w - 2 - 2*toolBoxInnerPad
14101412
14111413 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
@@ -1467,7 +1469,7 @@ func trimLeadingSpaces(s string, n int) string {
14671469 // like a unified diff gets +/- coloring. Image blocks are rendered
14681470 // inline when the terminal supports a protocol, else as a text
14691471 // 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 {
14711473 var body []string
14721474 hasImage := false
14731475 for _, b := range blocks {
@@ -1511,7 +1513,7 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
15111513 // text contains a unified-diff section (lines starting with "--- " /
15121514 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove
15131515 // 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 {
15151517 // Legacy path: transcripts saved before we dropped line numbers
15161518 // from the read tool still carry " 1\t..." prefixes. Detect and
15171519 // strip them, then fall through to the highlighter.
@@ -1647,7 +1649,7 @@ func parseHunkHeader(l string) (oldStart, newStart int, ok bool) {
16471649 // rows use a blank gutter so wrapped content cannot be mistaken for another
16481650 // source line. Code is syntax-highlighted if sourcePath hints at a known
16491651 // 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 {
16511653 if len(line) == 0 {
16521654 return []string{""}
16531655 }
@@ -1901,7 +1903,7 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
19011903 // line in the accent color, the trailing "[exit N] Took X.Ys" line
19021904 // in muted type, everything else on the default tool-output color.
19031905 // 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 {
19051907 lines = normalizeBashOutputLines(lines)
19061908 // Identify the footer line (exit + timing). The bash tool writes
19071909 // it as the last non-empty line of the result.
@@ -2607,7 +2609,7 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, fastText
26072609 return lines
26082610 }
26092611
2610-func reasoningStatusColor(th Theme, reasoningText string) int {
2612+func reasoningStatusColor(th Theme, reasoningText string) TerminalColor {
26112613 if strings.HasSuffix(reasoningText, ": max") {
26122614 return th.ThinkingMax
26132615 }
@@ -2631,7 +2633,7 @@ func reasoningLevelLabel(level string) string {
26312633
26322634 // contextUsage renders the "N%/ctxMax" fragment, returning the
26332635 // 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) {
26352637 if max <= 0 {
26362638 if used <= 0 {
26372639 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 = 1692 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) + content696 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 + padded698 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, "▌ ") + content713+ return v.Theme.FGColor(v.Theme.Accent, "▌ ") + content
714 }714 }
715 }715 }
716 var bubble []string716 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 rich978 // 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 = 121368 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 pads1374 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 = 121405 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*toolBoxInnerPad1411 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 rendered1469 // like a unified diff gets +/- coloring. Image blocks are rendered
1468 // inline when the terminal supports a protocol, else as a text1470 // 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 []string1473 var body []string
1472 hasImage := false1474 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/remove1514 // "+++ " / "+" / "-"/" "), 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 numbers1517 // Legacy path: transcripts saved before we dropped line numbers
1516 // from the read tool still carry " 1\t..." prefixes. Detect and1518 // 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 another1649 // 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 known1650 // 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" line1903 // 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 writes1908 // 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 lines2609 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.ThinkingMax2614 return th.ThinkingMax
2613 }2615 }
@@ -2631,7 +2633,7 @@ func reasoningLevelLabel(level string) string {
2631 2633
2632 // contextUsage renders the "N%/ctxMax" fragment, returning the2634 // 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.Muted2639 return "", th.Muted
M packages/tui/view_tool_box_test.go +1 -1
@@ -8,7 +8,7 @@ import (
88 func TestToolBoxSideTruncationPreservesANSIColor(t *testing.T) {
99 const width = 32
1010 const contentColor = 196
11- line := " " + Dark.FG256(contentColor, strings.Repeat("x", 80))
11+ line := " " + Dark.FG256(Color256(contentColor), strings.Repeat("x", 80))
1212
1313 got := toolBoxSide(Dark, line, width)
1414 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 = 329 const width = 32
10 const contentColor = 19610 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)) {

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close