detached da842a4

feat(config): add terminal.colors and VEV_COLORS overrides

Resolve an explicit color mode client-side (VEV_COLORS over terminal.colors,
auto means detection) and send it as a Forced Hello.Color; the picker gets the
same value. Web and headless UI-driver clients now declare forced truecolor
instead of inheriting the gateway process TERM.
commit
da842a4a232a1c91a55ba20cb410249927e6785d
author
brice · · 2026-10-03 12:38 UTC
committer
brice · · 2026-10-03 14:14 UTC
parent
9a421ca
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +217 -9
M internal/adapters/config/config.go +10 -0
@@ -13,6 +13,7 @@ import (
1313 "time"
1414
1515 "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/domain/terminalcap"
1617 "github.com/bnema/vev/internal/ports"
1718 )
1819
@@ -37,6 +38,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3738 seenTabsKeys := make(map[string]bool)
3839 seenEphemeralKeys := make(map[string]bool)
3940 seenKeyboardKeys := make(map[string]bool)
41+ seenTerminalKeys := make(map[string]bool)
4042 seenScrollbackKeys := make(map[string]bool)
4143 seenWebKeys := make(map[string]bool)
4244
@@ -171,6 +173,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
171173 continue
172174 }
173175 cfg.Keyboard.KittyProtocol = on
176+ case key == "terminal.colors":
177+ warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo)
178+ mode, auto, err := terminalcap.ParseColorMode(value)
179+ if err != nil {
180+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid terminal.colors %q (want auto, truecolor, 256, 16, or mono)", value)})
181+ continue
182+ }
183+ cfg.Terminal = domain.TerminalConfig{Colors: mode, ColorsSet: !auto}
174184 case key == "copy.word-separators":
175185 warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
176186 separators, ok := parseConfigString(value)
@@ -13,6 +13,7 @@ import (
13 "time"13 "time"
14 14
15 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/domain/terminalcap"
16 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
17 )18 )
18 19
@@ -37,6 +38,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
37 seenTabsKeys := make(map[string]bool)38 seenTabsKeys := make(map[string]bool)
38 seenEphemeralKeys := make(map[string]bool)39 seenEphemeralKeys := make(map[string]bool)
39 seenKeyboardKeys := make(map[string]bool)40 seenKeyboardKeys := make(map[string]bool)
41+ seenTerminalKeys := make(map[string]bool)
40 seenScrollbackKeys := make(map[string]bool)42 seenScrollbackKeys := make(map[string]bool)
41 seenWebKeys := make(map[string]bool)43 seenWebKeys := make(map[string]bool)
42 44
@@ -171,6 +173,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
171 continue173 continue
172 }174 }
173 cfg.Keyboard.KittyProtocol = on175 cfg.Keyboard.KittyProtocol = on
176+ case key == "terminal.colors":
177+ warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo)
178+ mode, auto, err := terminalcap.ParseColorMode(value)
179+ if err != nil {
180+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid terminal.colors %q (want auto, truecolor, 256, 16, or mono)", value)})
181+ continue
182+ }
183+ cfg.Terminal = domain.TerminalConfig{Colors: mode, ColorsSet: !auto}
174 case key == "copy.word-separators":184 case key == "copy.word-separators":
175 warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)185 warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
176 separators, ok := parseConfigString(value)186 separators, ok := parseConfigString(value)
A internal/adapters/config/terminal_colors_test.go +42 -0
@@ -0,0 +1,42 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/terminalcap"
11+)
12+
13+func TestParseTerminalColors(t *testing.T) {
14+ for _, tt := range []struct {
15+ name string
16+ input string
17+ want domain.TerminalConfig
18+ warnings int
19+ }{
20+ {"default is auto", "", domain.TerminalConfig{}, 0},
21+ {"auto", "terminal.colors = auto", domain.TerminalConfig{}, 0},
22+ {"truecolor", "terminal.colors = truecolor", domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, 0},
23+ {"256", "terminal.colors = 256", domain.TerminalConfig{Colors: terminalcap.ANSI256, ColorsSet: true}, 0},
24+ {"16", "terminal.colors = 16", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 0},
25+ {"mono", "terminal.colors = mono", domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, 0},
26+ {"case-insensitive", "terminal.colors = TrueColor", domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, 0},
27+ {"empty value is auto", "terminal.colors =", domain.TerminalConfig{}, 0},
28+ {"inline comment", "terminal.colors = 16 # linux console", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 0},
29+ {"invalid keeps default", "terminal.colors = rainbow", domain.TerminalConfig{}, 1},
30+ {"invalid keeps earlier valid", "terminal.colors = 16\nterminal.colors = rainbow", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 2},
31+ {"duplicate last wins with warning", "terminal.colors = 16\nterminal.colors = mono", domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, 1},
32+ {"later auto clears earlier override", "terminal.colors = 16\nterminal.colors = auto", domain.TerminalConfig{}, 1},
33+ } {
34+ t.Run(tt.name, func(t *testing.T) {
35+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
36+ require.NoError(t, err)
37+ require.Equal(t, tt.want, cfg.Terminal)
38+ require.Len(t, warnings, tt.warnings)
39+ require.Empty(t, cfg.BindingEntries)
40+ })
41+ }
42+}
@@ -0,0 +1,42 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/terminalcap"
11+)
12+
13+func TestParseTerminalColors(t *testing.T) {
14+ for _, tt := range []struct {
15+ name string
16+ input string
17+ want domain.TerminalConfig
18+ warnings int
19+ }{
20+ {"default is auto", "", domain.TerminalConfig{}, 0},
21+ {"auto", "terminal.colors = auto", domain.TerminalConfig{}, 0},
22+ {"truecolor", "terminal.colors = truecolor", domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, 0},
23+ {"256", "terminal.colors = 256", domain.TerminalConfig{Colors: terminalcap.ANSI256, ColorsSet: true}, 0},
24+ {"16", "terminal.colors = 16", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 0},
25+ {"mono", "terminal.colors = mono", domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, 0},
26+ {"case-insensitive", "terminal.colors = TrueColor", domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, 0},
27+ {"empty value is auto", "terminal.colors =", domain.TerminalConfig{}, 0},
28+ {"inline comment", "terminal.colors = 16 # linux console", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 0},
29+ {"invalid keeps default", "terminal.colors = rainbow", domain.TerminalConfig{}, 1},
30+ {"invalid keeps earlier valid", "terminal.colors = 16\nterminal.colors = rainbow", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 2},
31+ {"duplicate last wins with warning", "terminal.colors = 16\nterminal.colors = mono", domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, 1},
32+ {"later auto clears earlier override", "terminal.colors = 16\nterminal.colors = auto", domain.TerminalConfig{}, 1},
33+ } {
34+ t.Run(tt.name, func(t *testing.T) {
35+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
36+ require.NoError(t, err)
37+ require.Equal(t, tt.want, cfg.Terminal)
38+ require.Len(t, warnings, tt.warnings)
39+ require.Empty(t, cfg.BindingEntries)
40+ })
41+ }
42+}
M internal/app/broker_client.go +68 -5
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "log/slog"
88 "os"
9+ "strings"
910 "sync"
1011 "time"
1112
@@ -628,13 +629,75 @@ func detectTerminalColor(termEnv, colorTerm string, env []string) terminalcap.Co
628629 return terminalcap.Detect(detectionEnv).Color
629630 }
630631
631-// outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for
632-// a client that owns a real outer terminal: it probes that terminal once and
633-// applies the user's keyboard setting. Virtual terminals never probe.
634-func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
632+// envColors is the client-side color-mode override. It wins over the
633+// terminal.colors config key and is read by each client process; the daemon
634+// never sees it except as the Forced Hello.Color it produces.
635+const envColors = "VEV_COLORS"
636+
637+// virtualTerminalColor is the color capability declared by clients whose
638+// terminal is an in-process RGB-capable VT mirror (the browser terminal and the
639+// headless UI driver) rather than the gateway process' controlling terminal.
640+// It is forced so the gateway's own TERM/COLORTERM never downgrades them.
641+var virtualTerminalColor = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced}
642+
643+// virtualTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a
644+// client that renders into an RGB-capable virtual terminal: it declares forced
645+// truecolor instead of detecting from the gateway process environment.
646+func virtualTerminalAttachmentEnvironment() client.AttachmentEnvironment {
647+ env := terminalAttachmentEnvironment()
648+ env.Color = virtualTerminalColor
649+ return env
650+}
651+
652+// resolveColorOverride applies the explicit color override on top of the
653+// detected capability: a valid VEV_COLORS wins over terminal.colors, and
654+// "auto" at either level means detection. An empty VEV_COLORS is unset, so the
655+// config value still applies. An invalid VEV_COLORS is reported through log
656+// and ignored. A forced result carries SourceForced.
657+func resolveColorOverride(detected terminalcap.ColorCapabilities, envValue string, cfg domain.TerminalConfig, log *slog.Logger) terminalcap.ColorCapabilities {
658+ if strings.TrimSpace(envValue) != "" {
659+ mode, auto, err := terminalcap.ParseColorMode(envValue)
660+ switch {
661+ case err != nil:
662+ if log != nil {
663+ log.Warn("ignoring invalid "+envColors, "value", envValue, "err", err)
664+ }
665+ case auto:
666+ return detected
667+ default:
668+ return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
669+ }
670+ }
671+ if cfg.ColorsSet {
672+ return terminalcap.ColorCapabilities{Mode: cfg.Colors, Source: terminalcap.SourceForced}
673+ }
674+ return detected
675+}
676+
677+// userTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a
678+// client whose terminal is the user's real one: the detected color capability
679+// yields to VEV_COLORS and terminal.colors. The single resolved value feeds
680+// both the picker and every Hello.
681+func userTerminalAttachmentEnvironment(log *slog.Logger, cfg domain.TerminalConfig) client.AttachmentEnvironment {
635682 env := terminalAttachmentEnvironment()
683+ env.Color = resolveColorOverride(env.Color, os.Getenv(envColors), cfg, log)
684+ return env
685+}
686+
687+// configuredTerminalAttachmentEnvironment loads the user's config and returns
688+// userTerminalAttachmentEnvironment for it.
689+func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
690+ return userTerminalAttachmentEnvironment(log, loadConfigOrDefaults(log, platform.ConfigPath()).Terminal)
691+}
692+
693+// outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment
694+// for a client that owns a real outer terminal: it probes that terminal once
695+// and applies the user's keyboard setting. Virtual terminals never probe.
696+func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
697+ cfg := loadConfigOrDefaults(log, platform.ConfigPath())
698+ env := userTerminalAttachmentEnvironment(log, cfg.Terminal)
636699 env.ProbeTerminal = true
637- env.KittyKeyboard = loadConfigOrDefaults(log, platform.ConfigPath()).Keyboard.KittyProtocol
700+ env.KittyKeyboard = cfg.Keyboard.KittyProtocol
638701 return env
639702 }
640703
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "log/slog"7 "log/slog"
8 "os"8 "os"
9+ "strings"
9 "sync"10 "sync"
10 "time"11 "time"
11 12
@@ -628,13 +629,75 @@ func detectTerminalColor(termEnv, colorTerm string, env []string) terminalcap.Co
628 return terminalcap.Detect(detectionEnv).Color629 return terminalcap.Detect(detectionEnv).Color
629 }630 }
630 631
631-// outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for632+// envColors is the client-side color-mode override. It wins over the
632-// a client that owns a real outer terminal: it probes that terminal once and633+// terminal.colors config key and is read by each client process; the daemon
633-// applies the user's keyboard setting. Virtual terminals never probe.634+// never sees it except as the Forced Hello.Color it produces.
634-func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {635+const envColors = "VEV_COLORS"
636+
637+// virtualTerminalColor is the color capability declared by clients whose
638+// terminal is an in-process RGB-capable VT mirror (the browser terminal and the
639+// headless UI driver) rather than the gateway process' controlling terminal.
640+// It is forced so the gateway's own TERM/COLORTERM never downgrades them.
641+var virtualTerminalColor = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced}
642+
643+// virtualTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a
644+// client that renders into an RGB-capable virtual terminal: it declares forced
645+// truecolor instead of detecting from the gateway process environment.
646+func virtualTerminalAttachmentEnvironment() client.AttachmentEnvironment {
647+ env := terminalAttachmentEnvironment()
648+ env.Color = virtualTerminalColor
649+ return env
650+}
651+
652+// resolveColorOverride applies the explicit color override on top of the
653+// detected capability: a valid VEV_COLORS wins over terminal.colors, and
654+// "auto" at either level means detection. An empty VEV_COLORS is unset, so the
655+// config value still applies. An invalid VEV_COLORS is reported through log
656+// and ignored. A forced result carries SourceForced.
657+func resolveColorOverride(detected terminalcap.ColorCapabilities, envValue string, cfg domain.TerminalConfig, log *slog.Logger) terminalcap.ColorCapabilities {
658+ if strings.TrimSpace(envValue) != "" {
659+ mode, auto, err := terminalcap.ParseColorMode(envValue)
660+ switch {
661+ case err != nil:
662+ if log != nil {
663+ log.Warn("ignoring invalid "+envColors, "value", envValue, "err", err)
664+ }
665+ case auto:
666+ return detected
667+ default:
668+ return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
669+ }
670+ }
671+ if cfg.ColorsSet {
672+ return terminalcap.ColorCapabilities{Mode: cfg.Colors, Source: terminalcap.SourceForced}
673+ }
674+ return detected
675+}
676+
677+// userTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a
678+// client whose terminal is the user's real one: the detected color capability
679+// yields to VEV_COLORS and terminal.colors. The single resolved value feeds
680+// both the picker and every Hello.
681+func userTerminalAttachmentEnvironment(log *slog.Logger, cfg domain.TerminalConfig) client.AttachmentEnvironment {
635 env := terminalAttachmentEnvironment()682 env := terminalAttachmentEnvironment()
683+ env.Color = resolveColorOverride(env.Color, os.Getenv(envColors), cfg, log)
684+ return env
685+}
686+
687+// configuredTerminalAttachmentEnvironment loads the user's config and returns
688+// userTerminalAttachmentEnvironment for it.
689+func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
690+ return userTerminalAttachmentEnvironment(log, loadConfigOrDefaults(log, platform.ConfigPath()).Terminal)
691+}
692+
693+// outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment
694+// for a client that owns a real outer terminal: it probes that terminal once
695+// and applies the user's keyboard setting. Virtual terminals never probe.
696+func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
697+ cfg := loadConfigOrDefaults(log, platform.ConfigPath())
698+ env := userTerminalAttachmentEnvironment(log, cfg.Terminal)
636 env.ProbeTerminal = true699 env.ProbeTerminal = true
637- env.KittyKeyboard = loadConfigOrDefaults(log, platform.ConfigPath()).Keyboard.KittyProtocol700+ env.KittyKeyboard = cfg.Keyboard.KittyProtocol
638 return env701 return env
639 }702 }
640 703
M internal/app/broker_client_test.go +78 -0
@@ -3,9 +3,11 @@
33 package app
44
55 import (
6+ "bytes"
67 "context"
78 "errors"
89 "io"
10+ "log/slog"
911 "os"
1012 "slices"
1113 "sync"
@@ -1183,3 +1185,79 @@ func TestDetectTerminalColor(t *testing.T) {
11831185 })
11841186 }
11851187 }
1188+
1189+func TestResolveColorOverride(t *testing.T) {
1190+ detected := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}
1191+ forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities {
1192+ return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
1193+ }
1194+ tests := []struct {
1195+ name string
1196+ env string
1197+ cfg domain.TerminalConfig
1198+ want terminalcap.ColorCapabilities
1199+ wantWarn bool
1200+ }{
1201+ {name: "nothing set uses detection", want: detected},
1202+ {name: "config auto uses detection", cfg: domain.TerminalConfig{}, want: detected},
1203+ {name: "config forces mode", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)},
1204+ {name: "config forces truecolor", cfg: domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, want: forced(terminalcap.TrueColor)},
1205+ {name: "env forces mode", env: "mono", want: forced(terminalcap.Monochrome)},
1206+ {name: "env is case-insensitive and trimmed", env: " TrueColor ", want: forced(terminalcap.TrueColor)},
1207+ {name: "env wins over config", env: "256", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: forced(terminalcap.ANSI256)},
1208+ {name: "env auto overrides config with detection", env: "auto", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: detected},
1209+ {name: "blank env falls through to config", env: " ", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)},
1210+ {name: "invalid env falls back to config with a warning", env: "rainbow", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16), wantWarn: true},
1211+ {name: "invalid env falls back to detection with a warning", env: "24bit", want: detected, wantWarn: true},
1212+ }
1213+ for _, tt := range tests {
1214+ t.Run(tt.name, func(t *testing.T) {
1215+ var logs bytes.Buffer
1216+ log := slog.New(slog.NewTextHandler(&logs, nil))
1217+ require.Equal(t, tt.want, resolveColorOverride(detected, tt.env, tt.cfg, log))
1218+ if tt.wantWarn {
1219+ require.Contains(t, logs.String(), "VEV_COLORS")
1220+ } else {
1221+ require.Empty(t, logs.String())
1222+ }
1223+ })
1224+ }
1225+ t.Run("nil logger tolerates invalid env", func(t *testing.T) {
1226+ require.Equal(t, detected, resolveColorOverride(detected, "rainbow", domain.TerminalConfig{}, nil))
1227+ })
1228+}
1229+
1230+func TestTerminalAttachmentEnvironmentColor(t *testing.T) {
1231+ forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities {
1232+ return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
1233+ }
1234+ tests := []struct {
1235+ name string
1236+ term string
1237+ colors string
1238+ cfg domain.TerminalConfig
1239+ wantUser terminalcap.ColorCapabilities
1240+ }{
1241+ {name: "dumb process terminal", term: "dumb", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}},
1242+ {name: "linux console", term: "linux", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}},
1243+ {name: "env overrides process terminal", term: "dumb", colors: "truecolor", wantUser: forced(terminalcap.TrueColor)},
1244+ {name: "config overrides process terminal", term: "xterm-256color", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, wantUser: forced(terminalcap.ANSI16)},
1245+ }
1246+ for _, tt := range tests {
1247+ t.Run(tt.name, func(t *testing.T) {
1248+ t.Setenv("TERM", tt.term)
1249+ t.Setenv("COLORTERM", "")
1250+ t.Setenv("KITTY_WINDOW_ID", "")
1251+ t.Setenv("KITTY_PID", "")
1252+ t.Setenv("KITTY_LISTEN_ON", "")
1253+ t.Setenv(envColors, tt.colors)
1254+
1255+ require.Equal(t, tt.wantUser, userTerminalAttachmentEnvironment(nil, tt.cfg).Color)
1256+ // Web and headless UI-driver clients render into RGB VT mirrors and
1257+ // ignore both the gateway process terminal and the user override.
1258+ virtual := virtualTerminalAttachmentEnvironment()
1259+ require.Equal(t, forced(terminalcap.TrueColor), virtual.Color)
1260+ require.False(t, virtual.ProbeTerminal)
1261+ })
1262+ }
1263+}
@@ -3,9 +3,11 @@
3 package app3 package app
4 4
5 import (5 import (
6+ "bytes"
6 "context"7 "context"
7 "errors"8 "errors"
8 "io"9 "io"
10+ "log/slog"
9 "os"11 "os"
10 "slices"12 "slices"
11 "sync"13 "sync"
@@ -1183,3 +1185,79 @@ func TestDetectTerminalColor(t *testing.T) {
1183 })1185 })
1184 }1186 }
1185 }1187 }
1188+
1189+func TestResolveColorOverride(t *testing.T) {
1190+ detected := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}
1191+ forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities {
1192+ return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
1193+ }
1194+ tests := []struct {
1195+ name string
1196+ env string
1197+ cfg domain.TerminalConfig
1198+ want terminalcap.ColorCapabilities
1199+ wantWarn bool
1200+ }{
1201+ {name: "nothing set uses detection", want: detected},
1202+ {name: "config auto uses detection", cfg: domain.TerminalConfig{}, want: detected},
1203+ {name: "config forces mode", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)},
1204+ {name: "config forces truecolor", cfg: domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, want: forced(terminalcap.TrueColor)},
1205+ {name: "env forces mode", env: "mono", want: forced(terminalcap.Monochrome)},
1206+ {name: "env is case-insensitive and trimmed", env: " TrueColor ", want: forced(terminalcap.TrueColor)},
1207+ {name: "env wins over config", env: "256", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: forced(terminalcap.ANSI256)},
1208+ {name: "env auto overrides config with detection", env: "auto", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: detected},
1209+ {name: "blank env falls through to config", env: " ", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)},
1210+ {name: "invalid env falls back to config with a warning", env: "rainbow", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16), wantWarn: true},
1211+ {name: "invalid env falls back to detection with a warning", env: "24bit", want: detected, wantWarn: true},
1212+ }
1213+ for _, tt := range tests {
1214+ t.Run(tt.name, func(t *testing.T) {
1215+ var logs bytes.Buffer
1216+ log := slog.New(slog.NewTextHandler(&logs, nil))
1217+ require.Equal(t, tt.want, resolveColorOverride(detected, tt.env, tt.cfg, log))
1218+ if tt.wantWarn {
1219+ require.Contains(t, logs.String(), "VEV_COLORS")
1220+ } else {
1221+ require.Empty(t, logs.String())
1222+ }
1223+ })
1224+ }
1225+ t.Run("nil logger tolerates invalid env", func(t *testing.T) {
1226+ require.Equal(t, detected, resolveColorOverride(detected, "rainbow", domain.TerminalConfig{}, nil))
1227+ })
1228+}
1229+
1230+func TestTerminalAttachmentEnvironmentColor(t *testing.T) {
1231+ forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities {
1232+ return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
1233+ }
1234+ tests := []struct {
1235+ name string
1236+ term string
1237+ colors string
1238+ cfg domain.TerminalConfig
1239+ wantUser terminalcap.ColorCapabilities
1240+ }{
1241+ {name: "dumb process terminal", term: "dumb", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}},
1242+ {name: "linux console", term: "linux", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}},
1243+ {name: "env overrides process terminal", term: "dumb", colors: "truecolor", wantUser: forced(terminalcap.TrueColor)},
1244+ {name: "config overrides process terminal", term: "xterm-256color", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, wantUser: forced(terminalcap.ANSI16)},
1245+ }
1246+ for _, tt := range tests {
1247+ t.Run(tt.name, func(t *testing.T) {
1248+ t.Setenv("TERM", tt.term)
1249+ t.Setenv("COLORTERM", "")
1250+ t.Setenv("KITTY_WINDOW_ID", "")
1251+ t.Setenv("KITTY_PID", "")
1252+ t.Setenv("KITTY_LISTEN_ON", "")
1253+ t.Setenv(envColors, tt.colors)
1254+
1255+ require.Equal(t, tt.wantUser, userTerminalAttachmentEnvironment(nil, tt.cfg).Color)
1256+ // Web and headless UI-driver clients render into RGB VT mirrors and
1257+ // ignore both the gateway process terminal and the user override.
1258+ virtual := virtualTerminalAttachmentEnvironment()
1259+ require.Equal(t, forced(terminalcap.TrueColor), virtual.Color)
1260+ require.False(t, virtual.ProbeTerminal)
1261+ })
1262+ }
1263+}
M internal/app/ui_driver.go +2 -2
@@ -240,7 +240,7 @@ func runHeadlessUIDriver(ctx context.Context, options uiDriverOptions) error {
240240 defer terminal.Close()
241241 ui := client.NewUI(terminal, clk)
242242 sessionEnv := terminalSessionEnvironment()
243- attachmentEnv := terminalAttachmentEnvironment()
243+ attachmentEnv := virtualTerminalAttachmentEnvironment()
244244 attachmentEnv.Cwd = sessionEnv.Cwd
245245 return runUIDriverClient(ctx, brokerClientConfig{
246246 Logger: log,
@@ -495,7 +495,7 @@ func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget
495495 return err
496496 }
497497 sessionEnv := terminalSessionEnvironment()
498- attachmentEnv := terminalAttachmentEnvironment()
498+ attachmentEnv := configuredTerminalAttachmentEnvironment(log)
499499 attachmentEnv.Cwd = sessionEnv.Cwd
500500 return runBrokerClient(ctx, brokerClientConfig{
501501 Logger: log,
@@ -240,7 +240,7 @@ func runHeadlessUIDriver(ctx context.Context, options uiDriverOptions) error {
240 defer terminal.Close()240 defer terminal.Close()
241 ui := client.NewUI(terminal, clk)241 ui := client.NewUI(terminal, clk)
242 sessionEnv := terminalSessionEnvironment()242 sessionEnv := terminalSessionEnvironment()
243- attachmentEnv := terminalAttachmentEnvironment()243+ attachmentEnv := virtualTerminalAttachmentEnvironment()
244 attachmentEnv.Cwd = sessionEnv.Cwd244 attachmentEnv.Cwd = sessionEnv.Cwd
245 return runUIDriverClient(ctx, brokerClientConfig{245 return runUIDriverClient(ctx, brokerClientConfig{
246 Logger: log,246 Logger: log,
@@ -495,7 +495,7 @@ func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget
495 return err495 return err
496 }496 }
497 sessionEnv := terminalSessionEnvironment()497 sessionEnv := terminalSessionEnvironment()
498- attachmentEnv := terminalAttachmentEnvironment()498+ attachmentEnv := configuredTerminalAttachmentEnvironment(log)
499 attachmentEnv.Cwd = sessionEnv.Cwd499 attachmentEnv.Cwd = sessionEnv.Cwd
500 return runBrokerClient(ctx, brokerClientConfig{500 return runBrokerClient(ctx, brokerClientConfig{
501 Logger: log,501 Logger: log,
M internal/app/web_daemon.go +1 -1
@@ -194,7 +194,7 @@ func (webTerminal) PublishContext(ports.UIContext) error { return ports.ErrUIUna
194194 func runWebTerminalClient(ctx context.Context, terminal *webterm.Terminal) error {
195195 callbacks := terminalBrokerCallbacks()
196196 sessionEnv := terminalSessionEnvironment()
197- attachmentEnv := terminalAttachmentEnvironment()
197+ attachmentEnv := virtualTerminalAttachmentEnvironment()
198198 attachmentEnv.Cwd = sessionEnv.Cwd
199199 return runBrokerClient(ctx, brokerClientConfig{
200200 Connector: webTerminalConnector(),
@@ -194,7 +194,7 @@ func (webTerminal) PublishContext(ports.UIContext) error { return ports.ErrUIUna
194 func runWebTerminalClient(ctx context.Context, terminal *webterm.Terminal) error {194 func runWebTerminalClient(ctx context.Context, terminal *webterm.Terminal) error {
195 callbacks := terminalBrokerCallbacks()195 callbacks := terminalBrokerCallbacks()
196 sessionEnv := terminalSessionEnvironment()196 sessionEnv := terminalSessionEnvironment()
197- attachmentEnv := terminalAttachmentEnvironment()197+ attachmentEnv := virtualTerminalAttachmentEnvironment()
198 attachmentEnv.Cwd = sessionEnv.Cwd198 attachmentEnv.Cwd = sessionEnv.Cwd
199 return runBrokerClient(ctx, brokerClientConfig{199 return runBrokerClient(ctx, brokerClientConfig{
200 Connector: webTerminalConnector(),200 Connector: webTerminalConnector(),
M internal/domain/config.go +16 -1
@@ -1,6 +1,10 @@
11 package domain
22
3-import "time"
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/domain/terminalcap"
7+)
48
59 // MinBarInterval is the minimum supported bar refresh interval.
610 const MinBarInterval = time.Second
@@ -98,6 +102,16 @@ type KeyboardConfig struct {
98102 KittyProtocol bool
99103 }
100104
105+// TerminalConfig contains client-side outer-terminal settings. It is read by
106+// each client at startup and never applied by the daemon, so a change takes
107+// effect on the next client process.
108+type TerminalConfig struct {
109+ // Colors is the forced color mode when ColorsSet is true. A false
110+ // ColorsSet (terminal.colors = auto, the default) uses detection.
111+ Colors terminalcap.ColorMode
112+ ColorsSet bool
113+}
114+
101115 // ScrollbackConfig is vev's per-pane retention policy. The terminal library
102116 // only enforces these application-selected limits. Zero megabytes disables
103117 // history; zero lines removes the additional line ceiling.
@@ -139,6 +153,7 @@ type Config struct {
139153 Tabs TabsConfig
140154 Ephemeral EphemeralConfig
141155 Keyboard KeyboardConfig
156+ Terminal TerminalConfig
142157 Scrollback ScrollbackConfig
143158 }
144159
@@ -1,6 +1,10 @@
1 package domain1 package domain
2 2
3-import "time"3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/domain/terminalcap"
7+)
4 8
5 // MinBarInterval is the minimum supported bar refresh interval.9 // MinBarInterval is the minimum supported bar refresh interval.
6 const MinBarInterval = time.Second10 const MinBarInterval = time.Second
@@ -98,6 +102,16 @@ type KeyboardConfig struct {
98 KittyProtocol bool102 KittyProtocol bool
99 }103 }
100 104
105+// TerminalConfig contains client-side outer-terminal settings. It is read by
106+// each client at startup and never applied by the daemon, so a change takes
107+// effect on the next client process.
108+type TerminalConfig struct {
109+ // Colors is the forced color mode when ColorsSet is true. A false
110+ // ColorsSet (terminal.colors = auto, the default) uses detection.
111+ Colors terminalcap.ColorMode
112+ ColorsSet bool
113+}
114+
101 // ScrollbackConfig is vev's per-pane retention policy. The terminal library115 // ScrollbackConfig is vev's per-pane retention policy. The terminal library
102 // only enforces these application-selected limits. Zero megabytes disables116 // only enforces these application-selected limits. Zero megabytes disables
103 // history; zero lines removes the additional line ceiling.117 // history; zero lines removes the additional line ceiling.
@@ -139,6 +153,7 @@ type Config struct {
139 Tabs TabsConfig153 Tabs TabsConfig
140 Ephemeral EphemeralConfig154 Ephemeral EphemeralConfig
141 Keyboard KeyboardConfig155 Keyboard KeyboardConfig
156+ Terminal TerminalConfig
142 Scrollback ScrollbackConfig157 Scrollback ScrollbackConfig
143 }158 }
144 159

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 12

Tags 11

↑↓ moveenter openesc close