detached 25773e1

feat(tui): add inherited terminal theme

commit
25773e133dfdbda00ea2faf1993f2831b45988ca
author
brice · · 2026-08-05 08:57 UTC
parent
1513f55
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +866 -86
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/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. 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. 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).
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/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. 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. 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).
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 +20 -1
@@ -19,6 +19,21 @@ saved in `$ZOT_HOME/config.json` and apply immediately. If the selected
1919 file is deleted later, zot resets the setting to the built-in auto
2020 (default) theme.
2121
22+The built-in choices are:
23+
24+- **auto** — detect the terminal background and use zot's built-in dark or
25+ light palette. This remains the default.
26+- **inherited (from terminal)** — use the terminal's reported foreground,
27+ background, and ANSI palette. When `COLORTERM=truecolor`/`24bit` or
28+ `TERM` ends in `-direct`, zot emits truecolor; otherwise it quantizes
29+ derived colors to the xterm-256 palette as a best effort. Terminals that
30+ do not answer OSC color queries fall back to zot's built-in palette while
31+ still using the advertised color depth.
32+- **dark** / **light** — use zot's fixed built-in palettes.
33+
34+The inherited terminal snapshot is captured at startup and reused when the
35+setting is changed live; restart zot to pick up terminal palette changes.
36+
2237 Theme files bundled with extensions are discovered in-place from loaded
2338 extension directories:
2439
@@ -176,7 +191,11 @@ You may also put overrides directly at the top level or directly under
176191
177192 ## Color fields
178193
179-Most color fields are xterm-256 indexes (`0`–`255`).
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.
180199
181200 - `fg` — default foreground text.
182201 - `muted` — secondary text, dividers, gutters, inactive hints.
@@ -19,6 +19,21 @@ saved in `$ZOT_HOME/config.json` and apply immediately. If the selected
19 file is deleted later, zot resets the setting to the built-in auto19 file is deleted later, zot resets the setting to the built-in auto
20 (default) theme.20 (default) theme.
21 21
22+The built-in choices are:
23+
24+- **auto** — detect the terminal background and use zot's built-in dark or
25+ light palette. This remains the default.
26+- **inherited (from terminal)** — use the terminal's reported foreground,
27+ background, and ANSI palette. When `COLORTERM=truecolor`/`24bit` or
28+ `TERM` ends in `-direct`, zot emits truecolor; otherwise it quantizes
29+ derived colors to the xterm-256 palette as a best effort. Terminals that
30+ do not answer OSC color queries fall back to zot's built-in palette while
31+ still using the advertised color depth.
32+- **dark** / **light** — use zot's fixed built-in palettes.
33+
34+The inherited terminal snapshot is captured at startup and reused when the
35+setting is changed live; restart zot to pick up terminal palette changes.
36+
22 Theme files bundled with extensions are discovered in-place from loaded37 Theme files bundled with extensions are discovered in-place from loaded
23 extension directories:38 extension directories:
24 39
@@ -176,7 +191,11 @@ You may also put overrides directly at the top level or directly under
176 191
177 ## Color fields192 ## Color fields
178 193
179-Most color fields are xterm-256 indexes (`0`–`255`).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.
180 199
181 - `fg` — default foreground text.200 - `fg` — default foreground text.
182 - `muted` — secondary text, dividers, gutters, inactive hints.201 - `muted` — secondary text, dividers, gutters, inactive hints.
M packages/agent/modes/interactive.go +5 -1
@@ -4118,7 +4118,7 @@ func (i *Interactive) openSettingsDialog() {
41184118 {
41194119 key: "theme",
41204120 label: "color theme",
4121- desc: "choose a theme from $ZOT_HOME/themes or a loaded extension",
4121+ desc: "choose auto, inherited terminal colors, a built-in dark/light theme, or a loaded theme file",
41224122 options: themeOptions,
41234123 choice: themeChoice,
41244124 },
@@ -4820,6 +4820,10 @@ func (i *Interactive) applyThemeNow(name string) {
48204820 if tui.IsLightTheme(i.cfg.Theme) {
48214821 detected = tui.Light
48224822 }
4823+ // Keep the startup terminal snapshot available when a user switches
4824+ // themes live; inherited mode must not issue OSC queries while the TUI
4825+ // already owns raw stdin.
4826+ detected.Terminal = i.cfg.Theme.Terminal
48234827 th, applied, err := tui.LoadThemeFromHome(i.cfg.ZotHome, name, detected)
48244828 if err != nil {
48254829 if i.cfg.SettingsStore != nil {
@@ -4118,7 +4118,7 @@ func (i *Interactive) openSettingsDialog() {
4118 {4118 {
4119 key: "theme",4119 key: "theme",
4120 label: "color theme",4120 label: "color theme",
4121- desc: "choose a theme from $ZOT_HOME/themes or a loaded extension",4121+ desc: "choose auto, inherited terminal colors, a built-in dark/light theme, or a loaded theme file",
4122 options: themeOptions,4122 options: themeOptions,
4123 choice: themeChoice,4123 choice: themeChoice,
4124 },4124 },
@@ -4820,6 +4820,10 @@ func (i *Interactive) applyThemeNow(name string) {
4820 if tui.IsLightTheme(i.cfg.Theme) {4820 if tui.IsLightTheme(i.cfg.Theme) {
4821 detected = tui.Light4821 detected = tui.Light
4822 }4822 }
4823+ // Keep the startup terminal snapshot available when a user switches
4824+ // themes live; inherited mode must not issue OSC queries while the TUI
4825+ // already owns raw stdin.
4826+ detected.Terminal = i.cfg.Theme.Terminal
4823 th, applied, err := tui.LoadThemeFromHome(i.cfg.ZotHome, name, detected)4827 th, applied, err := tui.LoadThemeFromHome(i.cfg.ZotHome, name, detected)
4824 if err != nil {4828 if err != nil {
4825 if i.cfg.SettingsStore != nil {4829 if i.cfg.SettingsStore != nil {
A packages/agent/modes/theme_setting_test.go +52 -0
@@ -0,0 +1,52 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/tui"
7+)
8+
9+func TestSettingsDialogOffersInheritedTheme(t *testing.T) {
10+ interactive := NewInteractive(InteractiveConfig{})
11+ interactive.openSettingsDialog()
12+
13+ for _, item := range interactive.settingsDialog.items {
14+ if item.key != "theme" {
15+ continue
16+ }
17+ for _, option := range item.options {
18+ if option.value == "inherited" {
19+ if option.label != "inherited (from terminal)" {
20+ t.Fatalf("inherited label = %q, want inherited (from terminal)", option.label)
21+ }
22+ return
23+ }
24+ }
25+ t.Fatal("theme picker did not include inherited")
26+ }
27+ t.Fatal("settings dialog did not include theme picker")
28+}
29+
30+func TestApplyingInheritedThemeUsesCapturedTerminalProfile(t *testing.T) {
31+ detected := tui.Dark
32+ detected.Terminal = tui.TerminalProfile{
33+ Foreground: tui.ColorRGB(220, 220, 220),
34+ Background: tui.ColorRGB(10, 10, 10),
35+ HasForeground: true,
36+ HasBackground: true,
37+ TrueColor: true,
38+ }
39+ interactive := NewInteractive(InteractiveConfig{Theme: detected, ThemeName: "auto"})
40+ interactive.rend = nil
41+ interactive.applyThemeNow("inherited")
42+
43+ if !interactive.cfg.Theme.Inherited {
44+ t.Fatal("applying inherited theme did not switch the live theme")
45+ }
46+ if interactive.cfg.Theme.ColorMode != tui.ColorModeTrueColor {
47+ t.Fatalf("live inherited color mode = %v, want truecolor", interactive.cfg.Theme.ColorMode)
48+ }
49+ if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground {
50+ t.Fatal("live inherited theme lost the captured terminal profile")
51+ }
52+}
@@ -0,0 +1,52 @@
1+package modes
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/tui"
7+)
8+
9+func TestSettingsDialogOffersInheritedTheme(t *testing.T) {
10+ interactive := NewInteractive(InteractiveConfig{})
11+ interactive.openSettingsDialog()
12+
13+ for _, item := range interactive.settingsDialog.items {
14+ if item.key != "theme" {
15+ continue
16+ }
17+ for _, option := range item.options {
18+ if option.value == "inherited" {
19+ if option.label != "inherited (from terminal)" {
20+ t.Fatalf("inherited label = %q, want inherited (from terminal)", option.label)
21+ }
22+ return
23+ }
24+ }
25+ t.Fatal("theme picker did not include inherited")
26+ }
27+ t.Fatal("settings dialog did not include theme picker")
28+}
29+
30+func TestApplyingInheritedThemeUsesCapturedTerminalProfile(t *testing.T) {
31+ detected := tui.Dark
32+ detected.Terminal = tui.TerminalProfile{
33+ Foreground: tui.ColorRGB(220, 220, 220),
34+ Background: tui.ColorRGB(10, 10, 10),
35+ HasForeground: true,
36+ HasBackground: true,
37+ TrueColor: true,
38+ }
39+ interactive := NewInteractive(InteractiveConfig{Theme: detected, ThemeName: "auto"})
40+ interactive.rend = nil
41+ interactive.applyThemeNow("inherited")
42+
43+ if !interactive.cfg.Theme.Inherited {
44+ t.Fatal("applying inherited theme did not switch the live theme")
45+ }
46+ if interactive.cfg.Theme.ColorMode != tui.ColorModeTrueColor {
47+ t.Fatalf("live inherited color mode = %v, want truecolor", interactive.cfg.Theme.ColorMode)
48+ }
49+ if !interactive.cfg.Theme.Terminal.HasForeground || !interactive.cfg.Theme.Terminal.HasBackground {
50+ t.Fatal("live inherited theme lost the captured terminal profile")
51+ }
52+}
M packages/agent/settings_store_test.go +18 -0
@@ -161,3 +161,21 @@ func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) {
161161 t.Fatal("sub-agent diagnostics did not follow sub-agent LSP settings")
162162 }
163163 }
164+
165+func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) {
166+ t.Setenv("ZOT_HOME", t.TempDir())
167+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
168+ t.Fatal(err)
169+ }
170+
171+ if err := (configSettingsStore{}).SetTheme("inherited"); err != nil {
172+ t.Fatal(err)
173+ }
174+ cfg, err := LoadConfig()
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ if cfg.Theme != "inherited" {
179+ t.Fatalf("theme = %q, want inherited", cfg.Theme)
180+ }
181+}
@@ -161,3 +161,21 @@ func TestConfigLSPEnabledForDefaultsToTrue(t *testing.T) {
161 t.Fatal("sub-agent diagnostics did not follow sub-agent LSP settings")161 t.Fatal("sub-agent diagnostics did not follow sub-agent LSP settings")
162 }162 }
163 }163 }
164+
165+func TestConfigSettingsStorePersistsInheritedTheme(t *testing.T) {
166+ t.Setenv("ZOT_HOME", t.TempDir())
167+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
168+ t.Fatal(err)
169+ }
170+
171+ if err := (configSettingsStore{}).SetTheme("inherited"); err != nil {
172+ t.Fatal(err)
173+ }
174+ cfg, err := LoadConfig()
175+ if err != nil {
176+ t.Fatal(err)
177+ }
178+ if cfg.Theme != "inherited" {
179+ t.Fatalf("theme = %q, want inherited", cfg.Theme)
180+ }
181+}
M packages/agent/skills/builtin/write-zot-themes/SKILL.md +5 -0
@@ -17,6 +17,11 @@ nothing is required. A file may contain only colors, only spinner
1717 settings, only syntax colors, or only metadata. Missing values always
1818 inherit from the built-in detected light/dark default.
1919
20+The `/settings` picker also provides an **inherited (from terminal)**
21+built-in theme. It uses the terminal's reported colors and emits truecolor
22+when advertised, with a best-effort xterm-256 fallback otherwise. JSON theme
23+files remain explicit zot palettes and use the color forms documented below.
24+
2025 User themes are discovered from:
2126
2227 ```text
@@ -17,6 +17,11 @@ nothing is required. A file may contain only colors, only spinner
17 settings, only syntax colors, or only metadata. Missing values always17 settings, only syntax colors, or only metadata. Missing values always
18 inherit from the built-in detected light/dark default.18 inherit from the built-in detected light/dark default.
19 19
20+The `/settings` picker also provides an **inherited (from terminal)**
21+built-in theme. It uses the terminal's reported colors and emits truecolor
22+when advertised, with a best-effort xterm-256 fallback otherwise. JSON theme
23+files remain explicit zot palettes and use the color forms documented below.
24+
20 User themes are discovered from:25 User themes are discovered from:
21 26
22 ```text27 ```text
M packages/tui/detect_bg.go +205 -54
@@ -9,71 +9,229 @@ import (
99 "golang.org/x/term"
1010 )
1111
12-// DetectThemeFromBackground queries the controlling tty for its
13-// current background colour using the OSC 11 escape sequence and
14-// returns Dark or Light based on the response's perceived
15-// luminance. Falls back to Dark when the terminal does not
16-// reply within timeout, which is the expected behaviour for
17-// terminals that do not implement OSC 11 (Linux console, VS Code's
18-// integrated terminal in some configurations, tmux without
19-// pass-through, very old emulators).
12+// DetectTrueColor reports whether TERM or COLORTERM advertises direct color
13+// support. This follows the same conservative signals used by ../vev.
14+func DetectTrueColor(termEnv, colorTerm string) bool {
15+ switch strings.ToLower(strings.TrimSpace(colorTerm)) {
16+ case "truecolor", "24bit":
17+ return true
18+ }
19+
20+ termEnv = strings.ToLower(strings.TrimSpace(termEnv))
21+ return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct")
22+}
23+
24+// DetectThemeFromBackground queries the controlling tty for its current
25+// foreground, background, and ANSI palette using OSC 10/11/4. The returned
26+// theme keeps the detected light/dark built-in colors for the normal auto
27+// theme and carries the terminal snapshot for the inherited theme.
2028 //
21-// The query / parse runs synchronously before the TUI is
22-// initialised so the returned theme can drive the entire session.
23-// We briefly put stdin into raw mode and disable echo so the OSC
24-// reply doesn't leak onto the user's screen as visible bytes.
29+// The query / parse runs synchronously before the TUI is initialised so the
30+// returned snapshot can drive the entire session. We briefly put stdin into
31+// raw mode and disable echo so OSC replies do not leak onto the user's screen.
2532 func DetectThemeFromBackground(timeout time.Duration) Theme {
26- // Honour explicit override env var first; some users / CI envs
27- // know better than the heuristic.
33+ profile := detectTerminalProfile(timeout)
34+ base := Dark
35+
36+ // Honour explicit override env vars first; some users and CI environments
37+ // know better than the heuristic. The terminal snapshot is still retained
38+ // so selecting the inherited theme later remains useful.
2839 switch strings.ToLower(strings.TrimSpace(os.Getenv("ZOT_THEME"))) {
2940 case "dark":
30- return Dark
41+ base = Dark
3142 case "light":
32- return Light
43+ base = Light
44+ default:
45+ if profile.HasBackground {
46+ luma := 0.2126*float64(profile.Background.R) +
47+ 0.7152*float64(profile.Background.G) +
48+ 0.0722*float64(profile.Background.B)
49+ if luma >= 127.5 {
50+ base = Light
51+ }
52+ }
3353 }
3454
35- if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
36- return Dark
55+ base.Terminal = profile
56+ return base
57+}
58+
59+type oscColorResponse struct {
60+ kind int
61+ slot int
62+ color TerminalColor
63+}
64+
65+func detectTerminalProfile(timeout time.Duration) TerminalProfile {
66+ profile := TerminalProfile{TrueColor: DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"))}
67+ if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) || timeout <= 0 {
68+ return profile
3769 }
3870
3971 fd := int(os.Stdin.Fd())
4072 st, err := term.MakeRaw(fd)
4173 if err != nil {
42- return Dark
74+ return profile
4375 }
4476 defer term.Restore(fd, st)
4577
46- // Send the query. ST (\x1b\\) and BEL (\x07) are both accepted
47- // terminators; some terminals only honour one of them, so we
48- // send BEL which is more widely supported.
49- if _, err := os.Stdout.Write([]byte("\x1b]11;?\x07")); err != nil {
50- return Dark
78+ queries := terminalColorQueries()
79+ if _, err := os.Stdout.Write([]byte(queries)); err != nil {
80+ return profile
5181 }
5282
53- deadline := time.Now().Add(timeout)
54- resp := readOSCResponse(deadline)
55- if resp == "" {
56- return Dark
83+ responses := readOSCColorResponses(time.Now().Add(timeout), terminalColorQueryCount)
84+ for _, response := range responses {
85+ switch response.kind {
86+ case 10:
87+ profile.Foreground = response.color
88+ profile.HasForeground = true
89+ case 11:
90+ profile.Background = response.color
91+ profile.HasBackground = true
92+ case 4:
93+ if response.slot >= 0 && response.slot < len(profile.Palette) {
94+ profile.Palette[response.slot] = response.color
95+ profile.PaletteKnown |= uint16(1) << response.slot
96+ }
97+ }
5798 }
99+ if profile.HasBackground {
100+ luma := 0.2126*float64(profile.Background.R) +
101+ 0.7152*float64(profile.Background.G) +
102+ 0.0722*float64(profile.Background.B)
103+ profile.Light = luma >= 127.5
104+ profile.SchemeKnown = true
105+ }
106+ return profile
107+}
58108
59- r, g, b, ok := parseOSC11Reply(resp)
60- if !ok {
61- return Dark
109+const (
110+ terminalColorQueryCount = 18
111+ terminalColorResponseLimit = 64
112+)
113+
114+func terminalColorQueries() string {
115+ var b strings.Builder
116+ b.Grow(terminalColorQueryCount * 12)
117+ b.WriteString("\x1b]10;?\x07\x1b]11;?\x07")
118+ for slot := 0; slot < 16; slot++ {
119+ b.WriteString("\x1b]4;")
120+ b.WriteString(itoa(slot))
121+ b.WriteString(";?\x07")
62122 }
123+ return b.String()
124+}
63125
64- // Rec. 709 luma. Threshold at 0.5: anything brighter than
65- // mid-grey gets the light theme.
66- luma := 0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)
67- if luma >= 0.5 {
68- return Light
126+// readOSCColorResponses drains terminal replies until all expected replies
127+// arrive, the deadline expires, or stdin hits EOF. It only consumes complete
128+// BEL/ST-terminated OSC color responses; an incomplete tail is discarded at
129+// timeout so a failed query cannot poison the next interactive read.
130+func readOSCColorResponses(deadline time.Time, expected int) []oscColorResponse {
131+ responses := make([]oscColorResponse, 0, expected)
132+ buf := make([]byte, 0, 64)
133+ for len(responses) < expected && time.Now().Before(deadline) {
134+ remaining := time.Until(deadline)
135+ if remaining <= 0 {
136+ break
137+ }
138+ b, ok, err := peekStdin(os.Stdin, remaining)
139+ if err != nil || !ok {
140+ break
141+ }
142+ if len(buf) >= terminalColorResponseLimit {
143+ buf = buf[:0]
144+ }
145+ buf = append(buf, b)
146+ if b != '\x07' && !(len(buf) >= 2 && buf[len(buf)-2] == '\x1b' && buf[len(buf)-1] == '\\') {
147+ continue
148+ }
149+ if response, ok := parseOSCColorResponse(buf); ok {
150+ responses = append(responses, response)
151+ }
152+ buf = buf[:0]
153+ }
154+ return responses
155+}
156+
157+func parseOSCColorResponse(raw []byte) (oscColorResponse, bool) {
158+ s := string(raw)
159+ s = strings.TrimSuffix(s, "\x07")
160+ s = strings.TrimSuffix(s, "\x1b\\")
161+
162+ if strings.HasPrefix(s, "\x1b]10;") {
163+ color, ok := parseTerminalRGB(s[len("\x1b]10;"):])
164+ return oscColorResponse{kind: 10, color: color}, ok
165+ }
166+ if strings.HasPrefix(s, "\x1b]11;") {
167+ color, ok := parseTerminalRGB(s[len("\x1b]11;"):])
168+ return oscColorResponse{kind: 11, color: color}, ok
169+ }
170+ if strings.HasPrefix(s, "\x1b]4;") {
171+ body := s[len("\x1b]4;"):]
172+ parts := strings.SplitN(body, ";", 2)
173+ if len(parts) != 2 || parts[0] == "" {
174+ return oscColorResponse{}, false
175+ }
176+ slot, err := strconv.Atoi(parts[0])
177+ if err != nil || slot < 0 || slot >= 16 {
178+ return oscColorResponse{}, false
179+ }
180+ color, ok := parseTerminalRGB(parts[1])
181+ return oscColorResponse{kind: 4, slot: slot, color: color}, ok
182+ }
183+ return oscColorResponse{}, false
184+}
185+
186+func parseTerminalRGB(value string) (TerminalColor, bool) {
187+ value = strings.TrimSpace(value)
188+ if strings.HasPrefix(value, "#") {
189+ if len(value) != 7 {
190+ return TerminalColor{}, false
191+ }
192+ r, ok := parseHexByte(value[1:3])
193+ if !ok {
194+ return TerminalColor{}, false
195+ }
196+ g, ok := parseHexByte(value[3:5])
197+ if !ok {
198+ return TerminalColor{}, false
199+ }
200+ b, ok := parseHexByte(value[5:7])
201+ if !ok {
202+ return TerminalColor{}, false
203+ }
204+ return ColorRGB(r, g, b), true
205+ }
206+ if !strings.HasPrefix(value, "rgb:") {
207+ return TerminalColor{}, false
208+ }
209+ parts := strings.Split(value[len("rgb:"):], "/")
210+ if len(parts) != 3 {
211+ return TerminalColor{}, false
212+ }
213+ var channels [3]int
214+ for i, part := range parts {
215+ if len(part) < 1 || len(part) > 4 {
216+ return TerminalColor{}, false
217+ }
218+ v, err := strconv.ParseUint(part, 16, 16)
219+ if err != nil {
220+ return TerminalColor{}, false
221+ }
222+ max := uint64(1)
223+ for range part {
224+ max *= 16
225+ }
226+ max--
227+ channels[i] = int(v * 255 / max)
69228 }
70- return Dark
229+ return ColorRGB(channels[0], channels[1], channels[2]), true
71230 }
72231
73-// readOSCResponse drains stdin into a small buffer until either a
74-// terminator (BEL or ST) is seen, the deadline expires, or stdin
75-// hits EOF. Returns whatever was collected, or "" on no usable
76-// response.
232+// readOSCResponse drains stdin into a small buffer until either a terminator
233+// (BEL or ST) is seen, the deadline expires, or stdin hits EOF. It remains a
234+// small helper for callers that only need one OSC reply.
77235 func readOSCResponse(deadline time.Time) string {
78236 var buf [128]byte
79237 n := 0
@@ -90,12 +248,9 @@ func readOSCResponse(deadline time.Time) string {
90248 buf[n] = b
91249 n++
92250 }
93- // BEL terminator
94251 if b == 0x07 {
95252 return string(buf[:n])
96253 }
97- // ST terminator: ESC then '\\'. We saw it the moment the
98- // previous byte was ESC and the current byte is '\\'.
99254 if n >= 2 && buf[n-2] == 0x1b && buf[n-1] == '\\' {
100255 return string(buf[:n])
101256 }
@@ -103,19 +258,16 @@ func readOSCResponse(deadline time.Time) string {
103258 return string(buf[:n])
104259 }
105260
106-// parseOSC11Reply extracts the (r, g, b) colour components from an
107-// OSC 11 reply of the form "\x1b]11;rgb:RRRR/GGGG/BBBB\x07" (or with
108-// ST terminator). The component widths can be 1, 2, 3, or 4 hex
109-// digits per channel; we normalise them into the 0..1 range.
261+// parseOSC11Reply extracts the (r, g, b) colour components from an OSC 11
262+// reply of the form "\\x1b]11;rgb:RRRR/GGGG/BBBB\\x07" (or with ST
263+// terminator). The component widths can be 1, 2, 3, or 4 hex digits per
264+// channel; we normalise them into the 0..1 range.
110265 func parseOSC11Reply(s string) (float64, float64, float64, bool) {
111- // Locate "rgb:" within the response.
112266 i := strings.Index(s, "rgb:")
113267 if i < 0 {
114268 return 0, 0, 0, false
115269 }
116- body := s[i+len("rgb:"):]
117- // Trim trailing terminator(s).
118- body = strings.TrimRight(body, "\x07")
270+ body := strings.TrimRight(s[i+len("rgb:"):], "\x07")
119271 body = strings.TrimSuffix(body, "\x1b\\")
120272 parts := strings.Split(body, "/")
121273 if len(parts) != 3 {
@@ -129,9 +281,8 @@ func parseOSC11Reply(s string) (float64, float64, float64, bool) {
129281 if err != nil {
130282 return 0, false
131283 }
132- // Normalise to 0..1: max value is (16^len - 1).
133284 max := uint64(1)
134- for j := 0; j < len(hexstr); j++ {
285+ for range hexstr {
135286 max *= 16
136287 }
137288 max--
@@ -9,71 +9,229 @@ import (
9 "golang.org/x/term"9 "golang.org/x/term"
10 )10 )
11 11
12-// DetectThemeFromBackground queries the controlling tty for its12+// DetectTrueColor reports whether TERM or COLORTERM advertises direct color
13-// current background colour using the OSC 11 escape sequence and13+// support. This follows the same conservative signals used by ../vev.
14-// returns Dark or Light based on the response's perceived14+func DetectTrueColor(termEnv, colorTerm string) bool {
15-// luminance. Falls back to Dark when the terminal does not15+ switch strings.ToLower(strings.TrimSpace(colorTerm)) {
16-// reply within timeout, which is the expected behaviour for16+ case "truecolor", "24bit":
17-// terminals that do not implement OSC 11 (Linux console, VS Code's17+ return true
18-// integrated terminal in some configurations, tmux without18+ }
19-// pass-through, very old emulators).19+
20+ termEnv = strings.ToLower(strings.TrimSpace(termEnv))
21+ return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct")
22+}
23+
24+// DetectThemeFromBackground queries the controlling tty for its current
25+// foreground, background, and ANSI palette using OSC 10/11/4. The returned
26+// theme keeps the detected light/dark built-in colors for the normal auto
27+// theme and carries the terminal snapshot for the inherited theme.
20 //28 //
21-// The query / parse runs synchronously before the TUI is29+// The query / parse runs synchronously before the TUI is initialised so the
22-// initialised so the returned theme can drive the entire session.30+// returned snapshot can drive the entire session. We briefly put stdin into
23-// We briefly put stdin into raw mode and disable echo so the OSC31+// raw mode and disable echo so OSC replies do not leak onto the user's screen.
24-// reply doesn't leak onto the user's screen as visible bytes.
25 func DetectThemeFromBackground(timeout time.Duration) Theme {32 func DetectThemeFromBackground(timeout time.Duration) Theme {
26- // Honour explicit override env var first; some users / CI envs33+ profile := detectTerminalProfile(timeout)
27- // know better than the heuristic.34+ base := Dark
35+
36+ // Honour explicit override env vars first; some users and CI environments
37+ // know better than the heuristic. The terminal snapshot is still retained
38+ // so selecting the inherited theme later remains useful.
28 switch strings.ToLower(strings.TrimSpace(os.Getenv("ZOT_THEME"))) {39 switch strings.ToLower(strings.TrimSpace(os.Getenv("ZOT_THEME"))) {
29 case "dark":40 case "dark":
30- return Dark41+ base = Dark
31 case "light":42 case "light":
32- return Light43+ base = Light
44+ default:
45+ if profile.HasBackground {
46+ luma := 0.2126*float64(profile.Background.R) +
47+ 0.7152*float64(profile.Background.G) +
48+ 0.0722*float64(profile.Background.B)
49+ if luma >= 127.5 {
50+ base = Light
51+ }
52+ }
33 }53 }
34 54
35- if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {55+ base.Terminal = profile
36- return Dark56+ return base
57+}
58+
59+type oscColorResponse struct {
60+ kind int
61+ slot int
62+ color TerminalColor
63+}
64+
65+func detectTerminalProfile(timeout time.Duration) TerminalProfile {
66+ profile := TerminalProfile{TrueColor: DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"))}
67+ if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) || timeout <= 0 {
68+ return profile
37 }69 }
38 70
39 fd := int(os.Stdin.Fd())71 fd := int(os.Stdin.Fd())
40 st, err := term.MakeRaw(fd)72 st, err := term.MakeRaw(fd)
41 if err != nil {73 if err != nil {
42- return Dark74+ return profile
43 }75 }
44 defer term.Restore(fd, st)76 defer term.Restore(fd, st)
45 77
46- // Send the query. ST (\x1b\\) and BEL (\x07) are both accepted78+ queries := terminalColorQueries()
47- // terminators; some terminals only honour one of them, so we79+ if _, err := os.Stdout.Write([]byte(queries)); err != nil {
48- // send BEL which is more widely supported.80+ return profile
49- if _, err := os.Stdout.Write([]byte("\x1b]11;?\x07")); err != nil {
50- return Dark
51 }81 }
52 82
53- deadline := time.Now().Add(timeout)83+ responses := readOSCColorResponses(time.Now().Add(timeout), terminalColorQueryCount)
54- resp := readOSCResponse(deadline)84+ for _, response := range responses {
55- if resp == "" {85+ switch response.kind {
56- return Dark86+ case 10:
87+ profile.Foreground = response.color
88+ profile.HasForeground = true
89+ case 11:
90+ profile.Background = response.color
91+ profile.HasBackground = true
92+ case 4:
93+ if response.slot >= 0 && response.slot < len(profile.Palette) {
94+ profile.Palette[response.slot] = response.color
95+ profile.PaletteKnown |= uint16(1) << response.slot
96+ }
97+ }
57 }98 }
99+ if profile.HasBackground {
100+ luma := 0.2126*float64(profile.Background.R) +
101+ 0.7152*float64(profile.Background.G) +
102+ 0.0722*float64(profile.Background.B)
103+ profile.Light = luma >= 127.5
104+ profile.SchemeKnown = true
105+ }
106+ return profile
107+}
58 108
59- r, g, b, ok := parseOSC11Reply(resp)109+const (
60- if !ok {110+ terminalColorQueryCount = 18
61- return Dark111+ terminalColorResponseLimit = 64
112+)
113+
114+func terminalColorQueries() string {
115+ var b strings.Builder
116+ b.Grow(terminalColorQueryCount * 12)
117+ b.WriteString("\x1b]10;?\x07\x1b]11;?\x07")
118+ for slot := 0; slot < 16; slot++ {
119+ b.WriteString("\x1b]4;")
120+ b.WriteString(itoa(slot))
121+ b.WriteString(";?\x07")
62 }122 }
123+ return b.String()
124+}
63 125
64- // Rec. 709 luma. Threshold at 0.5: anything brighter than126+// readOSCColorResponses drains terminal replies until all expected replies
65- // mid-grey gets the light theme.127+// arrive, the deadline expires, or stdin hits EOF. It only consumes complete
66- luma := 0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)128+// BEL/ST-terminated OSC color responses; an incomplete tail is discarded at
67- if luma >= 0.5 {129+// timeout so a failed query cannot poison the next interactive read.
68- return Light130+func readOSCColorResponses(deadline time.Time, expected int) []oscColorResponse {
131+ responses := make([]oscColorResponse, 0, expected)
132+ buf := make([]byte, 0, 64)
133+ for len(responses) < expected && time.Now().Before(deadline) {
134+ remaining := time.Until(deadline)
135+ if remaining <= 0 {
136+ break
137+ }
138+ b, ok, err := peekStdin(os.Stdin, remaining)
139+ if err != nil || !ok {
140+ break
141+ }
142+ if len(buf) >= terminalColorResponseLimit {
143+ buf = buf[:0]
144+ }
145+ buf = append(buf, b)
146+ if b != '\x07' && !(len(buf) >= 2 && buf[len(buf)-2] == '\x1b' && buf[len(buf)-1] == '\\') {
147+ continue
148+ }
149+ if response, ok := parseOSCColorResponse(buf); ok {
150+ responses = append(responses, response)
151+ }
152+ buf = buf[:0]
153+ }
154+ return responses
155+}
156+
157+func parseOSCColorResponse(raw []byte) (oscColorResponse, bool) {
158+ s := string(raw)
159+ s = strings.TrimSuffix(s, "\x07")
160+ s = strings.TrimSuffix(s, "\x1b\\")
161+
162+ if strings.HasPrefix(s, "\x1b]10;") {
163+ color, ok := parseTerminalRGB(s[len("\x1b]10;"):])
164+ return oscColorResponse{kind: 10, color: color}, ok
165+ }
166+ if strings.HasPrefix(s, "\x1b]11;") {
167+ color, ok := parseTerminalRGB(s[len("\x1b]11;"):])
168+ return oscColorResponse{kind: 11, color: color}, ok
169+ }
170+ if strings.HasPrefix(s, "\x1b]4;") {
171+ body := s[len("\x1b]4;"):]
172+ parts := strings.SplitN(body, ";", 2)
173+ if len(parts) != 2 || parts[0] == "" {
174+ return oscColorResponse{}, false
175+ }
176+ slot, err := strconv.Atoi(parts[0])
177+ if err != nil || slot < 0 || slot >= 16 {
178+ return oscColorResponse{}, false
179+ }
180+ color, ok := parseTerminalRGB(parts[1])
181+ return oscColorResponse{kind: 4, slot: slot, color: color}, ok
182+ }
183+ return oscColorResponse{}, false
184+}
185+
186+func parseTerminalRGB(value string) (TerminalColor, bool) {
187+ value = strings.TrimSpace(value)
188+ if strings.HasPrefix(value, "#") {
189+ if len(value) != 7 {
190+ return TerminalColor{}, false
191+ }
192+ r, ok := parseHexByte(value[1:3])
193+ if !ok {
194+ return TerminalColor{}, false
195+ }
196+ g, ok := parseHexByte(value[3:5])
197+ if !ok {
198+ return TerminalColor{}, false
199+ }
200+ b, ok := parseHexByte(value[5:7])
201+ if !ok {
202+ return TerminalColor{}, false
203+ }
204+ return ColorRGB(r, g, b), true
205+ }
206+ if !strings.HasPrefix(value, "rgb:") {
207+ return TerminalColor{}, false
208+ }
209+ parts := strings.Split(value[len("rgb:"):], "/")
210+ if len(parts) != 3 {
211+ return TerminalColor{}, false
212+ }
213+ var channels [3]int
214+ for i, part := range parts {
215+ if len(part) < 1 || len(part) > 4 {
216+ return TerminalColor{}, false
217+ }
218+ v, err := strconv.ParseUint(part, 16, 16)
219+ if err != nil {
220+ return TerminalColor{}, false
221+ }
222+ max := uint64(1)
223+ for range part {
224+ max *= 16
225+ }
226+ max--
227+ channels[i] = int(v * 255 / max)
69 }228 }
70- return Dark229+ return ColorRGB(channels[0], channels[1], channels[2]), true
71 }230 }
72 231
73-// readOSCResponse drains stdin into a small buffer until either a232+// readOSCResponse drains stdin into a small buffer until either a terminator
74-// terminator (BEL or ST) is seen, the deadline expires, or stdin233+// (BEL or ST) is seen, the deadline expires, or stdin hits EOF. It remains a
75-// hits EOF. Returns whatever was collected, or "" on no usable234+// small helper for callers that only need one OSC reply.
76-// response.
77 func readOSCResponse(deadline time.Time) string {235 func readOSCResponse(deadline time.Time) string {
78 var buf [128]byte236 var buf [128]byte
79 n := 0237 n := 0
@@ -90,12 +248,9 @@ func readOSCResponse(deadline time.Time) string {
90 buf[n] = b248 buf[n] = b
91 n++249 n++
92 }250 }
93- // BEL terminator
94 if b == 0x07 {251 if b == 0x07 {
95 return string(buf[:n])252 return string(buf[:n])
96 }253 }
97- // ST terminator: ESC then '\\'. We saw it the moment the
98- // previous byte was ESC and the current byte is '\\'.
99 if n >= 2 && buf[n-2] == 0x1b && buf[n-1] == '\\' {254 if n >= 2 && buf[n-2] == 0x1b && buf[n-1] == '\\' {
100 return string(buf[:n])255 return string(buf[:n])
101 }256 }
@@ -103,19 +258,16 @@ func readOSCResponse(deadline time.Time) string {
103 return string(buf[:n])258 return string(buf[:n])
104 }259 }
105 260
106-// parseOSC11Reply extracts the (r, g, b) colour components from an261+// parseOSC11Reply extracts the (r, g, b) colour components from an OSC 11
107-// OSC 11 reply of the form "\x1b]11;rgb:RRRR/GGGG/BBBB\x07" (or with262+// reply of the form "\\x1b]11;rgb:RRRR/GGGG/BBBB\\x07" (or with ST
108-// ST terminator). The component widths can be 1, 2, 3, or 4 hex263+// terminator). The component widths can be 1, 2, 3, or 4 hex digits per
109-// digits per channel; we normalise them into the 0..1 range.264+// channel; we normalise them into the 0..1 range.
110 func parseOSC11Reply(s string) (float64, float64, float64, bool) {265 func parseOSC11Reply(s string) (float64, float64, float64, bool) {
111- // Locate "rgb:" within the response.
112 i := strings.Index(s, "rgb:")266 i := strings.Index(s, "rgb:")
113 if i < 0 {267 if i < 0 {
114 return 0, 0, 0, false268 return 0, 0, 0, false
115 }269 }
116- body := s[i+len("rgb:"):]270+ body := strings.TrimRight(s[i+len("rgb:"):], "\x07")
117- // Trim trailing terminator(s).
118- body = strings.TrimRight(body, "\x07")
119 body = strings.TrimSuffix(body, "\x1b\\")271 body = strings.TrimSuffix(body, "\x1b\\")
120 parts := strings.Split(body, "/")272 parts := strings.Split(body, "/")
121 if len(parts) != 3 {273 if len(parts) != 3 {
@@ -129,9 +281,8 @@ func parseOSC11Reply(s string) (float64, float64, float64, bool) {
129 if err != nil {281 if err != nil {
130 return 0, false282 return 0, false
131 }283 }
132- // Normalise to 0..1: max value is (16^len - 1).
133 max := uint64(1)284 max := uint64(1)
134- for j := 0; j < len(hexstr); j++ {285+ for range hexstr {
135 max *= 16286 max *= 16
136 }287 }
137 max--288 max--
M packages/tui/highlight.go +10 -1
@@ -37,7 +37,11 @@ func (th Theme) HighlightCode(src, lang string) []string {
3737 return out
3838 }
3939 style := th.chromaStyle()
40- formatter := formatters.Get("terminal256")
40+ formatterName := "terminal256"
41+ if th.ColorMode == ColorModeTrueColor {
42+ formatterName = "terminal16m"
43+ }
44+ formatter := formatters.Get(formatterName)
4145 if formatter == nil {
4246 return strings.Split(src, "\n")
4347 }
@@ -238,7 +242,12 @@ func (th Theme) chromaStyle() *chroma.Style {
238242
239243 func (th Theme) syntaxKey() string {
240244 syn := th.Syntax
245+ outputMode := "256"
246+ if th.ColorMode == ColorModeTrueColor {
247+ outputMode = "truecolor"
248+ }
241249 return strings.Join([]string{
250+ outputMode,
242251 th.SyntaxBaseStyle,
243252 syn.Keyword,
244253 syn.KeywordConstant,
@@ -37,7 +37,11 @@ func (th Theme) HighlightCode(src, lang string) []string {
37 return out37 return out
38 }38 }
39 style := th.chromaStyle()39 style := th.chromaStyle()
40- formatter := formatters.Get("terminal256")40+ formatterName := "terminal256"
41+ if th.ColorMode == ColorModeTrueColor {
42+ formatterName = "terminal16m"
43+ }
44+ formatter := formatters.Get(formatterName)
41 if formatter == nil {45 if formatter == nil {
42 return strings.Split(src, "\n")46 return strings.Split(src, "\n")
43 }47 }
@@ -238,7 +242,12 @@ func (th Theme) chromaStyle() *chroma.Style {
238 242
239 func (th Theme) syntaxKey() string {243 func (th Theme) syntaxKey() string {
240 syn := th.Syntax244 syn := th.Syntax
245+ outputMode := "256"
246+ if th.ColorMode == ColorModeTrueColor {
247+ outputMode = "truecolor"
248+ }
241 return strings.Join([]string{249 return strings.Join([]string{
250+ outputMode,
242 th.SyntaxBaseStyle,251 th.SyntaxBaseStyle,
243 syn.Keyword,252 syn.Keyword,
244 syn.KeywordConstant,253 syn.KeywordConstant,
M packages/tui/layout.go +1 -1
@@ -68,7 +68,7 @@ func InputBlock(th Theme, lines []string, width int) []string {
6868 if visible < bubbleW {
6969 content += strings.Repeat(" ", bubbleW-visible)
7070 }
71- bg := sgrBGColor(th.UserBubbleBG)
71+ bg := th.sgrBGColor(th.UserBubbleBG)
7272 bar := bg + th.FG256(th.Accent, "▌ ")
7373 body := bg + strings.ReplaceAll(content, reset, reset+bg) + reset
7474 return bar + body
@@ -68,7 +68,7 @@ func InputBlock(th Theme, lines []string, width int) []string {
68 if visible < bubbleW {68 if visible < bubbleW {
69 content += strings.Repeat(" ", bubbleW-visible)69 content += strings.Repeat(" ", bubbleW-visible)
70 }70 }
71- bg := sgrBGColor(th.UserBubbleBG)71+ bg := th.sgrBGColor(th.UserBubbleBG)
72 bar := bg + th.FG256(th.Accent, "▌ ")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
M packages/tui/render.go +1 -1
@@ -370,7 +370,7 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
370370 // overwrites, leaving ghost highlights behind.
371371 hasSelection := false
372372 if r.theme.Background == nil {
373- selectionBG := sgrBG(r.theme.SelectionBG)
373+ selectionBG := r.theme.bgPrefix(Color256(r.theme.SelectionBG))
374374 for _, l := range frame {
375375 if selectionBG != "" && strings.Contains(l, selectionBG) {
376376 hasSelection = true
@@ -370,7 +370,7 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
370 // overwrites, leaving ghost highlights behind.370 // overwrites, leaving ghost highlights behind.
371 hasSelection := false371 hasSelection := false
372 if r.theme.Background == nil {372 if r.theme.Background == nil {
373- selectionBG := sgrBG(r.theme.SelectionBG)373+ selectionBG := r.theme.bgPrefix(Color256(r.theme.SelectionBG))
374 for _, l := range frame {374 for _, l := range frame {
375 if selectionBG != "" && strings.Contains(l, selectionBG) {375 if selectionBG != "" && strings.Contains(l, selectionBG) {
376 hasSelection = true376 hasSelection = true
M packages/tui/theme.go +224 -18
@@ -33,6 +33,39 @@ 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+
44+// TerminalProfile is the best-effort color snapshot reported by the
45+// controlling terminal. The ANSI palette is optional; unknown slots are
46+// resolved through the classic xterm-256 palette.
47+type TerminalProfile struct {
48+ Foreground TerminalColor
49+ Background TerminalColor
50+
51+ HasForeground bool
52+ HasBackground bool
53+
54+ Palette [16]TerminalColor
55+ PaletteKnown uint16
56+ TrueColor bool
57+ Light bool
58+ SchemeKnown bool
59+}
60+
61+// PaletteColor returns a reported ANSI palette entry when available.
62+func (p TerminalProfile) PaletteColor(index int) (TerminalColor, bool) {
63+ if index < 0 || index >= len(p.Palette) || p.PaletteKnown&(uint16(1)<<index) == 0 {
64+ return TerminalColor{}, false
65+ }
66+ return p.Palette[index], true
67+}
68+
3669 // ANSI 256-color palette used by zot. Defined as numeric codes so we
3770 // can swap themes without changing any render code.
3871 type Theme struct {
@@ -53,6 +86,13 @@ type Theme struct {
5386 SelectionBG int // background for highlighted rows
5487 SelectionFG int // foreground for highlighted rows
5588
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.
92+ Inherited bool
93+ ColorMode ColorMode
94+ Terminal TerminalProfile
95+
5696 SpinnerFrames []string
5797 SpinnerMessages []string
5898 SpinnerIntervalMS int
@@ -193,24 +233,105 @@ var Light = Theme{
193233 Syntax: nordSyntax,
194234 }
195235
196-// FG256 wraps s in foreground color c using ANSI 256-color SGR.
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.
197239 func (t Theme) FG256(c int, s string) string {
198- return sgrFG(c) + s + reset
240+ return t.fgPrefix(c) + s + reset
199241 }
200242
201-// BG256 wraps s in background color c using ANSI 256-color SGR.
202-// Useful when the visible cell needs a coloured fill but the
203-// underlying character should be a regular space (so mouse-copy
204-// from the terminal yields whitespace instead of a glyph).
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).
205246 func (t Theme) BG256(c int, s string) string {
206- return sgrBG(c) + s + reset
247+ return t.bgPrefix(Color256(c)) + s + reset
207248 }
208249
209-// BG wraps s in a terminal background colour. Unlike BG256, this can
210-// target ANSI theme slots or truecolor RGB, which lets selected UI
211-// surfaces follow the user's terminal theme.
250+// BG wraps s in a terminal background color. Inherited themes quantize RGB
251+// values on terminals without truecolor support.
212252 func (t Theme) BG(c TerminalColor, s string) string {
213- return sgrBGColor(c) + s + reset
253+ return t.bgPrefix(c) + s + reset
254+}
255+
256+func (t Theme) fgPrefix(index int) string {
257+ if t.Inherited && index == t.FG && t.Terminal.HasForeground {
258+ return sgrFGColor(t.resolveColor(t.Terminal.Foreground))
259+ }
260+ return sgrFGColor(t.resolveColor(Color256(index)))
261+}
262+
263+func (t Theme) bgPrefix(color TerminalColor) string {
264+ return sgrBGColor(t.resolveColor(color))
265+}
266+
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 {
271+ percent = clampPercent(percent)
272+ foreground := t.resolveForeground(index)
273+ background := t.Terminal.Background
274+ if !t.Terminal.HasBackground {
275+ background = Color256(0)
276+ }
277+ fg, _ := rgbForTerminalColor(foreground)
278+ bg, _ := rgbForTerminalColor(background)
279+ return t.resolveColor(ColorRGB(
280+ blendChannel(fg[0], bg[0], percent),
281+ blendChannel(fg[1], bg[1], percent),
282+ blendChannel(fg[2], bg[2], percent),
283+ ))
284+}
285+
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 {
294+ switch color.Mode {
295+ case terminalColor256:
296+ index := clampXtermIndex(color.Index)
297+ if t.Inherited {
298+ if palette, ok := t.Terminal.PaletteColor(index); ok {
299+ if t.ColorMode == ColorModeTrueColor {
300+ return palette
301+ }
302+ return Color256(index)
303+ }
304+ if t.ColorMode == ColorModeTrueColor {
305+ r, g, b := xterm256RGB(index)
306+ return ColorRGB(r, g, b)
307+ }
308+ }
309+ if t.ColorMode == ColorModeTrueColor {
310+ r, g, b := xterm256RGB(index)
311+ return ColorRGB(r, g, b)
312+ }
313+ return Color256(index)
314+ case terminalColorRGB:
315+ if t.Inherited && t.ColorMode == ColorMode256 {
316+ return Color256(nearestXtermColor(color.R, color.G, color.B))
317+ }
318+ }
319+ return color
320+}
321+
322+func sgrFGColor(color TerminalColor) string {
323+ switch color.Mode {
324+ case terminalColorANSI:
325+ return "\x1b[" + itoa(color.Index) + "m"
326+ case terminalColorRGB:
327+ return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m"
328+ default:
329+ return sgrFG(clampXtermIndex(color.Index))
330+ }
331+}
332+
333+func (t Theme) sgrBGColor(color TerminalColor) string {
334+ return sgrBGColor(t.resolveColor(color))
214335 }
215336
216337 // AccentBar returns a 2-cell-wide leader: a coloured half-block
@@ -230,7 +351,7 @@ func (t Theme) Highlight(s string) string {
230351
231352 // SelectionStyle returns the SGR prefix for the theme's selected row.
232353 func (t Theme) SelectionStyle() string {
233- return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG)
354+ return t.fgPrefix(t.SelectionFG) + t.bgPrefix(Color256(t.SelectionBG))
234355 }
235356
236357 // BackgroundStyle returns the SGR prefix for the optional full-row
@@ -240,14 +361,14 @@ func (t Theme) BackgroundStyle() string {
240361 if t.Background == nil {
241362 return ""
242363 }
243- return sgrBGColor(*t.Background)
364+ return t.bgPrefix(*t.Background)
244365 }
245366
246367 // SelectionStyleFG returns the SGR prefix for selected-row text with
247368 // a custom foreground. Useful for preserving semantic marks inside a
248369 // highlighted row without hardcoding escape sequences outside theme.go.
249370 func (t Theme) SelectionStyleFG(fg int) string {
250- return sgrFG(fg) + sgrBG(t.SelectionBG)
371+ return t.fgPrefix(fg) + t.bgPrefix(Color256(t.SelectionBG))
251372 }
252373
253374 // PadHighlight styles s and extends the selection background to the
@@ -270,7 +391,7 @@ func (t Theme) UserBubble(s string, width int) string {
270391 if visible < width {
271392 s += strings.Repeat(" ", width-visible)
272393 }
273- return sgrFG(t.UserBubbleFG) + sgrBGColor(t.UserBubbleBG) + s + reset
394+ return t.fgPrefix(t.UserBubbleFG) + t.bgPrefix(t.UserBubbleBG) + s + reset
274395 }
275396
276397 // UserBubbleRow renders one user-bubble row prefixed with a coloured
@@ -302,19 +423,104 @@ func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
302423
303424 const reset = "\x1b[0m"
304425
305-func sgrFG(c int) string { return "\x1b[38;5;" + itoa(c) + "m" }
306-func sgrBG(c int) string { return "\x1b[48;5;" + itoa(c) + "m" }
426+func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" }
427+func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }
307428 func sgrBGColor(c TerminalColor) string {
308429 switch c.Mode {
309430 case terminalColorANSI:
310431 return "\x1b[" + itoa(c.Index) + "m"
311432 case terminalColorRGB:
312- return "\x1b[48;2;" + itoa(c.R) + ";" + itoa(c.G) + ";" + itoa(c.B) + "m"
433+ return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m"
313434 default:
314435 return sgrBG(c.Index)
315436 }
316437 }
317438
439+func clampXtermIndex(index int) int {
440+ if index < 0 {
441+ return 0
442+ }
443+ if index > 255 {
444+ return 255
445+ }
446+ return index
447+}
448+
449+func clampByte(value int) int {
450+ if value < 0 {
451+ return 0
452+ }
453+ if value > 255 {
454+ return 255
455+ }
456+ return value
457+}
458+
459+func clampPercent(percent int) int {
460+ if percent < 0 {
461+ return 0
462+ }
463+ if percent > 100 {
464+ return 100
465+ }
466+ return percent
467+}
468+
469+func rgbForTerminalColor(color TerminalColor) ([3]int, bool) {
470+ switch color.Mode {
471+ case terminalColorRGB:
472+ return [3]int{clampByte(color.R), clampByte(color.G), clampByte(color.B)}, true
473+ case terminalColor256:
474+ r, g, b := xterm256RGB(clampXtermIndex(color.Index))
475+ return [3]int{r, g, b}, true
476+ case terminalColorANSI:
477+ if index, ok := ansiSGRToXtermIndex(color.Index); ok {
478+ r, g, b := xterm256RGB(index)
479+ return [3]int{r, g, b}, true
480+ }
481+ }
482+ return [3]int{}, false
483+}
484+
485+func ansiSGRToXtermIndex(sgr int) (int, bool) {
486+ switch {
487+ case sgr >= 30 && sgr <= 37:
488+ return sgr - 30, true
489+ case sgr >= 90 && sgr <= 97:
490+ return sgr - 90 + 8, true
491+ case sgr >= 40 && sgr <= 47:
492+ return sgr - 40, true
493+ case sgr >= 100 && sgr <= 107:
494+ return sgr - 100 + 8, true
495+ default:
496+ return 0, false
497+ }
498+}
499+
500+func blendChannel(from, to, percent int) int {
501+ return clampByte((from*(100-percent) + to*percent + 50) / 100)
502+}
503+
504+func nearestXtermColor(r, g, b int) int {
505+ r = clampByte(r)
506+ g = clampByte(g)
507+ b = clampByte(b)
508+ best := 0
509+ bestDistance := int(^uint(0) >> 1)
510+ for index := 0; index < 256; index++ {
511+ cr, cg, cb := xterm256RGB(index)
512+ dr := r - cr
513+ dg := g - cg
514+ db := b - cb
515+ distance := dr*dr + dg*dg + db*db
516+ if distance < bestDistance {
517+ best = index
518+ bestDistance = distance
519+ }
520+ }
521+ return best
522+}
523+
318524 // small itoa to avoid pulling strconv into this hot path twice.
319525 func itoa(n int) string {
320526 if n == 0 {
@@ -33,6 +33,39 @@ func ColorRGB(r, g, b int) TerminalColor {
33 return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b}33 return TerminalColor{Mode: terminalColorRGB, R: r, G: g, B: b}
34 }34 }
35 35
36+// ColorMode is the output color depth used by an inherited theme.
37+type ColorMode uint8
38+
39+const (
40+ ColorMode256 ColorMode = iota
41+ ColorModeTrueColor
42+)
43+
44+// TerminalProfile is the best-effort color snapshot reported by the
45+// controlling terminal. The ANSI palette is optional; unknown slots are
46+// resolved through the classic xterm-256 palette.
47+type TerminalProfile struct {
48+ Foreground TerminalColor
49+ Background TerminalColor
50+
51+ HasForeground bool
52+ HasBackground bool
53+
54+ Palette [16]TerminalColor
55+ PaletteKnown uint16
56+ TrueColor bool
57+ Light bool
58+ SchemeKnown bool
59+}
60+
61+// PaletteColor returns a reported ANSI palette entry when available.
62+func (p TerminalProfile) PaletteColor(index int) (TerminalColor, bool) {
63+ if index < 0 || index >= len(p.Palette) || p.PaletteKnown&(uint16(1)<<index) == 0 {
64+ return TerminalColor{}, false
65+ }
66+ return p.Palette[index], true
67+}
68+
36 // ANSI 256-color palette used by zot. Defined as numeric codes so we69 // ANSI 256-color palette used by zot. Defined as numeric codes so we
37 // can swap themes without changing any render code.70 // can swap themes without changing any render code.
38 type Theme struct {71 type Theme struct {
@@ -53,6 +86,13 @@ type Theme struct {
53 SelectionBG int // background for highlighted rows86 SelectionBG int // background for highlighted rows
54 SelectionFG int // foreground for highlighted rows87 SelectionFG int // foreground for highlighted rows
55 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.
92+ Inherited bool
93+ ColorMode ColorMode
94+ Terminal TerminalProfile
95+
56 SpinnerFrames []string96 SpinnerFrames []string
57 SpinnerMessages []string97 SpinnerMessages []string
58 SpinnerIntervalMS int98 SpinnerIntervalMS int
@@ -193,24 +233,105 @@ var Light = Theme{
193 Syntax: nordSyntax,233 Syntax: nordSyntax,
194 }234 }
195 235
196-// FG256 wraps s in foreground color c using ANSI 256-color SGR.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.
197 func (t Theme) FG256(c int, s string) string {239 func (t Theme) FG256(c int, s string) string {
198- return sgrFG(c) + s + reset240+ return t.fgPrefix(c) + s + reset
199 }241 }
200 242
201-// BG256 wraps s in background color c using ANSI 256-color SGR.243+// BG256 wraps s in a background color using the theme's output capability.
202-// Useful when the visible cell needs a coloured fill but the244+// Useful when the visible cell needs a coloured fill but the underlying
203-// underlying character should be a regular space (so mouse-copy245+// character should be a regular space (so mouse-copy yields whitespace).
204-// from the terminal yields whitespace instead of a glyph).
205 func (t Theme) BG256(c int, s string) string {246 func (t Theme) BG256(c int, s string) string {
206- return sgrBG(c) + s + reset247+ return t.bgPrefix(Color256(c)) + s + reset
207 }248 }
208 249
209-// BG wraps s in a terminal background colour. Unlike BG256, this can250+// BG wraps s in a terminal background color. Inherited themes quantize RGB
210-// target ANSI theme slots or truecolor RGB, which lets selected UI251+// values on terminals without truecolor support.
211-// surfaces follow the user's terminal theme.
212 func (t Theme) BG(c TerminalColor, s string) string {252 func (t Theme) BG(c TerminalColor, s string) string {
213- return sgrBGColor(c) + s + reset253+ return t.bgPrefix(c) + s + reset
254+}
255+
256+func (t Theme) fgPrefix(index int) string {
257+ if t.Inherited && index == t.FG && t.Terminal.HasForeground {
258+ return sgrFGColor(t.resolveColor(t.Terminal.Foreground))
259+ }
260+ return sgrFGColor(t.resolveColor(Color256(index)))
261+}
262+
263+func (t Theme) bgPrefix(color TerminalColor) string {
264+ return sgrBGColor(t.resolveColor(color))
265+}
266+
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 {
271+ percent = clampPercent(percent)
272+ foreground := t.resolveForeground(index)
273+ background := t.Terminal.Background
274+ if !t.Terminal.HasBackground {
275+ background = Color256(0)
276+ }
277+ fg, _ := rgbForTerminalColor(foreground)
278+ bg, _ := rgbForTerminalColor(background)
279+ return t.resolveColor(ColorRGB(
280+ blendChannel(fg[0], bg[0], percent),
281+ blendChannel(fg[1], bg[1], percent),
282+ blendChannel(fg[2], bg[2], percent),
283+ ))
284+}
285+
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 {
294+ switch color.Mode {
295+ case terminalColor256:
296+ index := clampXtermIndex(color.Index)
297+ if t.Inherited {
298+ if palette, ok := t.Terminal.PaletteColor(index); ok {
299+ if t.ColorMode == ColorModeTrueColor {
300+ return palette
301+ }
302+ return Color256(index)
303+ }
304+ if t.ColorMode == ColorModeTrueColor {
305+ r, g, b := xterm256RGB(index)
306+ return ColorRGB(r, g, b)
307+ }
308+ }
309+ if t.ColorMode == ColorModeTrueColor {
310+ r, g, b := xterm256RGB(index)
311+ return ColorRGB(r, g, b)
312+ }
313+ return Color256(index)
314+ case terminalColorRGB:
315+ if t.Inherited && t.ColorMode == ColorMode256 {
316+ return Color256(nearestXtermColor(color.R, color.G, color.B))
317+ }
318+ }
319+ return color
320+}
321+
322+func sgrFGColor(color TerminalColor) string {
323+ switch color.Mode {
324+ case terminalColorANSI:
325+ return "\x1b[" + itoa(color.Index) + "m"
326+ case terminalColorRGB:
327+ return "\x1b[38;2;" + itoa(clampByte(color.R)) + ";" + itoa(clampByte(color.G)) + ";" + itoa(clampByte(color.B)) + "m"
328+ default:
329+ return sgrFG(clampXtermIndex(color.Index))
330+ }
331+}
332+
333+func (t Theme) sgrBGColor(color TerminalColor) string {
334+ return sgrBGColor(t.resolveColor(color))
214 }335 }
215 336
216 // AccentBar returns a 2-cell-wide leader: a coloured half-block337 // AccentBar returns a 2-cell-wide leader: a coloured half-block
@@ -230,7 +351,7 @@ func (t Theme) Highlight(s string) string {
230 351
231 // SelectionStyle returns the SGR prefix for the theme's selected row.352 // SelectionStyle returns the SGR prefix for the theme's selected row.
232 func (t Theme) SelectionStyle() string {353 func (t Theme) SelectionStyle() string {
233- return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG)354+ return t.fgPrefix(t.SelectionFG) + t.bgPrefix(Color256(t.SelectionBG))
234 }355 }
235 356
236 // BackgroundStyle returns the SGR prefix for the optional full-row357 // BackgroundStyle returns the SGR prefix for the optional full-row
@@ -240,14 +361,14 @@ func (t Theme) BackgroundStyle() string {
240 if t.Background == nil {361 if t.Background == nil {
241 return ""362 return ""
242 }363 }
243- return sgrBGColor(*t.Background)364+ return t.bgPrefix(*t.Background)
244 }365 }
245 366
246 // SelectionStyleFG returns the SGR prefix for selected-row text with367 // SelectionStyleFG returns the SGR prefix for selected-row text with
247 // a custom foreground. Useful for preserving semantic marks inside a368 // a custom foreground. Useful for preserving semantic marks inside a
248 // highlighted row without hardcoding escape sequences outside theme.go.369 // highlighted row without hardcoding escape sequences outside theme.go.
249 func (t Theme) SelectionStyleFG(fg int) string {370 func (t Theme) SelectionStyleFG(fg int) string {
250- return sgrFG(fg) + sgrBG(t.SelectionBG)371+ return t.fgPrefix(fg) + t.bgPrefix(Color256(t.SelectionBG))
251 }372 }
252 373
253 // PadHighlight styles s and extends the selection background to the374 // PadHighlight styles s and extends the selection background to the
@@ -270,7 +391,7 @@ func (t Theme) UserBubble(s string, width int) string {
270 if visible < width {391 if visible < width {
271 s += strings.Repeat(" ", width-visible)392 s += strings.Repeat(" ", width-visible)
272 }393 }
273- return sgrFG(t.UserBubbleFG) + sgrBGColor(t.UserBubbleBG) + s + reset394+ return t.fgPrefix(t.UserBubbleFG) + t.bgPrefix(t.UserBubbleBG) + s + reset
274 }395 }
275 396
276 // UserBubbleRow renders one user-bubble row prefixed with a coloured397 // UserBubbleRow renders one user-bubble row prefixed with a coloured
@@ -302,19 +423,104 @@ func Italic(s string) string { return "\x1b[3m" + s + "\x1b[23m" }
302 423
303 const reset = "\x1b[0m"424 const reset = "\x1b[0m"
304 425
305-func sgrFG(c int) string { return "\x1b[38;5;" + itoa(c) + "m" }426+func sgrFG(c int) string { return "\x1b[38;5;" + itoa(clampXtermIndex(c)) + "m" }
306-func sgrBG(c int) string { return "\x1b[48;5;" + itoa(c) + "m" }427+func sgrBG(c int) string { return "\x1b[48;5;" + itoa(clampXtermIndex(c)) + "m" }
307 func sgrBGColor(c TerminalColor) string {428 func sgrBGColor(c TerminalColor) string {
308 switch c.Mode {429 switch c.Mode {
309 case terminalColorANSI:430 case terminalColorANSI:
310 return "\x1b[" + itoa(c.Index) + "m"431 return "\x1b[" + itoa(c.Index) + "m"
311 case terminalColorRGB:432 case terminalColorRGB:
312- return "\x1b[48;2;" + itoa(c.R) + ";" + itoa(c.G) + ";" + itoa(c.B) + "m"433+ return "\x1b[48;2;" + itoa(clampByte(c.R)) + ";" + itoa(clampByte(c.G)) + ";" + itoa(clampByte(c.B)) + "m"
313 default:434 default:
314 return sgrBG(c.Index)435 return sgrBG(c.Index)
315 }436 }
316 }437 }
317 438
439+func clampXtermIndex(index int) int {
440+ if index < 0 {
441+ return 0
442+ }
443+ if index > 255 {
444+ return 255
445+ }
446+ return index
447+}
448+
449+func clampByte(value int) int {
450+ if value < 0 {
451+ return 0
452+ }
453+ if value > 255 {
454+ return 255
455+ }
456+ return value
457+}
458+
459+func clampPercent(percent int) int {
460+ if percent < 0 {
461+ return 0
462+ }
463+ if percent > 100 {
464+ return 100
465+ }
466+ return percent
467+}
468+
469+func rgbForTerminalColor(color TerminalColor) ([3]int, bool) {
470+ switch color.Mode {
471+ case terminalColorRGB:
472+ return [3]int{clampByte(color.R), clampByte(color.G), clampByte(color.B)}, true
473+ case terminalColor256:
474+ r, g, b := xterm256RGB(clampXtermIndex(color.Index))
475+ return [3]int{r, g, b}, true
476+ case terminalColorANSI:
477+ if index, ok := ansiSGRToXtermIndex(color.Index); ok {
478+ r, g, b := xterm256RGB(index)
479+ return [3]int{r, g, b}, true
480+ }
481+ }
482+ return [3]int{}, false
483+}
484+
485+func ansiSGRToXtermIndex(sgr int) (int, bool) {
486+ switch {
487+ case sgr >= 30 && sgr <= 37:
488+ return sgr - 30, true
489+ case sgr >= 90 && sgr <= 97:
490+ return sgr - 90 + 8, true
491+ case sgr >= 40 && sgr <= 47:
492+ return sgr - 40, true
493+ case sgr >= 100 && sgr <= 107:
494+ return sgr - 100 + 8, true
495+ default:
496+ return 0, false
497+ }
498+}
499+
500+func blendChannel(from, to, percent int) int {
501+ return clampByte((from*(100-percent) + to*percent + 50) / 100)
502+}
503+
504+func nearestXtermColor(r, g, b int) int {
505+ r = clampByte(r)
506+ g = clampByte(g)
507+ b = clampByte(b)
508+ best := 0
509+ bestDistance := int(^uint(0) >> 1)
510+ for index := 0; index < 256; index++ {
511+ cr, cg, cb := xterm256RGB(index)
512+ dr := r - cr
513+ dg := g - cg
514+ db := b - cb
515+ distance := dr*dr + dg*dg + db*db
516+ if distance < bestDistance {
517+ best = index
518+ bestDistance = distance
519+ }
520+ }
521+ return best
522+}
523+
318 // small itoa to avoid pulling strconv into this hot path twice.524 // small itoa to avoid pulling strconv into this hot path twice.
319 func itoa(n int) string {525 func itoa(n int) string {
320 if n == 0 {526 if n == 0 {
A packages/tui/theme_inherited_test.go +179 -0
@@ -0,0 +1,179 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestDetectTrueColor(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ term string
13+ colorTerm string
14+ want bool
15+ }{
16+ {name: "COLORTERM truecolor", term: "screen", colorTerm: "truecolor", want: true},
17+ {name: "COLORTERM 24bit", term: "screen", colorTerm: "24bit", want: true},
18+ {name: "direct TERM", term: "xterm-direct", want: true},
19+ {name: "direct TERM suffix", term: "screen-direct", want: true},
20+ {name: "256 color TERM", term: "xterm-256color", colorTerm: "", want: false},
21+ {name: "unknown TERM", term: "dumb", colorTerm: "", want: false},
22+ }
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ if got := DetectTrueColor(tt.term, tt.colorTerm); got != tt.want {
26+ t.Fatalf("DetectTrueColor(%q, %q) = %v, want %v", tt.term, tt.colorTerm, got, tt.want)
27+ }
28+ })
29+ }
30+}
31+
32+func TestParseOSCColorResponse(t *testing.T) {
33+ tests := []struct {
34+ name string
35+ raw string
36+ kind int
37+ slot int
38+ want TerminalColor
39+ }{
40+ {
41+ name: "foreground hash",
42+ raw: "\x1b]10;#123456\x07",
43+ kind: 10,
44+ want: ColorRGB(0x12, 0x34, 0x56),
45+ },
46+ {
47+ name: "background rgb",
48+ raw: "\x1b]11;rgb:ffff/8000/0000\x1b\\",
49+ kind: 11,
50+ want: ColorRGB(255, 127, 0),
51+ },
52+ {
53+ name: "palette slot",
54+ raw: "\x1b]4;12;rgb:12/34/56\x07",
55+ kind: 4,
56+ slot: 12,
57+ want: ColorRGB(0x12, 0x34, 0x56),
58+ },
59+ }
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ got, ok := parseOSCColorResponse([]byte(tt.raw))
63+ if !ok {
64+ t.Fatal("parseOSCColorResponse returned !ok")
65+ }
66+ if got.kind != tt.kind || got.slot != tt.slot || got.color != tt.want {
67+ t.Fatalf("response = %#v, want kind=%d slot=%d color=%#v", got, tt.kind, tt.slot, tt.want)
68+ }
69+ })
70+ }
71+}
72+
73+func inheritedTestTheme(trueColor bool) Theme {
74+ detected := Dark
75+ detected.Terminal = TerminalProfile{
76+ Foreground: ColorRGB(220, 210, 200),
77+ Background: ColorRGB(12, 18, 24),
78+ HasForeground: true,
79+ HasBackground: true,
80+ TrueColor: trueColor,
81+ }
82+ detected.Terminal.Palette[10] = ColorRGB(20, 220, 80)
83+ detected.Terminal.Palette[12] = ColorRGB(40, 100, 240)
84+ detected.Terminal.Palette[13] = ColorRGB(220, 70, 210)
85+ detected.Terminal.PaletteKnown = 1<<10 | 1<<12 | 1<<13
86+ return InheritedTheme(detected)
87+}
88+
89+func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) {
90+ 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)
93+ }
94+ 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)
96+ }
97+ if got := th.FG256(th.FG, "x"); !strings.Contains(got, "\x1b[38;2;220;210;200m") {
98+ t.Fatalf("foreground did not inherit the terminal default: %q", got)
99+ }
100+ if got := th.UserBubble("x", 3); !strings.Contains(got, "\x1b[48;2;") {
101+ t.Fatalf("user bubble did not use truecolor background: %q", got)
102+ }
103+ if got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n"); !strings.Contains(got, "\x1b[38;2;") {
104+ t.Fatalf("syntax highlighting did not use truecolor: %q", got)
105+ }
106+ if got := th.DimColor(th.FG, 45); got.Mode != terminalColorRGB {
107+ t.Fatalf("DimColor mode = %v, want RGB", got.Mode)
108+ }
109+}
110+
111+func TestInheritedThemeFallsBackTo256Colors(t *testing.T) {
112+ 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)
115+ }
116+ 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)
118+ }
119+ if got := th.UserBubble("x", 3); strings.Contains(got, "\x1b[48;2;") || !strings.Contains(got, "\x1b[48;5;") {
120+ t.Fatalf("user bubble did not quantize its background: %q", got)
121+ }
122+ if got := th.DimColor(th.FG, 45); got.Mode != terminalColor256 {
123+ t.Fatalf("DimColor mode = %v, want 256", got.Mode)
124+ }
125+ if got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n"); strings.Contains(got, "\x1b[38;2;") {
126+ t.Fatalf("syntax highlighting unexpectedly used truecolor: %q", got)
127+ }
128+}
129+
130+func TestExplicitThemesRemainIndexed(t *testing.T) {
131+ 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)
133+ }
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)
136+ }
137+}
138+
139+func TestAvailableThemesIncludesInherited(t *testing.T) {
140+ options := AvailableThemes(t.TempDir())
141+ for _, option := range options {
142+ if option.Value == "inherited" {
143+ if option.Label != "inherited (from terminal)" || !option.Builtin {
144+ t.Fatalf("unexpected inherited option: %#v", option)
145+ }
146+ return
147+ }
148+ }
149+ t.Fatal("available themes did not include inherited")
150+}
151+
152+func TestIsLightThemePreservesExplicitDarkTheme(t *testing.T) {
153+ dark := Dark
154+ dark.Terminal.SchemeKnown = true
155+ dark.Terminal.Light = true
156+ if IsLightTheme(dark) {
157+ t.Fatal("explicit dark theme was reclassified from the terminal scheme")
158+ }
159+
160+ inherited := InheritedTheme(dark)
161+ if !IsLightTheme(inherited) {
162+ t.Fatal("inherited light terminal theme was not recognized as light")
163+ }
164+}
165+
166+func TestRendererDetectsInheritedSelectionBackground(t *testing.T) {
167+ var buf bytes.Buffer
168+ r := NewRenderer(&buf)
169+ r.Resize(20, 2)
170+ r.SetTheme(inheritedTestTheme(true))
171+ selected := r.theme.SelectionStyle() + "selected" + reset
172+ r.Draw([]string{"", selected}, -1, -1)
173+ buf.Reset()
174+
175+ r.Draw([]string{"", "plain"}, -1, -1)
176+ if !strings.Contains(buf.String(), MoveTo(1, 1)) {
177+ t.Fatalf("selection repaint did not include unchanged rows: %q", buf.String())
178+ }
179+}
@@ -0,0 +1,179 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestDetectTrueColor(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ term string
13+ colorTerm string
14+ want bool
15+ }{
16+ {name: "COLORTERM truecolor", term: "screen", colorTerm: "truecolor", want: true},
17+ {name: "COLORTERM 24bit", term: "screen", colorTerm: "24bit", want: true},
18+ {name: "direct TERM", term: "xterm-direct", want: true},
19+ {name: "direct TERM suffix", term: "screen-direct", want: true},
20+ {name: "256 color TERM", term: "xterm-256color", colorTerm: "", want: false},
21+ {name: "unknown TERM", term: "dumb", colorTerm: "", want: false},
22+ }
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ if got := DetectTrueColor(tt.term, tt.colorTerm); got != tt.want {
26+ t.Fatalf("DetectTrueColor(%q, %q) = %v, want %v", tt.term, tt.colorTerm, got, tt.want)
27+ }
28+ })
29+ }
30+}
31+
32+func TestParseOSCColorResponse(t *testing.T) {
33+ tests := []struct {
34+ name string
35+ raw string
36+ kind int
37+ slot int
38+ want TerminalColor
39+ }{
40+ {
41+ name: "foreground hash",
42+ raw: "\x1b]10;#123456\x07",
43+ kind: 10,
44+ want: ColorRGB(0x12, 0x34, 0x56),
45+ },
46+ {
47+ name: "background rgb",
48+ raw: "\x1b]11;rgb:ffff/8000/0000\x1b\\",
49+ kind: 11,
50+ want: ColorRGB(255, 127, 0),
51+ },
52+ {
53+ name: "palette slot",
54+ raw: "\x1b]4;12;rgb:12/34/56\x07",
55+ kind: 4,
56+ slot: 12,
57+ want: ColorRGB(0x12, 0x34, 0x56),
58+ },
59+ }
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ got, ok := parseOSCColorResponse([]byte(tt.raw))
63+ if !ok {
64+ t.Fatal("parseOSCColorResponse returned !ok")
65+ }
66+ if got.kind != tt.kind || got.slot != tt.slot || got.color != tt.want {
67+ t.Fatalf("response = %#v, want kind=%d slot=%d color=%#v", got, tt.kind, tt.slot, tt.want)
68+ }
69+ })
70+ }
71+}
72+
73+func inheritedTestTheme(trueColor bool) Theme {
74+ detected := Dark
75+ detected.Terminal = TerminalProfile{
76+ Foreground: ColorRGB(220, 210, 200),
77+ Background: ColorRGB(12, 18, 24),
78+ HasForeground: true,
79+ HasBackground: true,
80+ TrueColor: trueColor,
81+ }
82+ detected.Terminal.Palette[10] = ColorRGB(20, 220, 80)
83+ detected.Terminal.Palette[12] = ColorRGB(40, 100, 240)
84+ detected.Terminal.Palette[13] = ColorRGB(220, 70, 210)
85+ detected.Terminal.PaletteKnown = 1<<10 | 1<<12 | 1<<13
86+ return InheritedTheme(detected)
87+}
88+
89+func TestInheritedThemeUsesTrueColorAndTerminalPalette(t *testing.T) {
90+ 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)
93+ }
94+ 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)
96+ }
97+ if got := th.FG256(th.FG, "x"); !strings.Contains(got, "\x1b[38;2;220;210;200m") {
98+ t.Fatalf("foreground did not inherit the terminal default: %q", got)
99+ }
100+ if got := th.UserBubble("x", 3); !strings.Contains(got, "\x1b[48;2;") {
101+ t.Fatalf("user bubble did not use truecolor background: %q", got)
102+ }
103+ if got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n"); !strings.Contains(got, "\x1b[38;2;") {
104+ t.Fatalf("syntax highlighting did not use truecolor: %q", got)
105+ }
106+ if got := th.DimColor(th.FG, 45); got.Mode != terminalColorRGB {
107+ t.Fatalf("DimColor mode = %v, want RGB", got.Mode)
108+ }
109+}
110+
111+func TestInheritedThemeFallsBackTo256Colors(t *testing.T) {
112+ 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)
115+ }
116+ 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)
118+ }
119+ if got := th.UserBubble("x", 3); strings.Contains(got, "\x1b[48;2;") || !strings.Contains(got, "\x1b[48;5;") {
120+ t.Fatalf("user bubble did not quantize its background: %q", got)
121+ }
122+ if got := th.DimColor(th.FG, 45); got.Mode != terminalColor256 {
123+ t.Fatalf("DimColor mode = %v, want 256", got.Mode)
124+ }
125+ if got := strings.Join(th.HighlightCode("func main() {}", "go"), "\n"); strings.Contains(got, "\x1b[38;2;") {
126+ t.Fatalf("syntax highlighting unexpectedly used truecolor: %q", got)
127+ }
128+}
129+
130+func TestExplicitThemesRemainIndexed(t *testing.T) {
131+ 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)
133+ }
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)
136+ }
137+}
138+
139+func TestAvailableThemesIncludesInherited(t *testing.T) {
140+ options := AvailableThemes(t.TempDir())
141+ for _, option := range options {
142+ if option.Value == "inherited" {
143+ if option.Label != "inherited (from terminal)" || !option.Builtin {
144+ t.Fatalf("unexpected inherited option: %#v", option)
145+ }
146+ return
147+ }
148+ }
149+ t.Fatal("available themes did not include inherited")
150+}
151+
152+func TestIsLightThemePreservesExplicitDarkTheme(t *testing.T) {
153+ dark := Dark
154+ dark.Terminal.SchemeKnown = true
155+ dark.Terminal.Light = true
156+ if IsLightTheme(dark) {
157+ t.Fatal("explicit dark theme was reclassified from the terminal scheme")
158+ }
159+
160+ inherited := InheritedTheme(dark)
161+ if !IsLightTheme(inherited) {
162+ t.Fatal("inherited light terminal theme was not recognized as light")
163+ }
164+}
165+
166+func TestRendererDetectsInheritedSelectionBackground(t *testing.T) {
167+ var buf bytes.Buffer
168+ r := NewRenderer(&buf)
169+ r.Resize(20, 2)
170+ r.SetTheme(inheritedTestTheme(true))
171+ selected := r.theme.SelectionStyle() + "selected" + reset
172+ r.Draw([]string{"", selected}, -1, -1)
173+ buf.Reset()
174+
175+ r.Draw([]string{"", "plain"}, -1, -1)
176+ if !strings.Contains(buf.String(), MoveTo(1, 1)) {
177+ t.Fatalf("selection repaint did not include unchanged rows: %q", buf.String())
178+ }
179+}
M packages/tui/theme_loader.go +141 -4
@@ -172,6 +172,135 @@ func (c *TerminalColorValue) UnmarshalJSON(data []byte) error {
172172 return nil
173173 }
174174
175+// InheritedTheme returns a theme derived from the terminal snapshot carried
176+// by detected. It prefers the terminal's reported ANSI palette for semantic
177+// accents, derives muted and selected surfaces by fading toward the terminal
178+// background, and uses truecolor output only when the terminal advertises it.
179+func InheritedTheme(detected Theme) Theme {
180+ base := Dark
181+ if detected.Terminal.SchemeKnown {
182+ if detected.Terminal.Light {
183+ base = Light
184+ }
185+ } else if IsLightTheme(detected) {
186+ base = Light
187+ }
188+ t := withTerminalProfile(base, detected)
189+ t.Inherited = true
190+ t.ColorMode = ColorMode256
191+ if t.Terminal.TrueColor {
192+ t.ColorMode = ColorModeTrueColor
193+ }
194+
195+ if t.Terminal.HasForeground {
196+ t.FG = terminalColorIndex(t.Terminal.Foreground)
197+ }
198+
199+ accentFallback := t.Accent
200+ if IsLightTheme(t) {
201+ accentFallback = Light.Accent
202+ }
203+ t.Accent = terminalPaletteIndex(t.Terminal, accentFallback, 12, 4, 14, 6, 13, 5)
204+ 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)
211+
212+ if t.Terminal.HasForeground && t.Terminal.HasBackground {
213+ t.Muted = terminalColorIndex(t.DimColor(t.FG, 45))
214+ t.ToolOut = t.Muted
215+ background := t.Terminal.Background
216+ foreground := t.Terminal.Foreground
217+ t.UserBubbleBG = blendTerminalColors(background, foreground, 12)
218+ t.SelectionBG = terminalColorIndex(blendTerminalColors(background, foreground, 25))
219+ }
220+
221+ // FG256 receives an index rather than a semantic role. Keep the inherited
222+ // foreground index distinct from accent/status roles so a terminal whose
223+ // default foreground happens to be ANSI blue does not recolor every blue
224+ // accent as the terminal default foreground.
225+ if t.Terminal.HasForeground {
226+ used := map[int]bool{
227+ t.Muted: true, t.Accent: true, t.Assistant: true, t.Tool: true,
228+ t.ToolOut: true, t.Error: true, t.Warning: true, t.Spinner: true,
229+ t.ThinkingMax: true, t.User: true, t.SelectionBG: true,
230+ }
231+ if used[t.FG] {
232+ t.FG = nearestXtermColorExcluding(t.Terminal.Foreground, used)
233+ }
234+ }
235+ t.UserBubbleFG = t.FG
236+ t.SelectionFG = t.FG
237+
238+ // Keep the terminal's configured background untouched. Painting an
239+ // inherited RGB background across every row would make terminal theme
240+ // changes and scrollback behavior less predictable.
241+ t.Background = nil
242+ return t
243+}
244+
245+func withTerminalProfile(base, detected Theme) Theme {
246+ base.Terminal = detected.Terminal
247+ return base
248+}
249+
250+func terminalPaletteIndex(profile TerminalProfile, fallback int, preferred ...int) int {
251+ for _, index := range preferred {
252+ if _, ok := profile.PaletteColor(index); ok {
253+ return index
254+ }
255+ }
256+ return fallback
257+}
258+
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 {
273+ fromRGB, _ := rgbForTerminalColor(from)
274+ toRGB, _ := rgbForTerminalColor(to)
275+ percent = clampPercent(percent)
276+ return ColorRGB(
277+ blendChannel(fromRGB[0], toRGB[0], percent),
278+ blendChannel(fromRGB[1], toRGB[1], percent),
279+ blendChannel(fromRGB[2], toRGB[2], percent),
280+ )
281+}
282+
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+
175304 // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json
176305 // to detected. Empty/auto/default keeps the built-in detected theme.
177306 // If preferred is set, it may be a theme name, a basename without
@@ -187,10 +316,12 @@ func LoadThemeFromHome(zotHome, preferred string, detected Theme) (Theme, string
187316 return detected, "", err
188317 }
189318 switch path {
319+ case "builtin:inherited":
320+ return InheritedTheme(detected), "inherited", nil
190321 case "builtin:dark":
191- return Dark, "dark", nil
322+ return withTerminalProfile(Dark, detected), "dark", nil
192323 case "builtin:light":
193- return Light, "light", nil
324+ return withTerminalProfile(Light, detected), "light", nil
194325 }
195326 b, err := os.ReadFile(path)
196327 if err != nil {
@@ -226,10 +357,11 @@ func LoadThemeFromHome(zotHome, preferred string, detected Theme) (Theme, string
226357 func AvailableThemes(zotHome string) []ThemeOption {
227358 out := []ThemeOption{
228359 {Value: "auto", Label: "auto", Description: "detect terminal background and use zot defaults", Builtin: true},
360+ {Value: "inherited", Label: "inherited (from terminal)", Description: "use terminal colors with truecolor or best-effort 256-color output", Builtin: true},
229361 {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true},
230362 {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},
231363 }
232- seen := map[string]bool{"auto": true, "dark": true, "light": true}
364+ seen := map[string]bool{"auto": true, "inherited": true, "dark": true, "light": true}
233365 paths, _ := themeFilesIn(filepath.Join(zotHome, "themes"))
234366 sort.Strings(paths)
235367 for _, path := range paths {
@@ -299,6 +431,8 @@ func resolveThemePath(zotHome, preferred string) (string, error) {
299431 switch strings.ToLower(preferred) {
300432 case "", "auto", "default", "system":
301433 return "", nil
434+ case "inherited", "terminal", "terminal-inherited":
435+ return "builtin:inherited", nil
302436 case "dark":
303437 return "builtin:dark", nil
304438 case "light":
@@ -471,7 +605,10 @@ func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme {
471605 }
472606
473607 func IsLightTheme(th Theme) bool {
474- return th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG
608+ if th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG {
609+ return true
610+ }
611+ return th.Inherited && th.Terminal.SchemeKnown && th.Terminal.Light
475612 }
476613
477614 func isLightTheme(th Theme) bool { return IsLightTheme(th) }
@@ -172,6 +172,135 @@ func (c *TerminalColorValue) UnmarshalJSON(data []byte) error {
172 return nil172 return nil
173 }173 }
174 174
175+// InheritedTheme returns a theme derived from the terminal snapshot carried
176+// by detected. It prefers the terminal's reported ANSI palette for semantic
177+// accents, derives muted and selected surfaces by fading toward the terminal
178+// background, and uses truecolor output only when the terminal advertises it.
179+func InheritedTheme(detected Theme) Theme {
180+ base := Dark
181+ if detected.Terminal.SchemeKnown {
182+ if detected.Terminal.Light {
183+ base = Light
184+ }
185+ } else if IsLightTheme(detected) {
186+ base = Light
187+ }
188+ t := withTerminalProfile(base, detected)
189+ t.Inherited = true
190+ t.ColorMode = ColorMode256
191+ if t.Terminal.TrueColor {
192+ t.ColorMode = ColorModeTrueColor
193+ }
194+
195+ if t.Terminal.HasForeground {
196+ t.FG = terminalColorIndex(t.Terminal.Foreground)
197+ }
198+
199+ accentFallback := t.Accent
200+ if IsLightTheme(t) {
201+ accentFallback = Light.Accent
202+ }
203+ t.Accent = terminalPaletteIndex(t.Terminal, accentFallback, 12, 4, 14, 6, 13, 5)
204+ 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)
211+
212+ if t.Terminal.HasForeground && t.Terminal.HasBackground {
213+ t.Muted = terminalColorIndex(t.DimColor(t.FG, 45))
214+ t.ToolOut = t.Muted
215+ background := t.Terminal.Background
216+ foreground := t.Terminal.Foreground
217+ t.UserBubbleBG = blendTerminalColors(background, foreground, 12)
218+ t.SelectionBG = terminalColorIndex(blendTerminalColors(background, foreground, 25))
219+ }
220+
221+ // FG256 receives an index rather than a semantic role. Keep the inherited
222+ // foreground index distinct from accent/status roles so a terminal whose
223+ // default foreground happens to be ANSI blue does not recolor every blue
224+ // accent as the terminal default foreground.
225+ if t.Terminal.HasForeground {
226+ used := map[int]bool{
227+ t.Muted: true, t.Accent: true, t.Assistant: true, t.Tool: true,
228+ t.ToolOut: true, t.Error: true, t.Warning: true, t.Spinner: true,
229+ t.ThinkingMax: true, t.User: true, t.SelectionBG: true,
230+ }
231+ if used[t.FG] {
232+ t.FG = nearestXtermColorExcluding(t.Terminal.Foreground, used)
233+ }
234+ }
235+ t.UserBubbleFG = t.FG
236+ t.SelectionFG = t.FG
237+
238+ // Keep the terminal's configured background untouched. Painting an
239+ // inherited RGB background across every row would make terminal theme
240+ // changes and scrollback behavior less predictable.
241+ t.Background = nil
242+ return t
243+}
244+
245+func withTerminalProfile(base, detected Theme) Theme {
246+ base.Terminal = detected.Terminal
247+ return base
248+}
249+
250+func terminalPaletteIndex(profile TerminalProfile, fallback int, preferred ...int) int {
251+ for _, index := range preferred {
252+ if _, ok := profile.PaletteColor(index); ok {
253+ return index
254+ }
255+ }
256+ return fallback
257+}
258+
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 {
273+ fromRGB, _ := rgbForTerminalColor(from)
274+ toRGB, _ := rgbForTerminalColor(to)
275+ percent = clampPercent(percent)
276+ return ColorRGB(
277+ blendChannel(fromRGB[0], toRGB[0], percent),
278+ blendChannel(fromRGB[1], toRGB[1], percent),
279+ blendChannel(fromRGB[2], toRGB[2], percent),
280+ )
281+}
282+
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+
175 // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json304 // LoadThemeFromHome applies a custom theme from $ZOT_HOME/themes/*.json
176 // to detected. Empty/auto/default keeps the built-in detected theme.305 // to detected. Empty/auto/default keeps the built-in detected theme.
177 // If preferred is set, it may be a theme name, a basename without306 // If preferred is set, it may be a theme name, a basename without
@@ -187,10 +316,12 @@ func LoadThemeFromHome(zotHome, preferred string, detected Theme) (Theme, string
187 return detected, "", err316 return detected, "", err
188 }317 }
189 switch path {318 switch path {
319+ case "builtin:inherited":
320+ return InheritedTheme(detected), "inherited", nil
190 case "builtin:dark":321 case "builtin:dark":
191- return Dark, "dark", nil322+ return withTerminalProfile(Dark, detected), "dark", nil
192 case "builtin:light":323 case "builtin:light":
193- return Light, "light", nil324+ return withTerminalProfile(Light, detected), "light", nil
194 }325 }
195 b, err := os.ReadFile(path)326 b, err := os.ReadFile(path)
196 if err != nil {327 if err != nil {
@@ -226,10 +357,11 @@ func LoadThemeFromHome(zotHome, preferred string, detected Theme) (Theme, string
226 func AvailableThemes(zotHome string) []ThemeOption {357 func AvailableThemes(zotHome string) []ThemeOption {
227 out := []ThemeOption{358 out := []ThemeOption{
228 {Value: "auto", Label: "auto", Description: "detect terminal background and use zot defaults", Builtin: true},359 {Value: "auto", Label: "auto", Description: "detect terminal background and use zot defaults", Builtin: true},
360+ {Value: "inherited", Label: "inherited (from terminal)", Description: "use terminal colors with truecolor or best-effort 256-color output", Builtin: true},
229 {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true},361 {Value: "dark", Label: "dark", Description: "built-in dark theme", Builtin: true},
230 {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},362 {Value: "light", Label: "light", Description: "built-in light theme", Builtin: true},
231 }363 }
232- seen := map[string]bool{"auto": true, "dark": true, "light": true}364+ seen := map[string]bool{"auto": true, "inherited": true, "dark": true, "light": true}
233 paths, _ := themeFilesIn(filepath.Join(zotHome, "themes"))365 paths, _ := themeFilesIn(filepath.Join(zotHome, "themes"))
234 sort.Strings(paths)366 sort.Strings(paths)
235 for _, path := range paths {367 for _, path := range paths {
@@ -299,6 +431,8 @@ func resolveThemePath(zotHome, preferred string) (string, error) {
299 switch strings.ToLower(preferred) {431 switch strings.ToLower(preferred) {
300 case "", "auto", "default", "system":432 case "", "auto", "default", "system":
301 return "", nil433 return "", nil
434+ case "inherited", "terminal", "terminal-inherited":
435+ return "builtin:inherited", nil
302 case "dark":436 case "dark":
303 return "builtin:dark", nil437 return "builtin:dark", nil
304 case "light":438 case "light":
@@ -471,7 +605,10 @@ func applySyntaxOverrides(s SyntaxTheme, o SyntaxThemeOverrides) SyntaxTheme {
471 }605 }
472 606
473 func IsLightTheme(th Theme) bool {607 func IsLightTheme(th Theme) bool {
474- return th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG608+ if th.FG == Light.FG && th.SelectionBG == Light.SelectionBG && th.SelectionFG == Light.SelectionFG {
609+ return true
610+ }
611+ return th.Inherited && th.Terminal.SchemeKnown && th.Terminal.Light
475 }612 }
476 613
477 func isLightTheme(th Theme) bool { return IsLightTheme(th) }614 func isLightTheme(th Theme) bool { return IsLightTheme(th) }
M packages/tui/view.go +4 -4
@@ -1196,7 +1196,7 @@ func compactToolBlank(th Theme, width int) string {
11961196 if width < 1 {
11971197 width = 1
11981198 }
1199- return sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset
1199+ return th.sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset
12001200 }
12011201
12021202 func compactToolHeader(th Theme, label string, width int) string {
@@ -1222,8 +1222,8 @@ func compactToolHeader(th Theme, label string, width int) string {
12221222 if visible < width {
12231223 fill = strings.Repeat(" ", width-visible)
12241224 }
1225- bg := sgrBGColor(th.UserBubbleBG)
1226- return bg + pad + sgrFG(th.FG) + name + bg + sgrFG(th.Muted) + rest + bg + fill + reset
1225+ bg := th.sgrBGColor(th.UserBubbleBG)
1226+ return bg + pad + th.fgPrefix(th.FG) + name + bg + th.fgPrefix(th.Muted) + rest + bg + fill + reset
12271227 }
12281228
12291229 func toolBodyLine(th Theme, line string, width int, compact bool) string {
@@ -1250,7 +1250,7 @@ func compactToolBody(th Theme, line string, width int) string {
12501250 if visible < width {
12511251 fill = strings.Repeat(" ", width-visible)
12521252 }
1253- bg := sgrBGColor(th.UserBubbleBG)
1253+ bg := th.sgrBGColor(th.UserBubbleBG)
12541254 return bg + strings.ReplaceAll(line, reset, reset+bg) + bg + fill + reset
12551255 }
12561256
@@ -1196,7 +1196,7 @@ func compactToolBlank(th Theme, width int) string {
1196 if width < 1 {1196 if width < 1 {
1197 width = 11197 width = 1
1198 }1198 }
1199- return sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset1199+ return th.sgrBGColor(th.UserBubbleBG) + strings.Repeat(" ", width) + reset
1200 }1200 }
1201 1201
1202 func compactToolHeader(th Theme, label string, width int) string {1202 func compactToolHeader(th Theme, label string, width int) string {
@@ -1222,8 +1222,8 @@ func compactToolHeader(th Theme, label string, width int) string {
1222 if visible < width {1222 if visible < width {
1223 fill = strings.Repeat(" ", width-visible)1223 fill = strings.Repeat(" ", width-visible)
1224 }1224 }
1225- bg := sgrBGColor(th.UserBubbleBG)1225+ bg := th.sgrBGColor(th.UserBubbleBG)
1226- return bg + pad + sgrFG(th.FG) + name + bg + sgrFG(th.Muted) + rest + bg + fill + reset1226+ return bg + pad + th.fgPrefix(th.FG) + name + bg + th.fgPrefix(th.Muted) + rest + bg + fill + reset
1227 }1227 }
1228 1228
1229 func toolBodyLine(th Theme, line string, width int, compact bool) string {1229 func toolBodyLine(th Theme, line string, width int, compact bool) string {
@@ -1250,7 +1250,7 @@ func compactToolBody(th Theme, line string, width int) string {
1250 if visible < width {1250 if visible < width {
1251 fill = strings.Repeat(" ", width-visible)1251 fill = strings.Repeat(" ", width-visible)
1252 }1252 }
1253- bg := sgrBGColor(th.UserBubbleBG)1253+ bg := th.sgrBGColor(th.UserBubbleBG)
1254 return bg + strings.ReplaceAll(line, reset, reset+bg) + bg + fill + reset1254 return bg + strings.ReplaceAll(line, reset, reset+bg) + bg + fill + reset
1255 }1255 }
1256 1256

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