detached 0103d51

refactor(terminal): model color capability as ColorCapabilities with one profile mapping

Introduce terminalcap.ColorCapabilities (Mode, Source) with RGB() and Colors(),
rename Indexed256 to ANSI256, and move the Detect-then-declared-truecolor
precedence into terminalcap.Resolve. Replace DetectTrueColor and the plain
TrueColor bool plumbing in the client with ColorCapabilities, and share one
usecase/colorprofile.Profile mapping between the daemon output renderer and the
client pickers. Wire fields (Hello.TrueColor, Theme.TrueColor) are unchanged and
filled from RGB().
commit
0103d519c4f1c6474c3663bcc7aa6f5fdcfd1511
author
brice · · 2026-10-03 12:21 UTC
committer
brice · · 2026-10-03 14:14 UTC
parent
8a884f0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
29 files · +243 -103
M boundary_test.go +3 -0
@@ -116,6 +116,9 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) {
116116 {"daemon rejects client", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/client", false, false},
117117 {"daemon rejects broker", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/broker", false, false},
118118 {"daemon rejects app", modulePath + "/internal/usecase/daemon", modulePath + "/internal/app", false, false},
119+ // Shared color-profile policy is a leaf use case both sides may import.
120+ {"client accepts colorprofile", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/colorprofile", false, true},
121+ {"daemon accepts colorprofile", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/colorprofile", false, true},
119122 }
120123 for _, tt := range tests {
121124 t.Run(tt.name, func(t *testing.T) {
@@ -116,6 +116,9 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) {
116 {"daemon rejects client", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/client", false, false},116 {"daemon rejects client", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/client", false, false},
117 {"daemon rejects broker", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/broker", false, false},117 {"daemon rejects broker", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/broker", false, false},
118 {"daemon rejects app", modulePath + "/internal/usecase/daemon", modulePath + "/internal/app", false, false},118 {"daemon rejects app", modulePath + "/internal/usecase/daemon", modulePath + "/internal/app", false, false},
119+ // Shared color-profile policy is a leaf use case both sides may import.
120+ {"client accepts colorprofile", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/colorprofile", false, true},
121+ {"daemon accepts colorprofile", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/colorprofile", false, true},
119 }122 }
120 for _, tt := range tests {123 for _, tt := range tests {
121 t.Run(tt.name, func(t *testing.T) {124 t.Run(tt.name, func(t *testing.T) {
M docs/architecture.md +1 -0
@@ -24,6 +24,7 @@ Every result keeps its request ID for strict correlation.
2424 carrying `Envelope{Payload}` (one complete serialized directional
2525 envelope, no type byte).
2626 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.
27+- `internal/usecase/colorprofile`: the single `terminalcap.ColorCapabilities` → vev-vt color profile mapping shared by the client picker and daemon output renderer.
2728 - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition.
2829 - `internal/adapters/sessionwire`: translates between typed session
2930 connections and raw wire transports: directional `oneof` envelope
@@ -24,6 +24,7 @@ Every result keeps its request ID for strict correlation.
24 carrying `Envelope{Payload}` (one complete serialized directional24 carrying `Envelope{Payload}` (one complete serialized directional
25 envelope, no type byte).25 envelope, no type byte).
26 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.26 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.
27+- `internal/usecase/colorprofile`: the single `terminalcap.ColorCapabilities` → vev-vt color profile mapping shared by the client picker and daemon output renderer.
27 - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition.28 - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition.
28 - `internal/adapters/sessionwire`: translates between typed session29 - `internal/adapters/sessionwire`: translates between typed session
29 connections and raw wire transports: directional `oneof` envelope30 connections and raw wire transports: directional `oneof` envelope
M internal/app/broker_client.go +14 -5
@@ -283,7 +283,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
283283 if reader == nil {
284284 reader = clipboard.New()
285285 }
286- picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor)
286+ picker := client.NewPicker(clk, 0, attachmentEnv.Color)
287287 presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk}
288288 supervisor, err := client.NewSupervisor(client.SupervisorConfig{
289289 Logger: cfg.Logger,
@@ -612,13 +612,22 @@ var (
612612 // process environment themselves.
613613 func terminalAttachmentEnvironment() client.AttachmentEnvironment {
614614 return client.AttachmentEnvironment{
615- TermEnv: os.Getenv("TERM"),
616- Cwd: currentWorkingDirectory(),
617- TrueColor: terminalcap.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
618- PID: uint32(os.Getpid()),
615+ TermEnv: os.Getenv("TERM"),
616+ Cwd: currentWorkingDirectory(),
617+ Color: detectTerminalColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
618+ PID: uint32(os.Getpid()),
619619 }
620620 }
621621
622+// detectTerminalColor derives the color capability from the process
623+// environment. Explicit TERM/COLORTERM values override env entries.
624+func detectTerminalColor(termEnv, colorTerm string, env []string) terminalcap.ColorCapabilities {
625+ detectionEnv := make([]string, 0, len(env)+2)
626+ detectionEnv = append(detectionEnv, env...)
627+ detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm)
628+ return terminalcap.Detect(detectionEnv).Color
629+}
630+
622631 // outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for
623632 // a client that owns a real outer terminal: it probes that terminal once and
624633 // applies the user's keyboard setting. Virtual terminals never probe.
@@ -283,7 +283,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
283 if reader == nil {283 if reader == nil {
284 reader = clipboard.New()284 reader = clipboard.New()
285 }285 }
286- picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor)286+ picker := client.NewPicker(clk, 0, attachmentEnv.Color)
287 presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk}287 presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk}
288 supervisor, err := client.NewSupervisor(client.SupervisorConfig{288 supervisor, err := client.NewSupervisor(client.SupervisorConfig{
289 Logger: cfg.Logger,289 Logger: cfg.Logger,
@@ -612,13 +612,22 @@ var (
612 // process environment themselves.612 // process environment themselves.
613 func terminalAttachmentEnvironment() client.AttachmentEnvironment {613 func terminalAttachmentEnvironment() client.AttachmentEnvironment {
614 return client.AttachmentEnvironment{614 return client.AttachmentEnvironment{
615- TermEnv: os.Getenv("TERM"),615+ TermEnv: os.Getenv("TERM"),
616- Cwd: currentWorkingDirectory(),616+ Cwd: currentWorkingDirectory(),
617- TrueColor: terminalcap.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),617+ Color: detectTerminalColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
618- PID: uint32(os.Getpid()),618+ PID: uint32(os.Getpid()),
619 }619 }
620 }620 }
621 621
622+// detectTerminalColor derives the color capability from the process
623+// environment. Explicit TERM/COLORTERM values override env entries.
624+func detectTerminalColor(termEnv, colorTerm string, env []string) terminalcap.ColorCapabilities {
625+ detectionEnv := make([]string, 0, len(env)+2)
626+ detectionEnv = append(detectionEnv, env...)
627+ detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm)
628+ return terminalcap.Detect(detectionEnv).Color
629+}
630+
622 // outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for631 // outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for
623 // a client that owns a real outer terminal: it probes that terminal once and632 // a client that owns a real outer terminal: it probes that terminal once and
624 // applies the user's keyboard setting. Virtual terminals never probe.633 // applies the user's keyboard setting. Virtual terminals never probe.
M internal/app/broker_client_test.go +21 -0
@@ -18,6 +18,7 @@ import (
1818 "github.com/bnema/vev/internal/adapters/brokeripc"
1919 "github.com/bnema/vev/internal/adapters/clock"
2020 "github.com/bnema/vev/internal/domain"
21+ "github.com/bnema/vev/internal/domain/terminalcap"
2122 "github.com/bnema/vev/internal/ports"
2223 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2324 "github.com/bnema/vev/internal/protocol"
@@ -1162,3 +1163,23 @@ func awaitTerminalCompositionEpoch(t *testing.T, service ports.BrokerService) po
11621163 }
11631164 }
11641165 }
1166+
1167+func TestDetectTerminalColor(t *testing.T) {
1168+ tests := []struct {
1169+ name string
1170+ term string
1171+ colorTerm string
1172+ env []string
1173+ want terminalcap.ColorCapabilities
1174+ }{
1175+ {name: "kitty environment keeps heuristic truecolor", term: "xterm-kitty", env: []string{"KITTY_WINDOW_ID=1"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceHeuristic}},
1176+ {name: "explicit COLORTERM overrides env", term: "xterm-256color", colorTerm: "truecolor", env: []string{"COLORTERM="}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}},
1177+ {name: "explicit TERM overrides env", term: "xterm-256color", env: []string{"TERM=foot-direct"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}},
1178+ {name: "unknown terminal is indexed", term: "unknown", want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
1179+ }
1180+ for _, tt := range tests {
1181+ t.Run(tt.name, func(t *testing.T) {
1182+ require.Equal(t, tt.want, detectTerminalColor(tt.term, tt.colorTerm, tt.env))
1183+ })
1184+ }
1185+}
@@ -18,6 +18,7 @@ import (
18 "github.com/bnema/vev/internal/adapters/brokeripc"18 "github.com/bnema/vev/internal/adapters/brokeripc"
19 "github.com/bnema/vev/internal/adapters/clock"19 "github.com/bnema/vev/internal/adapters/clock"
20 "github.com/bnema/vev/internal/domain"20 "github.com/bnema/vev/internal/domain"
21+ "github.com/bnema/vev/internal/domain/terminalcap"
21 "github.com/bnema/vev/internal/ports"22 "github.com/bnema/vev/internal/ports"
22 portsmocks "github.com/bnema/vev/internal/ports/mocks"23 portsmocks "github.com/bnema/vev/internal/ports/mocks"
23 "github.com/bnema/vev/internal/protocol"24 "github.com/bnema/vev/internal/protocol"
@@ -1162,3 +1163,23 @@ func awaitTerminalCompositionEpoch(t *testing.T, service ports.BrokerService) po
1162 }1163 }
1163 }1164 }
1164 }1165 }
1166+
1167+func TestDetectTerminalColor(t *testing.T) {
1168+ tests := []struct {
1169+ name string
1170+ term string
1171+ colorTerm string
1172+ env []string
1173+ want terminalcap.ColorCapabilities
1174+ }{
1175+ {name: "kitty environment keeps heuristic truecolor", term: "xterm-kitty", env: []string{"KITTY_WINDOW_ID=1"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceHeuristic}},
1176+ {name: "explicit COLORTERM overrides env", term: "xterm-256color", colorTerm: "truecolor", env: []string{"COLORTERM="}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}},
1177+ {name: "explicit TERM overrides env", term: "xterm-256color", env: []string{"TERM=foot-direct"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}},
1178+ {name: "unknown terminal is indexed", term: "unknown", want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
1179+ }
1180+ for _, tt := range tests {
1181+ t.Run(tt.name, func(t *testing.T) {
1182+ require.Equal(t, tt.want, detectTerminalColor(tt.term, tt.colorTerm, tt.env))
1183+ })
1184+ }
1185+}
M internal/app/broker_offline_client_render_test.go +2 -1
@@ -12,6 +12,7 @@ import (
1212 "github.com/stretchr/testify/require"
1313
1414 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/terminalcap"
1516 "github.com/bnema/vev/internal/ports"
1617 "github.com/bnema/vev/internal/protocol"
1718 "github.com/bnema/vev/internal/protocol/catalogue"
@@ -116,7 +117,7 @@ func (w *offlineRenderTerminalWriter) Write(data []byte) (int, error) {
116117 // daemon with a single session, so Render produces a real frame at any size.
117118 func offlineRenderPicker(t *testing.T) *client.Picker {
118119 t.Helper()
119- picker := client.NewPicker(nil, 0, true)
120+ picker := client.NewPicker(nil, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
120121 picker.ApplySnapshot(ports.BrokerSnapshot{
121122 Epoch: 1,
122123 Revision: 1,
@@ -12,6 +12,7 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/terminalcap"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/protocol"17 "github.com/bnema/vev/internal/protocol"
17 "github.com/bnema/vev/internal/protocol/catalogue"18 "github.com/bnema/vev/internal/protocol/catalogue"
@@ -116,7 +117,7 @@ func (w *offlineRenderTerminalWriter) Write(data []byte) (int, error) {
116 // daemon with a single session, so Render produces a real frame at any size.117 // daemon with a single session, so Render produces a real frame at any size.
117 func offlineRenderPicker(t *testing.T) *client.Picker {118 func offlineRenderPicker(t *testing.T) *client.Picker {
118 t.Helper()119 t.Helper()
119- picker := client.NewPicker(nil, 0, true)120+ picker := client.NewPicker(nil, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
120 picker.ApplySnapshot(ports.BrokerSnapshot{121 picker.ApplySnapshot(ports.BrokerSnapshot{
121 Epoch: 1,122 Epoch: 1,
122 Revision: 1,123 Revision: 1,
M internal/domain/terminalcap/capabilities.go +41 -23
@@ -10,9 +10,29 @@ const (
1010 // TrueColor is the zero value so manually constructed attachments retain the
1111 // historical renderer behavior; live Hello paths always use the detector.
1212 TrueColor ColorMode = iota
13- Indexed256
13+ ANSI256
1414 )
1515
16+// ColorCapabilities is the color output capability selected for one attachment
17+// and how confidently it was selected.
18+type ColorCapabilities struct {
19+ Mode ColorMode
20+ Source Source
21+}
22+
23+// RGB reports whether the attachment can receive RGB ANSI output.
24+func (c ColorCapabilities) RGB() bool { return c.Mode == TrueColor }
25+
26+// Colors reports the number of colors the attachment can display.
27+func (c ColorCapabilities) Colors() int {
28+ switch c.Mode {
29+ case ANSI256:
30+ return 256
31+ default:
32+ return 1 << 24
33+ }
34+}
35+
1636 // Source records how confidently a terminal capability was selected.
1737 type Source uint8
1838
@@ -33,8 +53,7 @@ const (
3353
3454 // Capabilities describes the output features selected for one client attachment.
3555 type Capabilities struct {
36- ColorMode ColorMode
37- ColorSource Source
56+ Color ColorCapabilities
3857 Application Application
3958 KittyGraphics bool
4059 }
@@ -43,15 +62,12 @@ type Capabilities struct {
4362 // accepted the Kitty graphics protocol. Environment detection never sets it.
4463 func (c Capabilities) SupportsKittyGraphics() bool { return c.KittyGraphics }
4564
46-// TrueColor reports whether this attachment can receive RGB ANSI output.
47-func (c Capabilities) TrueColor() bool { return c.ColorMode == TrueColor }
48-
4965 // Detect derives conservative attachment capabilities from a client environment.
5066 func Detect(env []string) Capabilities {
5167 values := environmentValues(env)
5268 term := strings.ToLower(strings.TrimSpace(values["TERM"]))
5369 colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"]))
54- caps := Capabilities{ColorMode: Indexed256}
70+ caps := Capabilities{Color: ColorCapabilities{Mode: ANSI256}}
5571
5672 if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" {
5773 caps.Application = ApplicationKitty
@@ -60,25 +76,25 @@ func Detect(env []string) Capabilities {
6076 kittyIdentity := term == "xterm-kitty" && caps.Application == ApplicationKitty
6177 switch colorTerm {
6278 case "truecolor", "24bit":
63- caps.ColorMode = TrueColor
64- caps.ColorSource = SourceDeclared
79+ caps.Color.Mode = TrueColor
80+ caps.Color.Source = SourceDeclared
6581 }
6682 if term == "xterm-direct" || strings.HasSuffix(term, "-direct") {
67- caps.ColorMode = TrueColor
68- caps.ColorSource = SourceDeclared
83+ caps.Color.Mode = TrueColor
84+ caps.Color.Source = SourceDeclared
6985 }
7086 if kittyIdentity {
71- if caps.ColorSource == SourceUnknown {
72- caps.ColorSource = SourceHeuristic
87+ if caps.Color.Source == SourceUnknown {
88+ caps.Color.Source = SourceHeuristic
7389 }
74- caps.ColorMode = TrueColor
90+ caps.Color.Mode = TrueColor
7591 return caps
7692 }
77- if caps.ColorSource == SourceDeclared {
93+ if caps.Color.Source == SourceDeclared {
7894 return caps
7995 }
8096 if strings.Contains(term, "256color") || term == "dumb" {
81- caps.ColorSource = SourceDeclared
97+ caps.Color.Source = SourceDeclared
8298 }
8399 return caps
84100 }
@@ -94,11 +110,13 @@ func environmentValues(env []string) map[string]string {
94110 return values
95111 }
96112
97-// DetectTrueColor reports whether the supplied terminal environment advertises
98-// direct color support. Explicit TERM/COLORTERM values override env entries.
99-func DetectTrueColor(termEnv, colorTerm string, env []string) bool {
100- detectionEnv := make([]string, 0, len(env)+2)
101- detectionEnv = append(detectionEnv, env...)
102- detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm)
103- return Detect(detectionEnv).TrueColor()
113+// Resolve selects attachment capabilities from the client environment and the
114+// client's explicit color declaration. A declared truecolor client upgrades a
115+// weaker environment detection to declared TrueColor.
116+func Resolve(env []string, declaredTrueColor bool) Capabilities {
117+ caps := Detect(env)
118+ if declaredTrueColor && !caps.Color.RGB() {
119+ caps.Color = ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}
120+ }
121+ return caps
104122 }
@@ -10,9 +10,29 @@ const (
10 // TrueColor is the zero value so manually constructed attachments retain the10 // TrueColor is the zero value so manually constructed attachments retain the
11 // historical renderer behavior; live Hello paths always use the detector.11 // historical renderer behavior; live Hello paths always use the detector.
12 TrueColor ColorMode = iota12 TrueColor ColorMode = iota
13- Indexed25613+ ANSI256
14 )14 )
15 15
16+// ColorCapabilities is the color output capability selected for one attachment
17+// and how confidently it was selected.
18+type ColorCapabilities struct {
19+ Mode ColorMode
20+ Source Source
21+}
22+
23+// RGB reports whether the attachment can receive RGB ANSI output.
24+func (c ColorCapabilities) RGB() bool { return c.Mode == TrueColor }
25+
26+// Colors reports the number of colors the attachment can display.
27+func (c ColorCapabilities) Colors() int {
28+ switch c.Mode {
29+ case ANSI256:
30+ return 256
31+ default:
32+ return 1 << 24
33+ }
34+}
35+
16 // Source records how confidently a terminal capability was selected.36 // Source records how confidently a terminal capability was selected.
17 type Source uint837 type Source uint8
18 38
@@ -33,8 +53,7 @@ const (
33 53
34 // Capabilities describes the output features selected for one client attachment.54 // Capabilities describes the output features selected for one client attachment.
35 type Capabilities struct {55 type Capabilities struct {
36- ColorMode ColorMode56+ Color ColorCapabilities
37- ColorSource Source
38 Application Application57 Application Application
39 KittyGraphics bool58 KittyGraphics bool
40 }59 }
@@ -43,15 +62,12 @@ type Capabilities struct {
43 // accepted the Kitty graphics protocol. Environment detection never sets it.62 // accepted the Kitty graphics protocol. Environment detection never sets it.
44 func (c Capabilities) SupportsKittyGraphics() bool { return c.KittyGraphics }63 func (c Capabilities) SupportsKittyGraphics() bool { return c.KittyGraphics }
45 64
46-// TrueColor reports whether this attachment can receive RGB ANSI output.
47-func (c Capabilities) TrueColor() bool { return c.ColorMode == TrueColor }
48-
49 // Detect derives conservative attachment capabilities from a client environment.65 // Detect derives conservative attachment capabilities from a client environment.
50 func Detect(env []string) Capabilities {66 func Detect(env []string) Capabilities {
51 values := environmentValues(env)67 values := environmentValues(env)
52 term := strings.ToLower(strings.TrimSpace(values["TERM"]))68 term := strings.ToLower(strings.TrimSpace(values["TERM"]))
53 colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"]))69 colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"]))
54- caps := Capabilities{ColorMode: Indexed256}70+ caps := Capabilities{Color: ColorCapabilities{Mode: ANSI256}}
55 71
56 if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" {72 if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" {
57 caps.Application = ApplicationKitty73 caps.Application = ApplicationKitty
@@ -60,25 +76,25 @@ func Detect(env []string) Capabilities {
60 kittyIdentity := term == "xterm-kitty" && caps.Application == ApplicationKitty76 kittyIdentity := term == "xterm-kitty" && caps.Application == ApplicationKitty
61 switch colorTerm {77 switch colorTerm {
62 case "truecolor", "24bit":78 case "truecolor", "24bit":
63- caps.ColorMode = TrueColor79+ caps.Color.Mode = TrueColor
64- caps.ColorSource = SourceDeclared80+ caps.Color.Source = SourceDeclared
65 }81 }
66 if term == "xterm-direct" || strings.HasSuffix(term, "-direct") {82 if term == "xterm-direct" || strings.HasSuffix(term, "-direct") {
67- caps.ColorMode = TrueColor83+ caps.Color.Mode = TrueColor
68- caps.ColorSource = SourceDeclared84+ caps.Color.Source = SourceDeclared
69 }85 }
70 if kittyIdentity {86 if kittyIdentity {
71- if caps.ColorSource == SourceUnknown {87+ if caps.Color.Source == SourceUnknown {
72- caps.ColorSource = SourceHeuristic88+ caps.Color.Source = SourceHeuristic
73 }89 }
74- caps.ColorMode = TrueColor90+ caps.Color.Mode = TrueColor
75 return caps91 return caps
76 }92 }
77- if caps.ColorSource == SourceDeclared {93+ if caps.Color.Source == SourceDeclared {
78 return caps94 return caps
79 }95 }
80 if strings.Contains(term, "256color") || term == "dumb" {96 if strings.Contains(term, "256color") || term == "dumb" {
81- caps.ColorSource = SourceDeclared97+ caps.Color.Source = SourceDeclared
82 }98 }
83 return caps99 return caps
84 }100 }
@@ -94,11 +110,13 @@ func environmentValues(env []string) map[string]string {
94 return values110 return values
95 }111 }
96 112
97-// DetectTrueColor reports whether the supplied terminal environment advertises113+// Resolve selects attachment capabilities from the client environment and the
98-// direct color support. Explicit TERM/COLORTERM values override env entries.114+// client's explicit color declaration. A declared truecolor client upgrades a
99-func DetectTrueColor(termEnv, colorTerm string, env []string) bool {115+// weaker environment detection to declared TrueColor.
100- detectionEnv := make([]string, 0, len(env)+2)116+func Resolve(env []string, declaredTrueColor bool) Capabilities {
101- detectionEnv = append(detectionEnv, env...)117+ caps := Detect(env)
102- detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm)118+ if declaredTrueColor && !caps.Color.RGB() {
103- return Detect(detectionEnv).TrueColor()119+ caps.Color = ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}
120+ }
121+ return caps
104 }122 }
M internal/domain/terminalcap/capabilities_test.go +47 -7
@@ -6,8 +6,48 @@ import (
66 "github.com/stretchr/testify/require"
77 )
88
9-func TestDetectTrueColorPreservesKittyEnvironment(t *testing.T) {
10- require.True(t, DetectTrueColor("xterm-kitty", "", []string{"KITTY_WINDOW_ID=1"}))
9+func TestColorCapabilities(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ in ColorCapabilities
13+ wantRGB bool
14+ wantColors int
15+ }{
16+ {name: "zero value is truecolor", in: ColorCapabilities{}, wantRGB: true, wantColors: 16777216},
17+ {name: "truecolor", in: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantRGB: true, wantColors: 16777216},
18+ {name: "ansi256", in: ColorCapabilities{Mode: ANSI256}, wantRGB: false, wantColors: 256},
19+ }
20+ for _, tt := range tests {
21+ t.Run(tt.name, func(t *testing.T) {
22+ require.Equal(t, tt.wantRGB, tt.in.RGB())
23+ require.Equal(t, tt.wantColors, tt.in.Colors())
24+ })
25+ }
26+}
27+
28+func TestResolve(t *testing.T) {
29+ tests := []struct {
30+ name string
31+ env []string
32+ declared bool
33+ want ColorCapabilities
34+ wantApp Application
35+ }{
36+ {name: "256 color without declaration stays indexed", env: []string{"TERM=xterm-256color"}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}},
37+ {name: "declaration upgrades indexed terminal", env: []string{"TERM=xterm-256color"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
38+ {name: "declaration upgrades unknown terminal", env: []string{"TERM=unknown"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
39+ {name: "declaration keeps environment truecolor detection", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, wantApp: ApplicationKitty},
40+ {name: "environment truecolor without declaration", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
41+ {name: "declaration keeps application", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantApp: ApplicationKitty},
42+ }
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ got := Resolve(tt.env, tt.declared)
46+ require.Equal(t, tt.want, got.Color)
47+ require.Equal(t, tt.wantApp, got.Application)
48+ require.False(t, got.KittyGraphics)
49+ })
50+ }
1151 }
1252
1353 func TestDetect(t *testing.T) {
@@ -24,16 +64,16 @@ func TestDetect(t *testing.T) {
2464 {name: "direct terminfo entry", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared},
2565 {name: "kitty signals infer truecolor", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty},
2666 {name: "kitty environment with declared truecolor remains color-only", env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceDeclared, wantApp: ApplicationKitty},
27- {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: Indexed256, wantSource: SourceDeclared},
28- {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: Indexed256, wantSource: SourceUnknown},
29- {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: Indexed256, wantSource: SourceDeclared, wantApp: ApplicationKitty},
67+ {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
68+ {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: ANSI256, wantSource: SourceUnknown},
69+ {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: ANSI256, wantSource: SourceDeclared, wantApp: ApplicationKitty},
3070 }
3171
3272 for _, tt := range tests {
3373 t.Run(tt.name, func(t *testing.T) {
3474 got := Detect(tt.env)
35- require.Equal(t, tt.wantMode, got.ColorMode)
36- require.Equal(t, tt.wantSource, got.ColorSource)
75+ require.Equal(t, tt.wantMode, got.Color.Mode)
76+ require.Equal(t, tt.wantSource, got.Color.Source)
3777 require.Equal(t, tt.wantApp, got.Application)
3878 require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics())
3979 })
@@ -6,8 +6,48 @@ import (
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 )7 )
8 8
9-func TestDetectTrueColorPreservesKittyEnvironment(t *testing.T) {9+func TestColorCapabilities(t *testing.T) {
10- require.True(t, DetectTrueColor("xterm-kitty", "", []string{"KITTY_WINDOW_ID=1"}))10+ tests := []struct {
11+ name string
12+ in ColorCapabilities
13+ wantRGB bool
14+ wantColors int
15+ }{
16+ {name: "zero value is truecolor", in: ColorCapabilities{}, wantRGB: true, wantColors: 16777216},
17+ {name: "truecolor", in: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantRGB: true, wantColors: 16777216},
18+ {name: "ansi256", in: ColorCapabilities{Mode: ANSI256}, wantRGB: false, wantColors: 256},
19+ }
20+ for _, tt := range tests {
21+ t.Run(tt.name, func(t *testing.T) {
22+ require.Equal(t, tt.wantRGB, tt.in.RGB())
23+ require.Equal(t, tt.wantColors, tt.in.Colors())
24+ })
25+ }
26+}
27+
28+func TestResolve(t *testing.T) {
29+ tests := []struct {
30+ name string
31+ env []string
32+ declared bool
33+ want ColorCapabilities
34+ wantApp Application
35+ }{
36+ {name: "256 color without declaration stays indexed", env: []string{"TERM=xterm-256color"}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}},
37+ {name: "declaration upgrades indexed terminal", env: []string{"TERM=xterm-256color"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
38+ {name: "declaration upgrades unknown terminal", env: []string{"TERM=unknown"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
39+ {name: "declaration keeps environment truecolor detection", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, wantApp: ApplicationKitty},
40+ {name: "environment truecolor without declaration", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
41+ {name: "declaration keeps application", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantApp: ApplicationKitty},
42+ }
43+ for _, tt := range tests {
44+ t.Run(tt.name, func(t *testing.T) {
45+ got := Resolve(tt.env, tt.declared)
46+ require.Equal(t, tt.want, got.Color)
47+ require.Equal(t, tt.wantApp, got.Application)
48+ require.False(t, got.KittyGraphics)
49+ })
50+ }
11 }51 }
12 52
13 func TestDetect(t *testing.T) {53 func TestDetect(t *testing.T) {
@@ -24,16 +64,16 @@ func TestDetect(t *testing.T) {
24 {name: "direct terminfo entry", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared},64 {name: "direct terminfo entry", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared},
25 {name: "kitty signals infer truecolor", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty},65 {name: "kitty signals infer truecolor", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty},
26 {name: "kitty environment with declared truecolor remains color-only", env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceDeclared, wantApp: ApplicationKitty},66 {name: "kitty environment with declared truecolor remains color-only", env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceDeclared, wantApp: ApplicationKitty},
27- {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: Indexed256, wantSource: SourceDeclared},67+ {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
28- {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: Indexed256, wantSource: SourceUnknown},68+ {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: ANSI256, wantSource: SourceUnknown},
29- {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: Indexed256, wantSource: SourceDeclared, wantApp: ApplicationKitty},69+ {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: ANSI256, wantSource: SourceDeclared, wantApp: ApplicationKitty},
30 }70 }
31 71
32 for _, tt := range tests {72 for _, tt := range tests {
33 t.Run(tt.name, func(t *testing.T) {73 t.Run(tt.name, func(t *testing.T) {
34 got := Detect(tt.env)74 got := Detect(tt.env)
35- require.Equal(t, tt.wantMode, got.ColorMode)75+ require.Equal(t, tt.wantMode, got.Color.Mode)
36- require.Equal(t, tt.wantSource, got.ColorSource)76+ require.Equal(t, tt.wantSource, got.Color.Source)
37 require.Equal(t, tt.wantApp, got.Application)77 require.Equal(t, tt.wantApp, got.Application)
38 require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics())78 require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics())
39 })79 })
M internal/usecase/client/attachment_input.go +1 -1
@@ -161,7 +161,7 @@ func (in *attachmentInput) start(ctx context.Context) error {
161161 if in.palette == nil {
162162 return nil
163163 }
164- retained := in.themes.update(func(current *protocol.Theme) { current.TrueColor = in.worker.cfg.TrueColor })
164+ retained := in.themes.update(func(current *protocol.Theme) { current.TrueColor = in.worker.cfg.Color.RGB() })
165165 return in.apply(ctx, in.palette.start(retained, false))
166166 }
167167
@@ -161,7 +161,7 @@ func (in *attachmentInput) start(ctx context.Context) error {
161 if in.palette == nil {161 if in.palette == nil {
162 return nil162 return nil
163 }163 }
164- retained := in.themes.update(func(current *protocol.Theme) { current.TrueColor = in.worker.cfg.TrueColor })164+ retained := in.themes.update(func(current *protocol.Theme) { current.TrueColor = in.worker.cfg.Color.RGB() })
165 return in.apply(ctx, in.palette.start(retained, false))165 return in.apply(ctx, in.palette.start(retained, false))
166 }166 }
167 167
M internal/usecase/client/attachment_input_test.go +2 -1
@@ -10,6 +10,7 @@ import (
1010 "github.com/stretchr/testify/require"
1111
1212 renderer "github.com/bnema/vev-vt"
13+ "github.com/bnema/vev/internal/domain/terminalcap"
1314 "github.com/bnema/vev/internal/ports"
1415 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1516 "github.com/bnema/vev/internal/protocol"
@@ -67,7 +68,7 @@ func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, reque
6768 cfg.Clock = h.clock
6869 cfg.Theme = themes
6970 cfg.Clipboard = clipboard
70- cfg.TrueColor = true
71+ cfg.Color = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}
7172 worker, err := newSessionAttachmentWorker(cfg)
7273 require.NoError(t, err)
7374 go func() {
@@ -10,6 +10,7 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12 renderer "github.com/bnema/vev-vt"12 renderer "github.com/bnema/vev-vt"
13+ "github.com/bnema/vev/internal/domain/terminalcap"
13 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
14 portsmocks "github.com/bnema/vev/internal/ports/mocks"15 portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 "github.com/bnema/vev/internal/protocol"16 "github.com/bnema/vev/internal/protocol"
@@ -67,7 +68,7 @@ func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, reque
67 cfg.Clock = h.clock68 cfg.Clock = h.clock
68 cfg.Theme = themes69 cfg.Theme = themes
69 cfg.Clipboard = clipboard70 cfg.Clipboard = clipboard
70- cfg.TrueColor = true71+ cfg.Color = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}
71 worker, err := newSessionAttachmentWorker(cfg)72 worker, err := newSessionAttachmentWorker(cfg)
72 require.NoError(t, err)73 require.NoError(t, err)
73 go func() {74 go func() {
M internal/usecase/client/attachment_session.go +9 -8
@@ -9,6 +9,7 @@ import (
99 "time"
1010
1111 "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/domain/terminalcap"
1213 "github.com/bnema/vev/internal/ports"
1314 "github.com/bnema/vev/internal/protocol"
1415 )
@@ -68,12 +69,12 @@ func (e *AttachmentIdentityError) Error() string {
6869 // The request is the exact request the supervisor opened the stream with; the
6970 // rest is client-local presentation data the worker mirrors into Hello.
7071 type sessionAttachmentConfig struct {
71- Request ports.BrokerOpenStreamRequest
72- ClientID [16]byte
73- Geometry domain.Geometry
74- TermEnv string
75- Cwd string
76- TrueColor bool
72+ Request ports.BrokerOpenStreamRequest
73+ ClientID [16]byte
74+ Geometry domain.Geometry
75+ TermEnv string
76+ Cwd string
77+ Color terminalcap.ColorCapabilities
7778 // PID is this client's process ID; Hello carries it on local attaches.
7879 PID uint32
7980 // Capabilities are the outer terminal's probed capabilities, declared in
@@ -322,7 +323,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
322323 var samePeerRequests uint64
323324 var samePeerSwitch *samePeerSwitchPending
324325 samePeerUI := attachmentSamePeerUI(fg)
325- picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}
326+ picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.Color)}
326327 defer picker.stopEscape()
327328 input := newAttachmentInput(ctx, w, fg, stream, picker)
328329 defer input.close()
@@ -751,7 +752,7 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
751752 PixelHeight: geometry.PixelHeight,
752753 TermEnv: w.cfg.TermEnv,
753754 Cwd: cwd,
754- TrueColor: w.cfg.TrueColor,
755+ TrueColor: w.cfg.Color.RGB(),
755756 // A remote attach reaches the terminal through this client, so both
756757 // declarations describe this client's outer terminal.
757758 KittyDirectGraphics: w.cfg.Capabilities.KittyGraphics,
@@ -9,6 +9,7 @@ import (
9 "time"9 "time"
10 10
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/domain/terminalcap"
12 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
13 "github.com/bnema/vev/internal/protocol"14 "github.com/bnema/vev/internal/protocol"
14 )15 )
@@ -68,12 +69,12 @@ func (e *AttachmentIdentityError) Error() string {
68 // The request is the exact request the supervisor opened the stream with; the69 // The request is the exact request the supervisor opened the stream with; the
69 // rest is client-local presentation data the worker mirrors into Hello.70 // rest is client-local presentation data the worker mirrors into Hello.
70 type sessionAttachmentConfig struct {71 type sessionAttachmentConfig struct {
71- Request ports.BrokerOpenStreamRequest72+ Request ports.BrokerOpenStreamRequest
72- ClientID [16]byte73+ ClientID [16]byte
73- Geometry domain.Geometry74+ Geometry domain.Geometry
74- TermEnv string75+ TermEnv string
75- Cwd string76+ Cwd string
76- TrueColor bool77+ Color terminalcap.ColorCapabilities
77 // PID is this client's process ID; Hello carries it on local attaches.78 // PID is this client's process ID; Hello carries it on local attaches.
78 PID uint3279 PID uint32
79 // Capabilities are the outer terminal's probed capabilities, declared in80 // Capabilities are the outer terminal's probed capabilities, declared in
@@ -322,7 +323,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
322 var samePeerRequests uint64323 var samePeerRequests uint64
323 var samePeerSwitch *samePeerSwitchPending324 var samePeerSwitch *samePeerSwitchPending
324 samePeerUI := attachmentSamePeerUI(fg)325 samePeerUI := attachmentSamePeerUI(fg)
325- picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}326+ picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.Color)}
326 defer picker.stopEscape()327 defer picker.stopEscape()
327 input := newAttachmentInput(ctx, w, fg, stream, picker)328 input := newAttachmentInput(ctx, w, fg, stream, picker)
328 defer input.close()329 defer input.close()
@@ -751,7 +752,7 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
751 PixelHeight: geometry.PixelHeight,752 PixelHeight: geometry.PixelHeight,
752 TermEnv: w.cfg.TermEnv,753 TermEnv: w.cfg.TermEnv,
753 Cwd: cwd,754 Cwd: cwd,
754- TrueColor: w.cfg.TrueColor,755+ TrueColor: w.cfg.Color.RGB(),
755 // A remote attach reaches the terminal through this client, so both756 // A remote attach reaches the terminal through this client, so both
756 // declarations describe this client's outer terminal.757 // declarations describe this client's outer terminal.
757 KittyDirectGraphics: w.cfg.Capabilities.KittyGraphics,758 KittyDirectGraphics: w.cfg.Capabilities.KittyGraphics,
M internal/usecase/client/attachment_session_test.go +6 -3
@@ -12,6 +12,7 @@ import (
1212 "github.com/stretchr/testify/require"
1313
1414 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/terminalcap"
1516 "github.com/bnema/vev/internal/ports"
1617 "github.com/bnema/vev/internal/protocol"
1718 )
@@ -528,9 +529,11 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
528529 tests := []struct {
529530 name string
530531 env AttachmentEnvironment
532+ want bool
531533 }{
532- {name: "unset"},
533- {name: "supplied", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", TrueColor: true}},
534+ {name: "unset defaults to truecolor", want: true},
535+ {name: "supplied truecolor", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}}, want: true},
536+ {name: "supplied ansi256", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}},
534537 }
535538 for _, tt := range tests {
536539 t.Run(tt.name, func(t *testing.T) {
@@ -551,7 +554,7 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
551554 hello := awaitHello(t, stream)
552555 require.Equal(t, tt.env.TermEnv, hello.TermEnv)
553556 require.Equal(t, tt.env.Cwd, hello.Cwd)
554- require.Equal(t, tt.env.TrueColor, hello.TrueColor)
557+ require.Equal(t, tt.want, hello.TrueColor)
555558 require.NoError(t, stream.Close())
556559 <-events
557560 })
@@ -12,6 +12,7 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/terminalcap"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/protocol"17 "github.com/bnema/vev/internal/protocol"
17 )18 )
@@ -528,9 +529,11 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
528 tests := []struct {529 tests := []struct {
529 name string530 name string
530 env AttachmentEnvironment531 env AttachmentEnvironment
532+ want bool
531 }{533 }{
532- {name: "unset"},534+ {name: "unset defaults to truecolor", want: true},
533- {name: "supplied", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", TrueColor: true}},535+ {name: "supplied truecolor", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}}, want: true},
536+ {name: "supplied ansi256", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}},
534 }537 }
535 for _, tt := range tests {538 for _, tt := range tests {
536 t.Run(tt.name, func(t *testing.T) {539 t.Run(tt.name, func(t *testing.T) {
@@ -551,7 +554,7 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
551 hello := awaitHello(t, stream)554 hello := awaitHello(t, stream)
552 require.Equal(t, tt.env.TermEnv, hello.TermEnv)555 require.Equal(t, tt.env.TermEnv, hello.TermEnv)
553 require.Equal(t, tt.env.Cwd, hello.Cwd)556 require.Equal(t, tt.env.Cwd, hello.Cwd)
554- require.Equal(t, tt.env.TrueColor, hello.TrueColor)557+ require.Equal(t, tt.want, hello.TrueColor)
555 require.NoError(t, stream.Close())558 require.NoError(t, stream.Close())
556 <-events559 <-events
557 })560 })
M internal/usecase/client/move_picker_overlay.go +4 -2
@@ -6,8 +6,10 @@ import (
66 "time"
77
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/terminalcap"
910 "github.com/bnema/vev/internal/ports"
1011 "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/usecase/colorprofile"
1113 "github.com/bnema/vev/internal/usecase/keys/kittykey"
1214 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
1315 )
@@ -40,8 +42,8 @@ type movePickerOverlay struct {
4042 presenting bool
4143 }
4244
43-func newMovePickerOverlay(trueColor bool) *movePickerOverlay {
44- return &movePickerOverlay{renderer: newPickerRenderer(pickerColorProfile(trueColor))}
45+func newMovePickerOverlay(color terminalcap.ColorCapabilities) *movePickerOverlay {
46+ return &movePickerOverlay{renderer: newPickerRenderer(colorprofile.Profile(color))}
4547 }
4648
4749 // offer opens the namespace a move offer names. A superseding offer retires
@@ -6,8 +6,10 @@ import (
6 "time"6 "time"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/terminalcap"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/protocol"11 "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/usecase/colorprofile"
11 "github.com/bnema/vev/internal/usecase/keys/kittykey"13 "github.com/bnema/vev/internal/usecase/keys/kittykey"
12 pickerusecase "github.com/bnema/vev/internal/usecase/picker"14 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
13 )15 )
@@ -40,8 +42,8 @@ type movePickerOverlay struct {
40 presenting bool42 presenting bool
41 }43 }
42 44
43-func newMovePickerOverlay(trueColor bool) *movePickerOverlay {45+func newMovePickerOverlay(color terminalcap.ColorCapabilities) *movePickerOverlay {
44- return &movePickerOverlay{renderer: newPickerRenderer(pickerColorProfile(trueColor))}46+ return &movePickerOverlay{renderer: newPickerRenderer(colorprofile.Profile(color))}
45 }47 }
46 48
47 // offer opens the namespace a move offer names. A superseding offer retires49 // offer opens the namespace a move offer names. A superseding offer retires
M internal/usecase/client/picker_composition.go +5 -4
@@ -4,6 +4,7 @@ import (
44 "time"
55
66 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/domain/terminalcap"
78 "github.com/bnema/vev/internal/ports"
89 "github.com/bnema/vev/internal/protocol"
910 "github.com/bnema/vev/internal/usecase/keys/kittykey"
@@ -49,11 +50,11 @@ func PickerPresentation(state State) bool {
4950 }
5051
5152 // NewPicker composes one client-owned picker. A nil clock uses the system
52-// clock, and a zero freshness uses the catalogue default. trueColor is the
53-// terminal's detected color capability (AttachmentEnvironment.TrueColor); the
53+// clock, and a zero freshness uses the catalogue default. color is the
54+// terminal's detected color capability (AttachmentEnvironment.Color); the
5455 // picker renders indexed colors without it.
55-func NewPicker(clock ports.Clock, freshness time.Duration, trueColor bool) *Picker {
56- return &Picker{controller: newPickerController(clock, freshness, trueColor)}
56+func NewPicker(clock ports.Clock, freshness time.Duration, color terminalcap.ColorCapabilities) *Picker {
57+ return &Picker{controller: newPickerController(clock, freshness, color)}
5758 }
5859
5960 // ApplySnapshot forwards a broker publication to the supervisor-owned picker.
@@ -4,6 +4,7 @@ import (
4 "time"4 "time"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/domain/terminalcap"
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/protocol"9 "github.com/bnema/vev/internal/protocol"
9 "github.com/bnema/vev/internal/usecase/keys/kittykey"10 "github.com/bnema/vev/internal/usecase/keys/kittykey"
@@ -49,11 +50,11 @@ func PickerPresentation(state State) bool {
49 }50 }
50 51
51 // NewPicker composes one client-owned picker. A nil clock uses the system52 // NewPicker composes one client-owned picker. A nil clock uses the system
52-// clock, and a zero freshness uses the catalogue default. trueColor is the53+// clock, and a zero freshness uses the catalogue default. color is the
53-// terminal's detected color capability (AttachmentEnvironment.TrueColor); the54+// terminal's detected color capability (AttachmentEnvironment.Color); the
54 // picker renders indexed colors without it.55 // picker renders indexed colors without it.
55-func NewPicker(clock ports.Clock, freshness time.Duration, trueColor bool) *Picker {56+func NewPicker(clock ports.Clock, freshness time.Duration, color terminalcap.ColorCapabilities) *Picker {
56- return &Picker{controller: newPickerController(clock, freshness, trueColor)}57+ return &Picker{controller: newPickerController(clock, freshness, color)}
57 }58 }
58 59
59 // ApplySnapshot forwards a broker publication to the supervisor-owned picker.60 // ApplySnapshot forwards a broker publication to the supervisor-owned picker.
M internal/usecase/client/picker_composition_external_test.go +2 -1
@@ -7,6 +7,7 @@ import (
77 "github.com/stretchr/testify/require"
88
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/terminalcap"
1011 "github.com/bnema/vev/internal/ports"
1112 "github.com/bnema/vev/internal/protocol"
1213 "github.com/bnema/vev/internal/protocol/catalogue"
@@ -19,7 +20,7 @@ import (
1920 // so it could never resolve a selection into an attachment.
2021 func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) {
2122 geometry := domain.Size{Cols: 80, Rows: 24}
22- picker := client.NewPicker(nil, 0, true)
23+ picker := client.NewPicker(nil, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
2324 require.NotNil(t, picker)
2425
2526 // The exact assignment an app composition performs.
@@ -7,6 +7,7 @@ import (
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/terminalcap"
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"12 "github.com/bnema/vev/internal/protocol"
12 "github.com/bnema/vev/internal/protocol/catalogue"13 "github.com/bnema/vev/internal/protocol/catalogue"
@@ -19,7 +20,7 @@ import (
19 // so it could never resolve a selection into an attachment.20 // so it could never resolve a selection into an attachment.
20 func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) {21 func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) {
21 geometry := domain.Size{Cols: 80, Rows: 24}22 geometry := domain.Size{Cols: 80, Rows: 24}
22- picker := client.NewPicker(nil, 0, true)23+ picker := client.NewPicker(nil, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
23 require.NotNil(t, picker)24 require.NotNil(t, picker)
24 25
25 // The exact assignment an app composition performs.26 // The exact assignment an app composition performs.
M internal/usecase/client/picker_controller.go +4 -2
@@ -8,9 +8,11 @@ import (
88 "time"
99
1010 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/domain/terminalcap"
1112 "github.com/bnema/vev/internal/ports"
1213 "github.com/bnema/vev/internal/protocol"
1314 "github.com/bnema/vev/internal/protocol/catalogue"
15+ "github.com/bnema/vev/internal/usecase/colorprofile"
1416 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
1517 "github.com/bnema/vev/internal/usecase/ui"
1618 )
@@ -123,14 +125,14 @@ type pickerController struct {
123125 // newPickerController builds a picker over an empty catalogue. The picker owns
124126 // input from the start: the controller presents it for the whole picker presentation and
125127 // has no session pipeline to defer to.
126-func newPickerController(clock ports.Clock, freshness time.Duration, trueColor bool) *pickerController {
128+func newPickerController(clock ports.Clock, freshness time.Duration, color terminalcap.ColorCapabilities) *pickerController {
127129 if supervisorNil(clock) {
128130 clock = systemClock{}
129131 }
130132 controller := &pickerController{
131133 clock: clock,
132134 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
133- renderer: newPickerRenderer(pickerColorProfile(trueColor)),
135+ renderer: newPickerRenderer(colorprofile.Profile(color)),
134136 sort: defaultPickerSort(),
135137 notices: ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}),
136138 ownsInput: true,
@@ -8,9 +8,11 @@ import (
8 "time"8 "time"
9 9
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/domain/terminalcap"
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
12 "github.com/bnema/vev/internal/protocol"13 "github.com/bnema/vev/internal/protocol"
13 "github.com/bnema/vev/internal/protocol/catalogue"14 "github.com/bnema/vev/internal/protocol/catalogue"
15+ "github.com/bnema/vev/internal/usecase/colorprofile"
14 pickerusecase "github.com/bnema/vev/internal/usecase/picker"16 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
15 "github.com/bnema/vev/internal/usecase/ui"17 "github.com/bnema/vev/internal/usecase/ui"
16 )18 )
@@ -123,14 +125,14 @@ type pickerController struct {
123 // newPickerController builds a picker over an empty catalogue. The picker owns125 // newPickerController builds a picker over an empty catalogue. The picker owns
124 // input from the start: the controller presents it for the whole picker presentation and126 // input from the start: the controller presents it for the whole picker presentation and
125 // has no session pipeline to defer to.127 // has no session pipeline to defer to.
126-func newPickerController(clock ports.Clock, freshness time.Duration, trueColor bool) *pickerController {128+func newPickerController(clock ports.Clock, freshness time.Duration, color terminalcap.ColorCapabilities) *pickerController {
127 if supervisorNil(clock) {129 if supervisorNil(clock) {
128 clock = systemClock{}130 clock = systemClock{}
129 }131 }
130 controller := &pickerController{132 controller := &pickerController{
131 clock: clock,133 clock: clock,
132 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),134 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
133- renderer: newPickerRenderer(pickerColorProfile(trueColor)),135+ renderer: newPickerRenderer(colorprofile.Profile(color)),
134 sort: defaultPickerSort(),136 sort: defaultPickerSort(),
135 notices: ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}),137 notices: ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}),
136 ownsInput: true,138 ownsInput: true,
M internal/usecase/client/picker_controller_test.go +4 -3
@@ -12,6 +12,7 @@ import (
1212 "github.com/stretchr/testify/require"
1313
1414 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/terminalcap"
1516 "github.com/bnema/vev/internal/ports"
1617 "github.com/bnema/vev/internal/protocol/catalogue"
1718 "github.com/bnema/vev/internal/usecase/ui"
@@ -118,7 +119,7 @@ func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision,
118119 func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) {
119120 t.Helper()
120121 clock := newSupervisorTestClock()
121- return newPickerController(clock, pickerTestFreshness, true), clock
122+ return newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}), clock
122123 }
123124
124125 func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string {
@@ -428,7 +429,7 @@ func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) {
428429 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
429430 return service, nil
430431 })
431- controller := newPickerController(clock, pickerTestFreshness, true)
432+ controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
432433 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
433434
434435 runErr := make(chan error, 1)
@@ -473,7 +474,7 @@ func TestSupervisorPickerFoldsLaterPublication(t *testing.T) {
473474 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
474475 return service, nil
475476 })
476- controller := newPickerController(clock, pickerTestFreshness, true)
477+ controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
477478 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
478479
479480 runErr := make(chan error, 1)
@@ -12,6 +12,7 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/terminalcap"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/protocol/catalogue"17 "github.com/bnema/vev/internal/protocol/catalogue"
17 "github.com/bnema/vev/internal/usecase/ui"18 "github.com/bnema/vev/internal/usecase/ui"
@@ -118,7 +119,7 @@ func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision,
118 func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) {119 func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) {
119 t.Helper()120 t.Helper()
120 clock := newSupervisorTestClock()121 clock := newSupervisorTestClock()
121- return newPickerController(clock, pickerTestFreshness, true), clock122+ return newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}), clock
122 }123 }
123 124
124 func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string {125 func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string {
@@ -428,7 +429,7 @@ func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) {
428 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))429 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
429 return service, nil430 return service, nil
430 })431 })
431- controller := newPickerController(clock, pickerTestFreshness, true)432+ controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
432 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})433 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
433 434
434 runErr := make(chan error, 1)435 runErr := make(chan error, 1)
@@ -473,7 +474,7 @@ func TestSupervisorPickerFoldsLaterPublication(t *testing.T) {
473 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))474 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
474 return service, nil475 return service, nil
475 })476 })
476- controller := newPickerController(clock, pickerTestFreshness, true)477+ controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
477 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})478 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
478 479
479 runErr := make(chan error, 1)480 runErr := make(chan error, 1)
M internal/usecase/client/picker_interaction.go +0 -10
@@ -340,16 +340,6 @@ func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer {
340340 return &pickerRenderer{profile: profile}
341341 }
342342
343-// pickerColorProfile maps the terminal's detected color capability to the
344-// picker renderer profile. A terminal without truecolor gets indexed colors,
345-// so RGB surfaces are quantized instead of dropped (#280).
346-func pickerColorProfile(trueColor bool) ansirenderer.ColorProfile {
347- if trueColor {
348- return ansirenderer.ColorProfileTrueColor
349- }
350- return ansirenderer.ColorProfileANSI256
351-}
352-
353343 // render composes the loop model into terminal bytes for one display
354344 // refresh. Previews arrive as source data (picker_preview.go) and are attached
355345 // to the model by the caller; a full redraw every refresh keeps the shadow
@@ -340,16 +340,6 @@ func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer {
340 return &pickerRenderer{profile: profile}340 return &pickerRenderer{profile: profile}
341 }341 }
342 342
343-// pickerColorProfile maps the terminal's detected color capability to the
344-// picker renderer profile. A terminal without truecolor gets indexed colors,
345-// so RGB surfaces are quantized instead of dropped (#280).
346-func pickerColorProfile(trueColor bool) ansirenderer.ColorProfile {
347- if trueColor {
348- return ansirenderer.ColorProfileTrueColor
349- }
350- return ansirenderer.ColorProfileANSI256
351-}
352-
353 // render composes the loop model into terminal bytes for one display343 // render composes the loop model into terminal bytes for one display
354 // refresh. Previews arrive as source data (picker_preview.go) and are attached344 // refresh. Previews arrive as source data (picker_preview.go) and are attached
355 // to the model by the caller; a full redraw every refresh keeps the shadow345 // to the model by the caller; a full redraw every refresh keeps the shadow
M internal/usecase/client/picker_interaction_test.go +11 -10
@@ -8,6 +8,7 @@ import (
88 renderer "github.com/bnema/vev-vt"
99 ansirenderer "github.com/bnema/vev-vt/ansi"
1010 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/domain/terminalcap"
1112 "github.com/bnema/vev/internal/protocol"
1213 "github.com/bnema/vev/internal/usecase/picker"
1314 "github.com/stretchr/testify/require"
@@ -73,26 +74,26 @@ func TestPickerRenderersFollowTerminalColorProfile(t *testing.T) {
7374 Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Tree: rgbPickerStyle(), Status: rgbPickerStyle(),
7475 SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(),
7576 }
76- renderers := map[string]func(trueColor bool) *pickerRenderer{
77- "session picker": func(trueColor bool) *pickerRenderer {
78- return NewPicker(nil, 0, trueColor).controller.renderer
77+ renderers := map[string]func(color terminalcap.ColorCapabilities) *pickerRenderer{
78+ "session picker": func(color terminalcap.ColorCapabilities) *pickerRenderer {
79+ return NewPicker(nil, 0, color).controller.renderer
7980 },
80- "move picker": func(trueColor bool) *pickerRenderer {
81- return newMovePickerOverlay(trueColor).renderer
81+ "move picker": func(color terminalcap.ColorCapabilities) *pickerRenderer {
82+ return newMovePickerOverlay(color).renderer
8283 },
8384 }
8485 for name, build := range renderers {
8586 for _, tc := range []struct {
86- trueColor bool
87+ color terminalcap.ColorCapabilities
8788 profile ansirenderer.ColorProfile
8889 want string
8990 forbidden string
9091 }{
91- {trueColor: false, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`},
92- {trueColor: true, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`},
92+ {color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`},
93+ {color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`},
9394 } {
94- t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.trueColor), func(t *testing.T) {
95- r := build(tc.trueColor)
95+ t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.color.RGB()), func(t *testing.T) {
96+ r := build(tc.color)
9697 require.Equal(t, tc.profile, r.profile)
9798 r.renderStyles = []picker.RenderStyles{styles}
9899 output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
@@ -8,6 +8,7 @@ import (
8 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
9 ansirenderer "github.com/bnema/vev-vt/ansi"9 ansirenderer "github.com/bnema/vev-vt/ansi"
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/domain/terminalcap"
11 "github.com/bnema/vev/internal/protocol"12 "github.com/bnema/vev/internal/protocol"
12 "github.com/bnema/vev/internal/usecase/picker"13 "github.com/bnema/vev/internal/usecase/picker"
13 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
@@ -73,26 +74,26 @@ func TestPickerRenderersFollowTerminalColorProfile(t *testing.T) {
73 Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Tree: rgbPickerStyle(), Status: rgbPickerStyle(),74 Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Tree: rgbPickerStyle(), Status: rgbPickerStyle(),
74 SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(),75 SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(),
75 }76 }
76- renderers := map[string]func(trueColor bool) *pickerRenderer{77+ renderers := map[string]func(color terminalcap.ColorCapabilities) *pickerRenderer{
77- "session picker": func(trueColor bool) *pickerRenderer {78+ "session picker": func(color terminalcap.ColorCapabilities) *pickerRenderer {
78- return NewPicker(nil, 0, trueColor).controller.renderer79+ return NewPicker(nil, 0, color).controller.renderer
79 },80 },
80- "move picker": func(trueColor bool) *pickerRenderer {81+ "move picker": func(color terminalcap.ColorCapabilities) *pickerRenderer {
81- return newMovePickerOverlay(trueColor).renderer82+ return newMovePickerOverlay(color).renderer
82 },83 },
83 }84 }
84 for name, build := range renderers {85 for name, build := range renderers {
85 for _, tc := range []struct {86 for _, tc := range []struct {
86- trueColor bool87+ color terminalcap.ColorCapabilities
87 profile ansirenderer.ColorProfile88 profile ansirenderer.ColorProfile
88 want string89 want string
89 forbidden string90 forbidden string
90 }{91 }{
91- {trueColor: false, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`},92+ {color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`},
92- {trueColor: true, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`},93+ {color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`},
93 } {94 } {
94- t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.trueColor), func(t *testing.T) {95+ t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.color.RGB()), func(t *testing.T) {
95- r := build(tc.trueColor)96+ r := build(tc.color)
96 require.Equal(t, tc.profile, r.profile)97 require.Equal(t, tc.profile, r.profile)
97 r.renderStyles = []picker.RenderStyles{styles}98 r.renderStyles = []picker.RenderStyles{styles}
98 output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))99 output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
M internal/usecase/client/supervisor.go +6 -3
@@ -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 "github.com/bnema/vev/internal/usecase/keys/kittykey"
1819 )
@@ -382,9 +383,11 @@ type SupervisorConfig struct {
382383 // AttachmentEnvironment is the composition seam for client environment data
383384 // carried by attachment Hello messages.
384385 type AttachmentEnvironment struct {
385- TermEnv string
386- Cwd string
387- TrueColor bool
386+ TermEnv string
387+ Cwd string
388+ // Color is the terminal's detected color capability. The zero value is
389+ // TrueColor; composition fills it from terminal detection.
390+ Color terminalcap.ColorCapabilities
388391 // PID is this client's process ID, sent on local attaches only.
389392 PID uint32
390393 // ProbeTerminal enables the one bounded capability probe of a real outer
@@ -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 "github.com/bnema/vev/internal/usecase/keys/kittykey"18 "github.com/bnema/vev/internal/usecase/keys/kittykey"
18 )19 )
@@ -382,9 +383,11 @@ type SupervisorConfig struct {
382 // AttachmentEnvironment is the composition seam for client environment data383 // AttachmentEnvironment is the composition seam for client environment data
383 // carried by attachment Hello messages.384 // carried by attachment Hello messages.
384 type AttachmentEnvironment struct {385 type AttachmentEnvironment struct {
385- TermEnv string386+ TermEnv string
386- Cwd string387+ Cwd string
387- TrueColor bool388+ // Color is the terminal's detected color capability. The zero value is
389+ // TrueColor; composition fills it from terminal detection.
390+ Color terminalcap.ColorCapabilities
388 // PID is this client's process ID, sent on local attaches only.391 // PID is this client's process ID, sent on local attaches only.
389 PID uint32392 PID uint32
390 // ProbeTerminal enables the one bounded capability probe of a real outer393 // ProbeTerminal enables the one bounded capability probe of a real outer
M internal/usecase/client/supervisor_attach.go +1 -1
@@ -347,7 +347,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
347347 Geometry: geometry,
348348 TermEnv: environment.TermEnv,
349349 Cwd: environment.Cwd,
350- TrueColor: environment.TrueColor,
350+ Color: environment.Color,
351351 PID: environment.PID,
352352 Capabilities: s.capabilities,
353353 SessionEnvironment: sessionEnv,
@@ -347,7 +347,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
347 Geometry: geometry,347 Geometry: geometry,
348 TermEnv: environment.TermEnv,348 TermEnv: environment.TermEnv,
349 Cwd: environment.Cwd,349 Cwd: environment.Cwd,
350- TrueColor: environment.TrueColor,350+ Color: environment.Color,
351 PID: environment.PID,351 PID: environment.PID,
352 Capabilities: s.capabilities,352 Capabilities: s.capabilities,
353 SessionEnvironment: sessionEnv,353 SessionEnvironment: sessionEnv,
M internal/usecase/client/supervisor_attach_test.go +2 -1
@@ -14,6 +14,7 @@ import (
1414 "github.com/stretchr/testify/require"
1515
1616 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/terminalcap"
1718 "github.com/bnema/vev/internal/ports"
1819 "github.com/bnema/vev/internal/protocol"
1920 )
@@ -1634,7 +1635,7 @@ func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) {
16341635 return admitted, nil
16351636 })
16361637
1637- controller := newPickerController(clock, pickerTestFreshness, true)
1638+ controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
16381639 connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil })
16391640 sup := mustSupervisor(t, SupervisorConfig{
16401641 Connector: connector,
@@ -14,6 +14,7 @@ import (
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/terminalcap"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
18 "github.com/bnema/vev/internal/protocol"19 "github.com/bnema/vev/internal/protocol"
19 )20 )
@@ -1634,7 +1635,7 @@ func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) {
1634 return admitted, nil1635 return admitted, nil
1635 })1636 })
1636 1637
1637- controller := newPickerController(clock, pickerTestFreshness, true)1638+ controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
1638 connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil })1639 connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil })
1639 sup := mustSupervisor(t, SupervisorConfig{1640 sup := mustSupervisor(t, SupervisorConfig{
1640 Connector: connector,1641 Connector: connector,
M internal/usecase/client/supervisor_test.go +3 -2
@@ -14,6 +14,7 @@ import (
1414 "github.com/stretchr/testify/require"
1515
1616 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/terminalcap"
1718 "github.com/bnema/vev/internal/ports"
1819 )
1920
@@ -715,7 +716,7 @@ func TestSupervisorPickerPublicationRequestsRepaint(t *testing.T) {
715716 }),
716717 Terminal: newSupervisorTestTerminal(reader),
717718 Clock: clock,
718- Picker: newPickerController(clock, pickerTestFreshness, true),
719+ Picker: newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}),
719720 Render: func(state State) {
720721 renders <- state
721722 },
@@ -1494,7 +1495,7 @@ func TestSupervisorPickerCloseWhileOffline(t *testing.T) {
14941495 })
14951496 s := mustSupervisor(t, SupervisorConfig{
14961497 Connector: connector, Terminal: terminal, Clock: clock,
1497- Picker: NewPicker(clock, 0, true),
1498+ Picker: NewPicker(clock, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}),
14981499 Notify: func(State, error) { reached <- struct{}{} },
14991500 })
15001501 ctx, cancel := context.WithCancel(context.Background())
@@ -14,6 +14,7 @@ import (
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/terminalcap"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
18 )19 )
19 20
@@ -715,7 +716,7 @@ func TestSupervisorPickerPublicationRequestsRepaint(t *testing.T) {
715 }),716 }),
716 Terminal: newSupervisorTestTerminal(reader),717 Terminal: newSupervisorTestTerminal(reader),
717 Clock: clock,718 Clock: clock,
718- Picker: newPickerController(clock, pickerTestFreshness, true),719+ Picker: newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}),
719 Render: func(state State) {720 Render: func(state State) {
720 renders <- state721 renders <- state
721 },722 },
@@ -1494,7 +1495,7 @@ func TestSupervisorPickerCloseWhileOffline(t *testing.T) {
1494 })1495 })
1495 s := mustSupervisor(t, SupervisorConfig{1496 s := mustSupervisor(t, SupervisorConfig{
1496 Connector: connector, Terminal: terminal, Clock: clock,1497 Connector: connector, Terminal: terminal, Clock: clock,
1497- Picker: NewPicker(clock, 0, true),1498+ Picker: NewPicker(clock, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}),
1498 Notify: func(State, error) { reached <- struct{}{} },1499 Notify: func(State, error) { reached <- struct{}{} },
1499 })1500 })
1500 ctx, cancel := context.WithCancel(context.Background())1501 ctx, cancel := context.WithCancel(context.Background())
A internal/usecase/colorprofile/profile.go +18 -0
@@ -0,0 +1,18 @@
1+// Package colorprofile maps terminal color capabilities to the vev-vt renderer
2+// color profile. It is the single mode-to-profile policy shared by the client
3+// and the daemon.
4+package colorprofile
5+
6+import (
7+ ansi "github.com/bnema/vev-vt/ansi"
8+ "github.com/bnema/vev/internal/domain/terminalcap"
9+)
10+
11+// Profile returns the renderer profile for c. A terminal without RGB support
12+// gets indexed colors, so RGB surfaces are quantized instead of dropped.
13+func Profile(c terminalcap.ColorCapabilities) ansi.ColorProfile {
14+ if c.RGB() {
15+ return ansi.ColorProfileTrueColor
16+ }
17+ return ansi.ColorProfileANSI256
18+}
@@ -0,0 +1,18 @@
1+// Package colorprofile maps terminal color capabilities to the vev-vt renderer
2+// color profile. It is the single mode-to-profile policy shared by the client
3+// and the daemon.
4+package colorprofile
5+
6+import (
7+ ansi "github.com/bnema/vev-vt/ansi"
8+ "github.com/bnema/vev/internal/domain/terminalcap"
9+)
10+
11+// Profile returns the renderer profile for c. A terminal without RGB support
12+// gets indexed colors, so RGB surfaces are quantized instead of dropped.
13+func Profile(c terminalcap.ColorCapabilities) ansi.ColorProfile {
14+ if c.RGB() {
15+ return ansi.ColorProfileTrueColor
16+ }
17+ return ansi.ColorProfileANSI256
18+}
A internal/usecase/colorprofile/profile_test.go +26 -0
@@ -0,0 +1,26 @@
1+package colorprofile
2+
3+import (
4+ "testing"
5+
6+ ansi "github.com/bnema/vev-vt/ansi"
7+ "github.com/bnema/vev/internal/domain/terminalcap"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestProfile(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ in terminalcap.ColorCapabilities
15+ want ansi.ColorProfile
16+ }{
17+ {name: "zero value is truecolor", in: terminalcap.ColorCapabilities{}, want: ansi.ColorProfileTrueColor},
18+ {name: "truecolor", in: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, want: ansi.ColorProfileTrueColor},
19+ {name: "ansi256", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, want: ansi.ColorProfileANSI256},
20+ }
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ require.Equal(t, tt.want, Profile(tt.in))
24+ })
25+ }
26+}
@@ -0,0 +1,26 @@
1+package colorprofile
2+
3+import (
4+ "testing"
5+
6+ ansi "github.com/bnema/vev-vt/ansi"
7+ "github.com/bnema/vev/internal/domain/terminalcap"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestProfile(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ in terminalcap.ColorCapabilities
15+ want ansi.ColorProfile
16+ }{
17+ {name: "zero value is truecolor", in: terminalcap.ColorCapabilities{}, want: ansi.ColorProfileTrueColor},
18+ {name: "truecolor", in: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, want: ansi.ColorProfileTrueColor},
19+ {name: "ansi256", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, want: ansi.ColorProfileANSI256},
20+ }
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ require.Equal(t, tt.want, Profile(tt.in))
24+ })
25+ }
26+}
M internal/usecase/daemon/attachment_output.go +2 -5
@@ -10,6 +10,7 @@ import (
1010 "github.com/bnema/vev/internal/domain"
1111 "github.com/bnema/vev/internal/domain/terminalcap"
1212 "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/usecase/colorprofile"
1314 )
1415
1516 // attachmentOutput owns the terminal-output dependency chain and emitted
@@ -102,11 +103,7 @@ func newOutputStateStream(windowSize ...uint8) *attachmentOutput {
102103 }
103104
104105 func newOutputStateStreamForCapabilities(capabilities terminalcap.Capabilities, windowSize ...uint8) *attachmentOutput {
105- profile := renderer.ColorProfileANSI256
106- if capabilities.TrueColor() {
107- profile = renderer.ColorProfileTrueColor
108- }
109- return newOutputStateStreamForProfile(profile, windowSize...)
106+ return newOutputStateStreamForProfile(colorprofile.Profile(capabilities.Color), windowSize...)
110107 }
111108
112109 func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *attachmentOutput {
@@ -10,6 +10,7 @@ import (
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/domain/terminalcap"11 "github.com/bnema/vev/internal/domain/terminalcap"
12 "github.com/bnema/vev/internal/protocol"12 "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/usecase/colorprofile"
13 )14 )
14 15
15 // attachmentOutput owns the terminal-output dependency chain and emitted16 // attachmentOutput owns the terminal-output dependency chain and emitted
@@ -102,11 +103,7 @@ func newOutputStateStream(windowSize ...uint8) *attachmentOutput {
102 }103 }
103 104
104 func newOutputStateStreamForCapabilities(capabilities terminalcap.Capabilities, windowSize ...uint8) *attachmentOutput {105 func newOutputStateStreamForCapabilities(capabilities terminalcap.Capabilities, windowSize ...uint8) *attachmentOutput {
105- profile := renderer.ColorProfileANSI256106+ return newOutputStateStreamForProfile(colorprofile.Profile(capabilities.Color), windowSize...)
106- if capabilities.TrueColor() {
107- profile = renderer.ColorProfileTrueColor
108- }
109- return newOutputStateStreamForProfile(profile, windowSize...)
110 }107 }
111 108
112 func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *attachmentOutput {109 func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *attachmentOutput {
M internal/usecase/daemon/attachment_output_test.go +2 -2
@@ -59,12 +59,12 @@ func TestAttachmentOutputUsesTerminalColorProfile(t *testing.T) {
5959 }{
6060 {
6161 name: "truecolor",
62- capabilities: terminalcap.Capabilities{ColorMode: terminalcap.TrueColor},
62+ capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
6363 want: "\x1b[1;1H\x1b[0;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0mX\x1b[0m",
6464 },
6565 {
6666 name: "indexed 256",
67- capabilities: terminalcap.Capabilities{ColorMode: terminalcap.Indexed256},
67+ capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
6868 want: "\x1b[1;1H\x1b[0;38;5;196;48;5;21;58;5;46mX\x1b[0m",
6969 },
7070 }
@@ -59,12 +59,12 @@ func TestAttachmentOutputUsesTerminalColorProfile(t *testing.T) {
59 }{59 }{
60 {60 {
61 name: "truecolor",61 name: "truecolor",
62- capabilities: terminalcap.Capabilities{ColorMode: terminalcap.TrueColor},62+ capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
63 want: "\x1b[1;1H\x1b[0;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0mX\x1b[0m",63 want: "\x1b[1;1H\x1b[0;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0mX\x1b[0m",
64 },64 },
65 {65 {
66 name: "indexed 256",66 name: "indexed 256",
67- capabilities: terminalcap.Capabilities{ColorMode: terminalcap.Indexed256},67+ capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
68 want: "\x1b[1;1H\x1b[0;38;5;196;48;5;21;58;5;46mX\x1b[0m",68 want: "\x1b[1;1H\x1b[0;38;5;196;48;5;21;58;5;46mX\x1b[0m",
69 },69 },
70 }70 }
M internal/usecase/daemon/client.go +4 -2
@@ -696,7 +696,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domai
696696 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
697697 d.touchMRU(sess)
698698 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
699- if ac.terminalCapabilities.ColorSource == terminalcap.SourceDeclared && !ac.terminalCapabilities.TrueColor() {
699+ if ac.terminalCapabilities.Color.Source == terminalcap.SourceDeclared && !ac.terminalCapabilities.Color.RGB() {
700700 d.publishToast(ac, domain.Notification{
701701 Code: domain.NoticeUser,
702702 Severity: domain.NoticeWarn,
@@ -721,7 +721,9 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
721721 resumeToken = d.nextResumeTokenLocked()
722722 }
723723 if !opts.capabilitiesSet {
724- opts.terminalCapabilities = terminalcap.Capabilities{ColorMode: terminalcap.TrueColor}
724+ // The zero value is TrueColor: manually constructed attachments keep
725+ // the historical renderer behavior.
726+ opts.terminalCapabilities = terminalcap.Capabilities{}
725727 }
726728 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)
727729 geometry = geometry.NormalizePixels()
@@ -696,7 +696,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domai
696 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {696 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
697 d.touchMRU(sess)697 d.touchMRU(sess)
698 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)698 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
699- if ac.terminalCapabilities.ColorSource == terminalcap.SourceDeclared && !ac.terminalCapabilities.TrueColor() {699+ if ac.terminalCapabilities.Color.Source == terminalcap.SourceDeclared && !ac.terminalCapabilities.Color.RGB() {
700 d.publishToast(ac, domain.Notification{700 d.publishToast(ac, domain.Notification{
701 Code: domain.NoticeUser,701 Code: domain.NoticeUser,
702 Severity: domain.NoticeWarn,702 Severity: domain.NoticeWarn,
@@ -721,7 +721,9 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
721 resumeToken = d.nextResumeTokenLocked()721 resumeToken = d.nextResumeTokenLocked()
722 }722 }
723 if !opts.capabilitiesSet {723 if !opts.capabilitiesSet {
724- opts.terminalCapabilities = terminalcap.Capabilities{ColorMode: terminalcap.TrueColor}724+ // The zero value is TrueColor: manually constructed attachments keep
725+ // the historical renderer behavior.
726+ opts.terminalCapabilities = terminalcap.Capabilities{}
725 }727 }
726 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)728 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)
727 geometry = geometry.NormalizePixels()729 geometry = geometry.NormalizePixels()
M internal/usecase/daemon/daemon.go +1 -5
@@ -1477,15 +1477,11 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
14771477 // otherwise falls back to the session's normal first-tab repair.
14781478 initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)
14791479 }
1480- terminalCapabilities := terminalcap.Detect(h.Env)
1480+ terminalCapabilities := terminalcap.Resolve(h.Env, h.TrueColor)
14811481 // Kitty graphics are enabled only by the explicit direct-terminal
14821482 // declaration in Hello. Environment values remain useful for color and
14831483 // diagnostics, but cannot authorize terminal-global graphics side effects.
14841484 terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
1485- if h.TrueColor && !terminalCapabilities.TrueColor() {
1486- terminalCapabilities.ColorMode = terminalcap.TrueColor
1487- terminalCapabilities.ColorSource = terminalcap.SourceDeclared
1488- }
14891485 opts := attachClientOptions{
14901486 clientID: h.ClientID,
14911487 kittyKeyboard: h.KittyKeyboard,
@@ -1477,15 +1477,11 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
1477 // otherwise falls back to the session's normal first-tab repair.1477 // otherwise falls back to the session's normal first-tab repair.
1478 initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)1478 initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)
1479 }1479 }
1480- terminalCapabilities := terminalcap.Detect(h.Env)1480+ terminalCapabilities := terminalcap.Resolve(h.Env, h.TrueColor)
1481 // Kitty graphics are enabled only by the explicit direct-terminal1481 // Kitty graphics are enabled only by the explicit direct-terminal
1482 // declaration in Hello. Environment values remain useful for color and1482 // declaration in Hello. Environment values remain useful for color and
1483 // diagnostics, but cannot authorize terminal-global graphics side effects.1483 // diagnostics, but cannot authorize terminal-global graphics side effects.
1484 terminalCapabilities.KittyGraphics = h.KittyDirectGraphics1484 terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
1485- if h.TrueColor && !terminalCapabilities.TrueColor() {
1486- terminalCapabilities.ColorMode = terminalcap.TrueColor
1487- terminalCapabilities.ColorSource = terminalcap.SourceDeclared
1488- }
1489 opts := attachClientOptions{1485 opts := attachClientOptions{
1490 clientID: h.ClientID,1486 clientID: h.ClientID,
1491 kittyKeyboard: h.KittyKeyboard,1487 kittyKeyboard: h.KittyKeyboard,
M internal/usecase/daemon/graphics_output_test.go +1 -1
@@ -510,7 +510,7 @@ func TestGraphicsNamespacePoolExhaustionFallsBackToText(t *testing.T) {
510510 domain.Geometry{Size: defaultSize},
511511 attachClientOptions{
512512 capabilitiesSet: true,
513- terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, ColorMode: terminalcap.TrueColor},
513+ terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
514514 },
515515 )
516516 d.mu.Unlock()
@@ -510,7 +510,7 @@ func TestGraphicsNamespacePoolExhaustionFallsBackToText(t *testing.T) {
510 domain.Geometry{Size: defaultSize},510 domain.Geometry{Size: defaultSize},
511 attachClientOptions{511 attachClientOptions{
512 capabilitiesSet: true,512 capabilitiesSet: true,
513- terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, ColorMode: terminalcap.TrueColor},513+ terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
514 },514 },
515 )515 )
516 d.mu.Unlock()516 d.mu.Unlock()

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