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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +217 -9
M internal/adapters/config/config.go +10 -0
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/vev/internal/domain" | |
| 16 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 16 | 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 | 38 | seenTabsKeys := make(map[string]bool) | |
| 38 | 39 | seenEphemeralKeys := make(map[string]bool) | |
| 39 | 40 | seenKeyboardKeys := make(map[string]bool) | |
| 41 | + | seenTerminalKeys := make(map[string]bool) | |
| 40 | 42 | seenScrollbackKeys := make(map[string]bool) | |
| 41 | 43 | seenWebKeys := make(map[string]bool) | |
| 42 | 44 | ||
| @@ -171,6 +173,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 171 | 173 | continue | |
| 172 | 174 | } | |
| 173 | 175 | 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 | 184 | case key == "copy.word-separators": | |
| 175 | 185 | warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo) | |
| 176 | 186 | 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 | continue | 173 | continue | ||
| 172 | } | 174 | } | ||
| 173 | cfg.Keyboard.KittyProtocol = on | 175 | 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 ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "log/slog" | |
| 8 | 8 | "os" | |
| 9 | + | "strings" | |
| 9 | 10 | "sync" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| @@ -628,13 +629,75 @@ func detectTerminalColor(termEnv, colorTerm string, env []string) terminalcap.Co | |||
| 628 | 629 | return terminalcap.Detect(detectionEnv).Color | |
| 629 | 630 | } | |
| 630 | 631 | ||
| 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 { | |
| 635 | 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 | 699 | env.ProbeTerminal = true | |
| 637 | - | env.KittyKeyboard = loadConfigOrDefaults(log, platform.ConfigPath()).Keyboard.KittyProtocol | |
| 700 | + | env.KittyKeyboard = cfg.Keyboard.KittyProtocol | |
| 638 | 701 | return env | |
| 639 | 702 | } | |
| 640 | 703 | ||
| @@ -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).Color | 629 | return terminalcap.Detect(detectionEnv).Color | ||
| 629 | } | 630 | } | ||
| 630 | 631 | ||||
| 631 | - | // outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for | 632 | + | // 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 and | 633 | + | // 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 = true | 699 | env.ProbeTerminal = true | ||
| 637 | - | env.KittyKeyboard = loadConfigOrDefaults(log, platform.ConfigPath()).Keyboard.KittyProtocol | 700 | + | env.KittyKeyboard = cfg.Keyboard.KittyProtocol |
| 638 | return env | 701 | return env | ||
| 639 | } | 702 | } | ||
| 640 | 703 | ||||
M internal/app/broker_client_test.go +78 -0
| @@ -3,9 +3,11 @@ | |||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "bytes" | |
| 6 | 7 | "context" | |
| 7 | 8 | "errors" | |
| 8 | 9 | "io" | |
| 10 | + | "log/slog" | |
| 9 | 11 | "os" | |
| 10 | 12 | "slices" | |
| 11 | 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 | + | } | |
| @@ -3,9 +3,11 @@ | |||||
| 3 | package app | 3 | 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 { | |||
| 240 | 240 | defer terminal.Close() | |
| 241 | 241 | ui := client.NewUI(terminal, clk) | |
| 242 | 242 | sessionEnv := terminalSessionEnvironment() | |
| 243 | - | attachmentEnv := terminalAttachmentEnvironment() | |
| 243 | + | attachmentEnv := virtualTerminalAttachmentEnvironment() | |
| 244 | 244 | attachmentEnv.Cwd = sessionEnv.Cwd | |
| 245 | 245 | return runUIDriverClient(ctx, brokerClientConfig{ | |
| 246 | 246 | Logger: log, | |
| @@ -495,7 +495,7 @@ func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget | |||
| 495 | 495 | return err | |
| 496 | 496 | } | |
| 497 | 497 | sessionEnv := terminalSessionEnvironment() | |
| 498 | - | attachmentEnv := terminalAttachmentEnvironment() | |
| 498 | + | attachmentEnv := configuredTerminalAttachmentEnvironment(log) | |
| 499 | 499 | attachmentEnv.Cwd = sessionEnv.Cwd | |
| 500 | 500 | return runBrokerClient(ctx, brokerClientConfig{ | |
| 501 | 501 | 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.Cwd | 244 | 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 err | 495 | return err | ||
| 496 | } | 496 | } | ||
| 497 | sessionEnv := terminalSessionEnvironment() | 497 | sessionEnv := terminalSessionEnvironment() | ||
| 498 | - | attachmentEnv := terminalAttachmentEnvironment() | 498 | + | attachmentEnv := configuredTerminalAttachmentEnvironment(log) |
| 499 | attachmentEnv.Cwd = sessionEnv.Cwd | 499 | 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 | |||
| 194 | 194 | func runWebTerminalClient(ctx context.Context, terminal *webterm.Terminal) error { | |
| 195 | 195 | callbacks := terminalBrokerCallbacks() | |
| 196 | 196 | sessionEnv := terminalSessionEnvironment() | |
| 197 | - | attachmentEnv := terminalAttachmentEnvironment() | |
| 197 | + | attachmentEnv := virtualTerminalAttachmentEnvironment() | |
| 198 | 198 | attachmentEnv.Cwd = sessionEnv.Cwd | |
| 199 | 199 | return runBrokerClient(ctx, brokerClientConfig{ | |
| 200 | 200 | 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.Cwd | 198 | 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 @@ | |||
| 1 | 1 | package domain | |
| 2 | 2 | ||
| 3 | - | import "time" | |
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain/terminalcap" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | // MinBarInterval is the minimum supported bar refresh interval. | |
| 6 | 10 | const MinBarInterval = time.Second | |
| @@ -98,6 +102,16 @@ type KeyboardConfig struct { | |||
| 98 | 102 | 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 | 115 | // ScrollbackConfig is vev's per-pane retention policy. The terminal library | |
| 102 | 116 | // only enforces these application-selected limits. Zero megabytes disables | |
| 103 | 117 | // history; zero lines removes the additional line ceiling. | |
| @@ -139,6 +153,7 @@ type Config struct { | |||
| 139 | 153 | Tabs TabsConfig | |
| 140 | 154 | Ephemeral EphemeralConfig | |
| 141 | 155 | Keyboard KeyboardConfig | |
| 156 | + | Terminal TerminalConfig | |
| 142 | 157 | Scrollback ScrollbackConfig | |
| 143 | 158 | } | |
| 144 | 159 | ||
| @@ -1,6 +1,10 @@ | |||||
| 1 | package domain | 1 | 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.Second | 10 | const MinBarInterval = time.Second | ||
| @@ -98,6 +102,16 @@ type KeyboardConfig struct { | |||||
| 98 | KittyProtocol bool | 102 | 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 library | 115 | // ScrollbackConfig is vev's per-pane retention policy. The terminal library | ||
| 102 | // only enforces these application-selected limits. Zero megabytes disables | 116 | // 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 TabsConfig | 153 | Tabs TabsConfig | ||
| 140 | Ephemeral EphemeralConfig | 154 | Ephemeral EphemeralConfig | ||
| 141 | Keyboard KeyboardConfig | 155 | Keyboard KeyboardConfig | ||
| 156 | + | Terminal TerminalConfig | |||
| 142 | Scrollback ScrollbackConfig | 157 | Scrollback ScrollbackConfig | ||
| 143 | } | 158 | } | ||
| 144 | 159 | ||||