detached 4b57b07

feat(protocol): carry color capabilities in Hello

Replace Hello.TrueColor and Theme.TrueColor with Hello.color, a
ColorCapabilities{mode, source} message (field 26; Hello 12 and Theme 5
reserved). Add ANSI16 and Monochrome modes and the Forced source to
terminalcap, map all four modes in colorprofile.Profile, and make
terminalcap.Resolve take the declared capability: declared and forced claims
are used verbatim, anything else falls back to detection. The daemon
downgrade toast names the actual mode and is suppressed for forced modes.
sessionwire rejects a missing color, an UNSPECIFIED mode, or unknown enum
values. Bump protocol.Version to 65.

The usecase theme.Theme TrueColor field was transport-only and is dropped in
the same change.
commit
4b57b07c6d492a944814f50bd4e83742ecc2d216
author
brice · · 2026-10-03 12:28 UTC
committer
brice · · 2026-10-03 14:14 UTC
parent
0103d51
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
51 files · +785 -205
M buf.yaml +4 -0
@@ -18,5 +18,9 @@ breaking:
1818 ignore_only:
1919 # Protocol v58 intentionally replaces CommandResult.ok with a closed
2020 # outcome. Strict version equality prevents cross-version decoding.
21+ # Protocol v65 replaces the Hello and Theme true_color bools with
22+ # Hello.color capabilities under the same strict-equality rule.
2123 FIELD_NO_DELETE:
2224 - internal/protocol/wire/schema/command.proto
25+ - internal/protocol/wire/schema/session.proto
26+ - internal/protocol/wire/schema/terminal.proto
@@ -18,5 +18,9 @@ breaking:
18 ignore_only:18 ignore_only:
19 # Protocol v58 intentionally replaces CommandResult.ok with a closed19 # Protocol v58 intentionally replaces CommandResult.ok with a closed
20 # outcome. Strict version equality prevents cross-version decoding.20 # outcome. Strict version equality prevents cross-version decoding.
21+ # Protocol v65 replaces the Hello and Theme true_color bools with
22+ # Hello.color capabilities under the same strict-equality rule.
21 FIELD_NO_DELETE:23 FIELD_NO_DELETE:
22 - internal/protocol/wire/schema/command.proto24 - internal/protocol/wire/schema/command.proto
25+ - internal/protocol/wire/schema/session.proto
26+ - internal/protocol/wire/schema/terminal.proto
M docs/protocol.md +10 -0
@@ -88,6 +88,16 @@ constant or the request fails
8888 with the version-mismatch response above. There is no multi-version
8989 negotiation: equality is mandatory.
9090
91+`Hello.color` (`ColorCapabilities{mode, source}`, field 26) is the only carrier
92+of terminal color capability; `Theme` has no capability field. `mode` is a
93+closed enum (`TRUE_COLOR`, `ANSI256`, `ANSI16`, `MONOCHROME`) and a missing
94+message, an `UNSPECIFIED` mode, or any unknown enum value is a malformed Hello
95+(`sessionwire` rejects it before any mutation). `source` `UNSPECIFIED` means
96+"unknown": the daemon then ignores the claimed mode and re-detects from
97+`Hello.Env`; `DECLARED` and `FORCED` claims are used verbatim.
98+(`internal/domain/terminalcap.Resolve`; wire tests in
99+`sessionwire/proto_convert_test.go:TestProtoHelloColorWire`.)
100+
91101 Command requests additionally carry a 10-second result deadline
92102 (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per
93103 connection and correlated by `RequestID`. Every dispatched request produces
@@ -88,6 +88,16 @@ constant or the request fails
88 with the version-mismatch response above. There is no multi-version88 with the version-mismatch response above. There is no multi-version
89 negotiation: equality is mandatory.89 negotiation: equality is mandatory.
90 90
91+`Hello.color` (`ColorCapabilities{mode, source}`, field 26) is the only carrier
92+of terminal color capability; `Theme` has no capability field. `mode` is a
93+closed enum (`TRUE_COLOR`, `ANSI256`, `ANSI16`, `MONOCHROME`) and a missing
94+message, an `UNSPECIFIED` mode, or any unknown enum value is a malformed Hello
95+(`sessionwire` rejects it before any mutation). `source` `UNSPECIFIED` means
96+"unknown": the daemon then ignores the claimed mode and re-detects from
97+`Hello.Env`; `DECLARED` and `FORCED` claims are used verbatim.
98+(`internal/domain/terminalcap.Resolve`; wire tests in
99+`sessionwire/proto_convert_test.go:TestProtoHelloColorWire`.)
100+
91 Command requests additionally carry a 10-second result deadline101 Command requests additionally carry a 10-second result deadline
92 (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per102 (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per
93 connection and correlated by `RequestID`. Every dispatched request produces103 connection and correlated by `RequestID`. Every dispatched request produces
M internal/adapters/sessionwire/client_test.go +1 -1
@@ -26,7 +26,7 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
2626 {name: "ping", message: protocol.Ping{}},
2727 {name: "list", message: protocol.List{}},
2828 {name: "kill", message: protocol.Kill{RequestID: 1, Name: "work"}},
29- {name: "theme", message: protocol.Theme{TrueColor: true}},
29+ {name: "theme", message: protocol.Theme{SchemeKnown: true}},
3030 {name: "ack", message: protocol.Ack{Epoch: 1, State: 1}},
3131 {name: "image", message: protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}}},
3232 {name: "notice", message: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}},
@@ -26,7 +26,7 @@ func TestClientConnectionEncodesEveryClientMessage(t *testing.T) {
26 {name: "ping", message: protocol.Ping{}},26 {name: "ping", message: protocol.Ping{}},
27 {name: "list", message: protocol.List{}},27 {name: "list", message: protocol.List{}},
28 {name: "kill", message: protocol.Kill{RequestID: 1, Name: "work"}},28 {name: "kill", message: protocol.Kill{RequestID: 1, Name: "work"}},
29- {name: "theme", message: protocol.Theme{TrueColor: true}},29+ {name: "theme", message: protocol.Theme{SchemeKnown: true}},
30 {name: "ack", message: protocol.Ack{Epoch: 1, State: 1}},30 {name: "ack", message: protocol.Ack{Epoch: 1, State: 1}},
31 {name: "image", message: protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}}},31 {name: "image", message: protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}}},
32 {name: "notice", message: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}},32 {name: "notice", message: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}},
M internal/adapters/sessionwire/proto_convert_misc.go +0 -2
@@ -69,7 +69,6 @@ func themeToWire(message protocol.Theme) *wire.Theme {
6969 Foreground: rgbToWire(message.Foreground),
7070 HasBackground: message.HasBackground,
7171 Background: rgbToWire(message.Background),
72- TrueColor: message.TrueColor,
7372 SchemeKnown: message.SchemeKnown,
7473 Light: message.Light,
7574 PaletteKnown: uint32(message.PaletteKnown),
@@ -97,7 +96,6 @@ func themeFromWire(message *wire.Theme) (protocol.Theme, error) {
9796 theme.Foreground = foreground
9897 theme.HasBackground = message.GetHasBackground()
9998 theme.Background = background
100- theme.TrueColor = message.GetTrueColor()
10199 theme.SchemeKnown = message.GetSchemeKnown()
102100 theme.Light = message.GetLight()
103101 known := message.GetPaletteKnown()
@@ -69,7 +69,6 @@ func themeToWire(message protocol.Theme) *wire.Theme {
69 Foreground: rgbToWire(message.Foreground),69 Foreground: rgbToWire(message.Foreground),
70 HasBackground: message.HasBackground,70 HasBackground: message.HasBackground,
71 Background: rgbToWire(message.Background),71 Background: rgbToWire(message.Background),
72- TrueColor: message.TrueColor,
73 SchemeKnown: message.SchemeKnown,72 SchemeKnown: message.SchemeKnown,
74 Light: message.Light,73 Light: message.Light,
75 PaletteKnown: uint32(message.PaletteKnown),74 PaletteKnown: uint32(message.PaletteKnown),
@@ -97,7 +96,6 @@ func themeFromWire(message *wire.Theme) (protocol.Theme, error) {
97 theme.Foreground = foreground96 theme.Foreground = foreground
98 theme.HasBackground = message.GetHasBackground()97 theme.HasBackground = message.GetHasBackground()
99 theme.Background = background98 theme.Background = background
100- theme.TrueColor = message.GetTrueColor()
101 theme.SchemeKnown = message.GetSchemeKnown()99 theme.SchemeKnown = message.GetSchemeKnown()
102 theme.Light = message.GetLight()100 theme.Light = message.GetLight()
103 known := message.GetPaletteKnown()101 known := message.GetPaletteKnown()
M internal/adapters/sessionwire/proto_convert_range_test.go +57 -0
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/domain/terminalcap"
78 "github.com/bnema/vev/internal/protocol"
89 "github.com/bnema/vev/internal/protocol/wire"
910 "github.com/stretchr/testify/require"
@@ -80,6 +81,62 @@ func TestNarrowingConversionsRejectOverflow(t *testing.T) {
8081 },
8182 wantErr: protocol.ErrInvalidHello,
8283 },
84+ {
85+ name: "hello color missing",
86+ run: func(t *testing.T) error {
87+ hello := testWireHello(t)
88+ hello.Color = nil
89+ _, err := helloFromWire(hello)
90+ return err
91+ },
92+ wantErr: errProtoConvertRange,
93+ },
94+ {
95+ name: "hello color mode unspecified",
96+ run: func(t *testing.T) error {
97+ hello := testWireHello(t)
98+ hello.Color.Mode = wire.ColorMode_COLOR_MODE_UNSPECIFIED
99+ _, err := helloFromWire(hello)
100+ return err
101+ },
102+ wantErr: errProtoConvertRange,
103+ },
104+ {
105+ name: "hello color mode unknown",
106+ run: func(t *testing.T) error {
107+ hello := testWireHello(t)
108+ hello.Color.Mode = wire.ColorMode(5)
109+ _, err := helloFromWire(hello)
110+ return err
111+ },
112+ wantErr: errProtoConvertRange,
113+ },
114+ {
115+ name: "hello color source unknown",
116+ run: func(t *testing.T) error {
117+ hello := testWireHello(t)
118+ hello.Color.Source = wire.ColorSource(4)
119+ _, err := helloFromWire(hello)
120+ return err
121+ },
122+ wantErr: errProtoConvertRange,
123+ },
124+ {
125+ name: "hello color mode not encodable",
126+ run: func(t *testing.T) error {
127+ _, err := helloToWire(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ColorMode(9)}})
128+ return err
129+ },
130+ wantErr: errProtoConvertRange,
131+ },
132+ {
133+ name: "hello color source not encodable",
134+ run: func(t *testing.T) error {
135+ _, err := helloToWire(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Source: terminalcap.Source(9)}})
136+ return err
137+ },
138+ wantErr: errProtoConvertRange,
139+ },
83140 {
84141 name: "hello geometry",
85142 run: func(t *testing.T) error {
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
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/protocol"8 "github.com/bnema/vev/internal/protocol"
8 "github.com/bnema/vev/internal/protocol/wire"9 "github.com/bnema/vev/internal/protocol/wire"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
@@ -80,6 +81,62 @@ func TestNarrowingConversionsRejectOverflow(t *testing.T) {
80 },81 },
81 wantErr: protocol.ErrInvalidHello,82 wantErr: protocol.ErrInvalidHello,
82 },83 },
84+ {
85+ name: "hello color missing",
86+ run: func(t *testing.T) error {
87+ hello := testWireHello(t)
88+ hello.Color = nil
89+ _, err := helloFromWire(hello)
90+ return err
91+ },
92+ wantErr: errProtoConvertRange,
93+ },
94+ {
95+ name: "hello color mode unspecified",
96+ run: func(t *testing.T) error {
97+ hello := testWireHello(t)
98+ hello.Color.Mode = wire.ColorMode_COLOR_MODE_UNSPECIFIED
99+ _, err := helloFromWire(hello)
100+ return err
101+ },
102+ wantErr: errProtoConvertRange,
103+ },
104+ {
105+ name: "hello color mode unknown",
106+ run: func(t *testing.T) error {
107+ hello := testWireHello(t)
108+ hello.Color.Mode = wire.ColorMode(5)
109+ _, err := helloFromWire(hello)
110+ return err
111+ },
112+ wantErr: errProtoConvertRange,
113+ },
114+ {
115+ name: "hello color source unknown",
116+ run: func(t *testing.T) error {
117+ hello := testWireHello(t)
118+ hello.Color.Source = wire.ColorSource(4)
119+ _, err := helloFromWire(hello)
120+ return err
121+ },
122+ wantErr: errProtoConvertRange,
123+ },
124+ {
125+ name: "hello color mode not encodable",
126+ run: func(t *testing.T) error {
127+ _, err := helloToWire(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ColorMode(9)}})
128+ return err
129+ },
130+ wantErr: errProtoConvertRange,
131+ },
132+ {
133+ name: "hello color source not encodable",
134+ run: func(t *testing.T) error {
135+ _, err := helloToWire(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Source: terminalcap.Source(9)}})
136+ return err
137+ },
138+ wantErr: errProtoConvertRange,
139+ },
83 {140 {
84 name: "hello geometry",141 name: "hello geometry",
85 run: func(t *testing.T) error {142 run: func(t *testing.T) error {
M internal/adapters/sessionwire/proto_convert_session.go +80 -2
@@ -11,6 +11,7 @@ import (
1111 "google.golang.org/protobuf/proto"
1212
1313 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/terminalcap"
1415 "github.com/bnema/vev/internal/protocol"
1516 "github.com/bnema/vev/internal/protocol/wire"
1617 )
@@ -29,6 +30,10 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
2930 return nil, err
3031 }
3132 exact := exactTargetToWire(message.ExactTarget)
33+ color, err := colorToWire(message.Color)
34+ if err != nil {
35+ return nil, err
36+ }
3237 return &wire.Hello{
3338 Version: uint32(message.Version),
3439 Intent: uint32(message.Intent),
@@ -41,7 +46,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
4146 PixelHeight: pixelHeight,
4247 TermEnv: message.TermEnv,
4348 Cwd: message.Cwd,
44- TrueColor: message.TrueColor,
49+ Color: color,
4550 MaxOutputInFlight: uint32(message.MaxOutputInFlight),
4651 Env: append([]string(nil), message.Env...),
4752 SessionTarget: sessionTarget,
@@ -57,6 +62,76 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
5762 }, nil
5863 }
5964
65+// colorToWire maps a color capability to its wire message. Unknown semantic
66+// values are never encoded.
67+func colorToWire(color terminalcap.ColorCapabilities) (*wire.ColorCapabilities, error) {
68+ if !color.Valid() {
69+ return nil, errProtoConvertRange
70+ }
71+ var mode wire.ColorMode
72+ switch color.Mode {
73+ case terminalcap.TrueColor:
74+ mode = wire.ColorMode_COLOR_MODE_TRUE_COLOR
75+ case terminalcap.ANSI256:
76+ mode = wire.ColorMode_COLOR_MODE_ANSI256
77+ case terminalcap.ANSI16:
78+ mode = wire.ColorMode_COLOR_MODE_ANSI16
79+ case terminalcap.Monochrome:
80+ mode = wire.ColorMode_COLOR_MODE_MONOCHROME
81+ default:
82+ return nil, errProtoConvertRange
83+ }
84+ var source wire.ColorSource
85+ switch color.Source {
86+ case terminalcap.SourceUnknown:
87+ source = wire.ColorSource_COLOR_SOURCE_UNSPECIFIED
88+ case terminalcap.SourceHeuristic:
89+ source = wire.ColorSource_COLOR_SOURCE_HEURISTIC
90+ case terminalcap.SourceDeclared:
91+ source = wire.ColorSource_COLOR_SOURCE_DECLARED
92+ case terminalcap.SourceForced:
93+ source = wire.ColorSource_COLOR_SOURCE_FORCED
94+ default:
95+ return nil, errProtoConvertRange
96+ }
97+ return &wire.ColorCapabilities{Mode: mode, Source: source}, nil
98+}
99+
100+// colorFromWire is strict: Hello must claim a color capability, so a missing
101+// message, an UNSPECIFIED mode, or any value outside the closed enums is a
102+// decode error. An UNSPECIFIED source is the explicit "unknown" source.
103+func colorFromWire(message *wire.ColorCapabilities) (terminalcap.ColorCapabilities, error) {
104+ if message == nil {
105+ return terminalcap.ColorCapabilities{}, errProtoConvertRange
106+ }
107+ var color terminalcap.ColorCapabilities
108+ switch message.GetMode() {
109+ case wire.ColorMode_COLOR_MODE_TRUE_COLOR:
110+ color.Mode = terminalcap.TrueColor
111+ case wire.ColorMode_COLOR_MODE_ANSI256:
112+ color.Mode = terminalcap.ANSI256
113+ case wire.ColorMode_COLOR_MODE_ANSI16:
114+ color.Mode = terminalcap.ANSI16
115+ case wire.ColorMode_COLOR_MODE_MONOCHROME:
116+ color.Mode = terminalcap.Monochrome
117+ default:
118+ return terminalcap.ColorCapabilities{}, errProtoConvertRange
119+ }
120+ switch message.GetSource() {
121+ case wire.ColorSource_COLOR_SOURCE_UNSPECIFIED:
122+ color.Source = terminalcap.SourceUnknown
123+ case wire.ColorSource_COLOR_SOURCE_HEURISTIC:
124+ color.Source = terminalcap.SourceHeuristic
125+ case wire.ColorSource_COLOR_SOURCE_DECLARED:
126+ color.Source = terminalcap.SourceDeclared
127+ case wire.ColorSource_COLOR_SOURCE_FORCED:
128+ color.Source = terminalcap.SourceForced
129+ default:
130+ return terminalcap.ColorCapabilities{}, errProtoConvertRange
131+ }
132+ return color, nil
133+}
134+
60135 func geometryToWire(size domain.Size, pixelWidth, pixelHeight int) (uint32, uint32, uint32, uint32, error) {
61136 for _, value := range []int{size.Cols, size.Rows, pixelWidth, pixelHeight} {
62137 if value < 0 || value > math.MaxUint16 {
@@ -108,7 +183,10 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) {
108183 hello.PixelHeight = int(pixelHeight)
109184 hello.TermEnv = message.GetTermEnv()
110185 hello.Cwd = message.GetCwd()
111- hello.TrueColor = message.GetTrueColor()
186+ hello.Color, err = colorFromWire(message.GetColor())
187+ if err != nil {
188+ return protocol.Hello{}, err
189+ }
112190 window, err := mustUint8(message.GetMaxOutputInFlight())
113191 if err != nil {
114192 return protocol.Hello{}, err
@@ -11,6 +11,7 @@ import (
11 "google.golang.org/protobuf/proto"11 "google.golang.org/protobuf/proto"
12 12
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/terminalcap"
14 "github.com/bnema/vev/internal/protocol"15 "github.com/bnema/vev/internal/protocol"
15 "github.com/bnema/vev/internal/protocol/wire"16 "github.com/bnema/vev/internal/protocol/wire"
16 )17 )
@@ -29,6 +30,10 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
29 return nil, err30 return nil, err
30 }31 }
31 exact := exactTargetToWire(message.ExactTarget)32 exact := exactTargetToWire(message.ExactTarget)
33+ color, err := colorToWire(message.Color)
34+ if err != nil {
35+ return nil, err
36+ }
32 return &wire.Hello{37 return &wire.Hello{
33 Version: uint32(message.Version),38 Version: uint32(message.Version),
34 Intent: uint32(message.Intent),39 Intent: uint32(message.Intent),
@@ -41,7 +46,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
41 PixelHeight: pixelHeight,46 PixelHeight: pixelHeight,
42 TermEnv: message.TermEnv,47 TermEnv: message.TermEnv,
43 Cwd: message.Cwd,48 Cwd: message.Cwd,
44- TrueColor: message.TrueColor,49+ Color: color,
45 MaxOutputInFlight: uint32(message.MaxOutputInFlight),50 MaxOutputInFlight: uint32(message.MaxOutputInFlight),
46 Env: append([]string(nil), message.Env...),51 Env: append([]string(nil), message.Env...),
47 SessionTarget: sessionTarget,52 SessionTarget: sessionTarget,
@@ -57,6 +62,76 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
57 }, nil62 }, nil
58 }63 }
59 64
65+// colorToWire maps a color capability to its wire message. Unknown semantic
66+// values are never encoded.
67+func colorToWire(color terminalcap.ColorCapabilities) (*wire.ColorCapabilities, error) {
68+ if !color.Valid() {
69+ return nil, errProtoConvertRange
70+ }
71+ var mode wire.ColorMode
72+ switch color.Mode {
73+ case terminalcap.TrueColor:
74+ mode = wire.ColorMode_COLOR_MODE_TRUE_COLOR
75+ case terminalcap.ANSI256:
76+ mode = wire.ColorMode_COLOR_MODE_ANSI256
77+ case terminalcap.ANSI16:
78+ mode = wire.ColorMode_COLOR_MODE_ANSI16
79+ case terminalcap.Monochrome:
80+ mode = wire.ColorMode_COLOR_MODE_MONOCHROME
81+ default:
82+ return nil, errProtoConvertRange
83+ }
84+ var source wire.ColorSource
85+ switch color.Source {
86+ case terminalcap.SourceUnknown:
87+ source = wire.ColorSource_COLOR_SOURCE_UNSPECIFIED
88+ case terminalcap.SourceHeuristic:
89+ source = wire.ColorSource_COLOR_SOURCE_HEURISTIC
90+ case terminalcap.SourceDeclared:
91+ source = wire.ColorSource_COLOR_SOURCE_DECLARED
92+ case terminalcap.SourceForced:
93+ source = wire.ColorSource_COLOR_SOURCE_FORCED
94+ default:
95+ return nil, errProtoConvertRange
96+ }
97+ return &wire.ColorCapabilities{Mode: mode, Source: source}, nil
98+}
99+
100+// colorFromWire is strict: Hello must claim a color capability, so a missing
101+// message, an UNSPECIFIED mode, or any value outside the closed enums is a
102+// decode error. An UNSPECIFIED source is the explicit "unknown" source.
103+func colorFromWire(message *wire.ColorCapabilities) (terminalcap.ColorCapabilities, error) {
104+ if message == nil {
105+ return terminalcap.ColorCapabilities{}, errProtoConvertRange
106+ }
107+ var color terminalcap.ColorCapabilities
108+ switch message.GetMode() {
109+ case wire.ColorMode_COLOR_MODE_TRUE_COLOR:
110+ color.Mode = terminalcap.TrueColor
111+ case wire.ColorMode_COLOR_MODE_ANSI256:
112+ color.Mode = terminalcap.ANSI256
113+ case wire.ColorMode_COLOR_MODE_ANSI16:
114+ color.Mode = terminalcap.ANSI16
115+ case wire.ColorMode_COLOR_MODE_MONOCHROME:
116+ color.Mode = terminalcap.Monochrome
117+ default:
118+ return terminalcap.ColorCapabilities{}, errProtoConvertRange
119+ }
120+ switch message.GetSource() {
121+ case wire.ColorSource_COLOR_SOURCE_UNSPECIFIED:
122+ color.Source = terminalcap.SourceUnknown
123+ case wire.ColorSource_COLOR_SOURCE_HEURISTIC:
124+ color.Source = terminalcap.SourceHeuristic
125+ case wire.ColorSource_COLOR_SOURCE_DECLARED:
126+ color.Source = terminalcap.SourceDeclared
127+ case wire.ColorSource_COLOR_SOURCE_FORCED:
128+ color.Source = terminalcap.SourceForced
129+ default:
130+ return terminalcap.ColorCapabilities{}, errProtoConvertRange
131+ }
132+ return color, nil
133+}
134+
60 func geometryToWire(size domain.Size, pixelWidth, pixelHeight int) (uint32, uint32, uint32, uint32, error) {135 func geometryToWire(size domain.Size, pixelWidth, pixelHeight int) (uint32, uint32, uint32, uint32, error) {
61 for _, value := range []int{size.Cols, size.Rows, pixelWidth, pixelHeight} {136 for _, value := range []int{size.Cols, size.Rows, pixelWidth, pixelHeight} {
62 if value < 0 || value > math.MaxUint16 {137 if value < 0 || value > math.MaxUint16 {
@@ -108,7 +183,10 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) {
108 hello.PixelHeight = int(pixelHeight)183 hello.PixelHeight = int(pixelHeight)
109 hello.TermEnv = message.GetTermEnv()184 hello.TermEnv = message.GetTermEnv()
110 hello.Cwd = message.GetCwd()185 hello.Cwd = message.GetCwd()
111- hello.TrueColor = message.GetTrueColor()186+ hello.Color, err = colorFromWire(message.GetColor())
187+ if err != nil {
188+ return protocol.Hello{}, err
189+ }
112 window, err := mustUint8(message.GetMaxOutputInFlight())190 window, err := mustUint8(message.GetMaxOutputInFlight())
113 if err != nil {191 if err != nil {
114 return protocol.Hello{}, err192 return protocol.Hello{}, err
M internal/adapters/sessionwire/proto_convert_test.go +96 -1
@@ -10,6 +10,7 @@ import (
1010 "google.golang.org/protobuf/proto"
1111
1212 "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/domain/terminalcap"
1314 "github.com/bnema/vev/internal/protocol"
1415 "github.com/bnema/vev/internal/protocol/wire"
1516 "github.com/stretchr/testify/require"
@@ -40,13 +41,15 @@ func TestProtoClientRoundTrips(t *testing.T) {
4041 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}},
4142 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true},
4243 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, ClientPID: 4242},
44+ protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}},
45+ protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}},
4346 protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")},
4447 protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}},
4548 protocol.Detach{},
4649 protocol.Ping{},
4750 protocol.List{},
4851 protocol.Kill{RequestID: 1, Name: "work"},
49- protocol.Theme{TrueColor: true},
52+ protocol.Theme{SchemeKnown: true},
5053 protocol.Ack{Epoch: 1, State: 1},
5154 protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}},
5255 protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected},
@@ -353,3 +356,95 @@ func protoShortName(message any) string {
353356 }
354357 return reflect.TypeOf(message).Name()
355358 }
359+
360+// TestProtoHelloColorWire pins the Hello color capability byte for byte for
361+// every mode and source, and refuses truncated prefixes, trailing garbage,
362+// and values outside the closed enums.
363+func TestProtoHelloColorWire(t *testing.T) {
364+ modes := []struct {
365+ name string
366+ mode terminalcap.ColorMode
367+ code byte
368+ }{
369+ {name: "truecolor", mode: terminalcap.TrueColor, code: 1},
370+ {name: "ansi256", mode: terminalcap.ANSI256, code: 2},
371+ {name: "ansi16", mode: terminalcap.ANSI16, code: 3},
372+ {name: "monochrome", mode: terminalcap.Monochrome, code: 4},
373+ }
374+ sources := []struct {
375+ name string
376+ source terminalcap.Source
377+ code byte
378+ }{
379+ {name: "unknown", source: terminalcap.SourceUnknown, code: 0},
380+ {name: "heuristic", source: terminalcap.SourceHeuristic, code: 1},
381+ {name: "declared", source: terminalcap.SourceDeclared, code: 2},
382+ {name: "forced", source: terminalcap.SourceForced, code: 3},
383+ }
384+ for _, m := range modes {
385+ for _, src := range sources {
386+ t.Run(m.name+"/"+src.name, func(t *testing.T) {
387+ color := terminalcap.ColorCapabilities{Mode: m.mode, Source: src.source}
388+ // The minimal attach Hello: version, intent, empty client id, cols,
389+ // rows, then color (field 26, length-delimited), preceded by the
390+ // oneof tag for Hello (field 1).
391+ colorBytes := []byte{0xD2, 0x01}
392+ if src.code == 0 {
393+ colorBytes = append(colorBytes, 0x02, 0x08, m.code)
394+ } else {
395+ colorBytes = append(colorBytes, 0x04, 0x08, m.code, 0x10, src.code)
396+ }
397+ hello := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, ClientID: [16]byte{1}, Size: domain.Size{Cols: 80, Rows: 24}, Color: color}
398+ envelope, err := encodeProtoClient(hello)
399+ require.NoError(t, err)
400+ raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(envelope)
401+ require.NoError(t, err)
402+ require.True(t, bytes.HasSuffix(raw, colorBytes), "color is the last Hello field on the wire: % x", raw)
403+ // ClientEnvelope tag 1 (Hello), then the Hello length.
404+ require.Equal(t, byte(0x0A), raw[0])
405+
406+ require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix")
407+ require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage")
408+ require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
409+ decoded := &wire.ClientEnvelope{}
410+ require.NoError(t, proto.Unmarshal(raw, decoded))
411+ got, err := decodeProtoClient(decoded)
412+ require.NoError(t, err)
413+ require.Equal(t, hello, got)
414+ })
415+ }
416+ }
417+
418+ t.Run("missing color is rejected", func(t *testing.T) {
419+ message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24}
420+ raw, err := proto.Marshal(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}})
421+ require.NoError(t, err)
422+ require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw), "structurally valid")
423+ decoded := &wire.ClientEnvelope{}
424+ require.NoError(t, proto.Unmarshal(raw, decoded))
425+ _, err = decodeProtoClient(decoded)
426+ require.Error(t, err)
427+ })
428+
429+ t.Run("unspecified mode on the wire is rejected", func(t *testing.T) {
430+ message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24, Color: &wire.ColorCapabilities{Source: wire.ColorSource_COLOR_SOURCE_DECLARED}}
431+ _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}})
432+ require.Error(t, err)
433+ })
434+
435+ t.Run("unknown enum values are rejected", func(t *testing.T) {
436+ for _, color := range []*wire.ColorCapabilities{
437+ {Mode: wire.ColorMode(5)},
438+ {Mode: wire.ColorMode_COLOR_MODE_TRUE_COLOR, Source: wire.ColorSource(4)},
439+ } {
440+ message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24, Color: color}
441+ _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}})
442+ require.Error(t, err)
443+ }
444+ })
445+
446+ t.Run("invalid semantic color is never encoded", func(t *testing.T) {
447+ _, err := encodeProtoClient(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ColorMode(9)}})
448+ require.Error(t, err)
449+ })
450+}
@@ -10,6 +10,7 @@ import (
10 "google.golang.org/protobuf/proto"10 "google.golang.org/protobuf/proto"
11 11
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/domain/terminalcap"
13 "github.com/bnema/vev/internal/protocol"14 "github.com/bnema/vev/internal/protocol"
14 "github.com/bnema/vev/internal/protocol/wire"15 "github.com/bnema/vev/internal/protocol/wire"
15 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
@@ -40,13 +41,15 @@ func TestProtoClientRoundTrips(t *testing.T) {
40 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}},41 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}},
41 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true},42 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, KittyDirectGraphics: true, KittyKeyboard: true},
42 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, ClientPID: 4242},43 protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, ClientPID: 4242},
44+ protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}},
45+ protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}},
43 protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")},46 protocol.Input{InputSeq: 1, ActionID: 2, Data: []byte("x")},
44 protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}},47 protocol.Resize{Size: domain.Size{Cols: 80, Rows: 24}},
45 protocol.Detach{},48 protocol.Detach{},
46 protocol.Ping{},49 protocol.Ping{},
47 protocol.List{},50 protocol.List{},
48 protocol.Kill{RequestID: 1, Name: "work"},51 protocol.Kill{RequestID: 1, Name: "work"},
49- protocol.Theme{TrueColor: true},52+ protocol.Theme{SchemeKnown: true},
50 protocol.Ack{Epoch: 1, State: 1},53 protocol.Ack{Epoch: 1, State: 1},
51 protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}},54 protocol.ImagePush{InputSeq: 1, Mime: "image/png", Data: []byte{1}},
52 protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected},55 protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected},
@@ -353,3 +356,95 @@ func protoShortName(message any) string {
353 }356 }
354 return reflect.TypeOf(message).Name()357 return reflect.TypeOf(message).Name()
355 }358 }
359+
360+// TestProtoHelloColorWire pins the Hello color capability byte for byte for
361+// every mode and source, and refuses truncated prefixes, trailing garbage,
362+// and values outside the closed enums.
363+func TestProtoHelloColorWire(t *testing.T) {
364+ modes := []struct {
365+ name string
366+ mode terminalcap.ColorMode
367+ code byte
368+ }{
369+ {name: "truecolor", mode: terminalcap.TrueColor, code: 1},
370+ {name: "ansi256", mode: terminalcap.ANSI256, code: 2},
371+ {name: "ansi16", mode: terminalcap.ANSI16, code: 3},
372+ {name: "monochrome", mode: terminalcap.Monochrome, code: 4},
373+ }
374+ sources := []struct {
375+ name string
376+ source terminalcap.Source
377+ code byte
378+ }{
379+ {name: "unknown", source: terminalcap.SourceUnknown, code: 0},
380+ {name: "heuristic", source: terminalcap.SourceHeuristic, code: 1},
381+ {name: "declared", source: terminalcap.SourceDeclared, code: 2},
382+ {name: "forced", source: terminalcap.SourceForced, code: 3},
383+ }
384+ for _, m := range modes {
385+ for _, src := range sources {
386+ t.Run(m.name+"/"+src.name, func(t *testing.T) {
387+ color := terminalcap.ColorCapabilities{Mode: m.mode, Source: src.source}
388+ // The minimal attach Hello: version, intent, empty client id, cols,
389+ // rows, then color (field 26, length-delimited), preceded by the
390+ // oneof tag for Hello (field 1).
391+ colorBytes := []byte{0xD2, 0x01}
392+ if src.code == 0 {
393+ colorBytes = append(colorBytes, 0x02, 0x08, m.code)
394+ } else {
395+ colorBytes = append(colorBytes, 0x04, 0x08, m.code, 0x10, src.code)
396+ }
397+ hello := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, ClientID: [16]byte{1}, Size: domain.Size{Cols: 80, Rows: 24}, Color: color}
398+ envelope, err := encodeProtoClient(hello)
399+ require.NoError(t, err)
400+ raw, err := proto.MarshalOptions{Deterministic: true}.Marshal(envelope)
401+ require.NoError(t, err)
402+ require.True(t, bytes.HasSuffix(raw, colorBytes), "color is the last Hello field on the wire: % x", raw)
403+ // ClientEnvelope tag 1 (Hello), then the Hello length.
404+ require.Equal(t, byte(0x0A), raw[0])
405+
406+ require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw[:len(raw)-1]), "truncated prefix")
407+ require.Error(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, append(append([]byte(nil), raw...), 0xFF)), "trailing garbage")
408+ require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw))
409+ decoded := &wire.ClientEnvelope{}
410+ require.NoError(t, proto.Unmarshal(raw, decoded))
411+ got, err := decodeProtoClient(decoded)
412+ require.NoError(t, err)
413+ require.Equal(t, hello, got)
414+ })
415+ }
416+ }
417+
418+ t.Run("missing color is rejected", func(t *testing.T) {
419+ message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24}
420+ raw, err := proto.Marshal(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}})
421+ require.NoError(t, err)
422+ require.NoError(t, wire.ScanEnvelope(&wire.ClientEnvelope{}, raw), "structurally valid")
423+ decoded := &wire.ClientEnvelope{}
424+ require.NoError(t, proto.Unmarshal(raw, decoded))
425+ _, err = decodeProtoClient(decoded)
426+ require.Error(t, err)
427+ })
428+
429+ t.Run("unspecified mode on the wire is rejected", func(t *testing.T) {
430+ message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24, Color: &wire.ColorCapabilities{Source: wire.ColorSource_COLOR_SOURCE_DECLARED}}
431+ _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}})
432+ require.Error(t, err)
433+ })
434+
435+ t.Run("unknown enum values are rejected", func(t *testing.T) {
436+ for _, color := range []*wire.ColorCapabilities{
437+ {Mode: wire.ColorMode(5)},
438+ {Mode: wire.ColorMode_COLOR_MODE_TRUE_COLOR, Source: wire.ColorSource(4)},
439+ } {
440+ message := &wire.Hello{Version: uint32(protocol.Version), Intent: uint32(protocol.IntentAttach), ClientId: make([]byte, 16), Cols: 80, Rows: 24, Color: color}
441+ _, err := decodeProtoClient(&wire.ClientEnvelope{Payload: &wire.ClientEnvelope_Hello{Hello: message}})
442+ require.Error(t, err)
443+ }
444+ })
445+
446+ t.Run("invalid semantic color is never encoded", func(t *testing.T) {
447+ _, err := encodeProtoClient(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ColorMode(9)}})
448+ require.Error(t, err)
449+ })
450+}
M internal/adapters/sessionwire/server_test.go +1 -1
@@ -30,7 +30,7 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {
3030 {name: "ping", want: protocol.Ping{}},
3131 {name: "list", want: protocol.List{}},
3232 {name: "kill", want: protocol.Kill{RequestID: 1, Name: "work"}},
33- {name: "theme", want: protocol.Theme{TrueColor: true}},
33+ {name: "theme", want: protocol.Theme{SchemeKnown: true}},
3434 {name: "ack", want: protocol.Ack{Epoch: 1, State: 2}},
3535 {name: "image", want: protocol.ImagePush{InputSeq: 2, Mime: "image/png", Data: []byte{1}}},
3636 {name: "notice", want: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}},
@@ -30,7 +30,7 @@ func TestServerConnectionDecodesEveryClientMessage(t *testing.T) {
30 {name: "ping", want: protocol.Ping{}},30 {name: "ping", want: protocol.Ping{}},
31 {name: "list", want: protocol.List{}},31 {name: "list", want: protocol.List{}},
32 {name: "kill", want: protocol.Kill{RequestID: 1, Name: "work"}},32 {name: "kill", want: protocol.Kill{RequestID: 1, Name: "work"}},
33- {name: "theme", want: protocol.Theme{TrueColor: true}},33+ {name: "theme", want: protocol.Theme{SchemeKnown: true}},
34 {name: "ack", want: protocol.Ack{Epoch: 1, State: 2}},34 {name: "ack", want: protocol.Ack{Epoch: 1, State: 2}},
35 {name: "image", want: protocol.ImagePush{InputSeq: 2, Mime: "image/png", Data: []byte{1}}},35 {name: "image", want: protocol.ImagePush{InputSeq: 2, Mime: "image/png", Data: []byte{1}}},
36 {name: "notice", want: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}},36 {name: "notice", want: protocol.ClientNotice{Action: protocol.ClientNoticeLinkConnected}},
M internal/app/daemon_integration_test.go +2 -1
@@ -28,6 +28,7 @@ import (
2828 "github.com/bnema/vev/internal/adapters/sessionwire"
2929 "github.com/bnema/vev/internal/adapters/snapshot"
3030 "github.com/bnema/vev/internal/domain"
31+ "github.com/bnema/vev/internal/domain/terminalcap"
3132 "github.com/bnema/vev/internal/persist"
3233 "github.com/bnema/vev/internal/ports"
3334 "github.com/bnema/vev/internal/protocol"
@@ -115,7 +116,7 @@ func attachWithEnvironment(t *testing.T, dir string, intent uint8, name string,
115116 raw, err := ipc.DialContext(context.Background(), dir)
116117 require.NoError(t, err)
117118 conn := sessionwire.NewClientConnection(raw)
118- hello := protocol.Hello{Version: protocol.Version, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color", TrueColor: true, Env: env}
119+ hello := protocol.Hello{Version: protocol.Version, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color", Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}, Env: env}
119120 require.NoError(t, conn.SendClient(hello))
120121 p := recvTypedPump(conn)
121122 select {
@@ -28,6 +28,7 @@ import (
28 "github.com/bnema/vev/internal/adapters/sessionwire"28 "github.com/bnema/vev/internal/adapters/sessionwire"
29 "github.com/bnema/vev/internal/adapters/snapshot"29 "github.com/bnema/vev/internal/adapters/snapshot"
30 "github.com/bnema/vev/internal/domain"30 "github.com/bnema/vev/internal/domain"
31+ "github.com/bnema/vev/internal/domain/terminalcap"
31 "github.com/bnema/vev/internal/persist"32 "github.com/bnema/vev/internal/persist"
32 "github.com/bnema/vev/internal/ports"33 "github.com/bnema/vev/internal/ports"
33 "github.com/bnema/vev/internal/protocol"34 "github.com/bnema/vev/internal/protocol"
@@ -115,7 +116,7 @@ func attachWithEnvironment(t *testing.T, dir string, intent uint8, name string,
115 raw, err := ipc.DialContext(context.Background(), dir)116 raw, err := ipc.DialContext(context.Background(), dir)
116 require.NoError(t, err)117 require.NoError(t, err)
117 conn := sessionwire.NewClientConnection(raw)118 conn := sessionwire.NewClientConnection(raw)
118- hello := protocol.Hello{Version: protocol.Version, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color", TrueColor: true, Env: env}119+ hello := protocol.Hello{Version: protocol.Version, Intent: intent, Name: name, Size: sz, TermEnv: "xterm-256color", Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}, Env: env}
119 require.NoError(t, conn.SendClient(hello))120 require.NoError(t, conn.SendClient(hello))
120 p := recvTypedPump(conn)121 p := recvTypedPump(conn)
121 select {122 select {
M internal/domain/terminalcap/capabilities.go +28 -5
@@ -11,8 +11,13 @@ const (
1111 // historical renderer behavior; live Hello paths always use the detector.
1212 TrueColor ColorMode = iota
1313 ANSI256
14+ ANSI16
15+ Monochrome
1416 )
1517
18+// Valid reports whether m is one of the defined color modes.
19+func (m ColorMode) Valid() bool { return m <= Monochrome }
20+
1621 // ColorCapabilities is the color output capability selected for one attachment
1722 // and how confidently it was selected.
1823 type ColorCapabilities struct {
@@ -20,6 +25,9 @@ type ColorCapabilities struct {
2025 Source Source
2126 }
2227
28+// Valid reports whether both the mode and the source are defined values.
29+func (c ColorCapabilities) Valid() bool { return c.Mode.Valid() && c.Source.Valid() }
30+
2331 // RGB reports whether the attachment can receive RGB ANSI output.
2432 func (c ColorCapabilities) RGB() bool { return c.Mode == TrueColor }
2533
@@ -28,6 +36,10 @@ func (c ColorCapabilities) Colors() int {
2836 switch c.Mode {
2937 case ANSI256:
3038 return 256
39+ case ANSI16:
40+ return 16
41+ case Monochrome:
42+ return 0
3143 default:
3244 return 1 << 24
3345 }
@@ -40,8 +52,14 @@ const (
4052 SourceUnknown Source = iota
4153 SourceHeuristic
4254 SourceDeclared
55+ // SourceForced is an explicit user override that detection must not
56+ // second-guess and that never warrants a downgrade notice.
57+ SourceForced
4358 )
4459
60+// Valid reports whether s is one of the defined sources.
61+func (s Source) Valid() bool { return s <= SourceForced }
62+
4563 // Application identifies a known terminal application when its environment
4664 // provides a trustworthy origin signal.
4765 type Application uint8
@@ -111,12 +129,17 @@ func environmentValues(env []string) map[string]string {
111129 }
112130
113131 // 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 {
132+// client's color claim. A declared or forced client color is used verbatim. A
133+// heuristic truecolor claim upgrades a weaker daemon-side detection, because
134+// the daemon may not see the client environment that produced the inference.
135+// Any other claim falls back to environment detection.
136+func Resolve(env []string, declared ColorCapabilities) Capabilities {
117137 caps := Detect(env)
118- if declaredTrueColor && !caps.Color.RGB() {
119- caps.Color = ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}
138+ switch {
139+ case declared.Source == SourceDeclared || declared.Source == SourceForced:
140+ caps.Color = declared
141+ case declared.Source == SourceHeuristic && declared.RGB() && !caps.Color.RGB():
142+ caps.Color = declared
120143 }
121144 return caps
122145 }
@@ -11,8 +11,13 @@ const (
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 ANSI25613 ANSI256
14+ ANSI16
15+ Monochrome
14 )16 )
15 17
18+// Valid reports whether m is one of the defined color modes.
19+func (m ColorMode) Valid() bool { return m <= Monochrome }
20+
16 // ColorCapabilities is the color output capability selected for one attachment21 // ColorCapabilities is the color output capability selected for one attachment
17 // and how confidently it was selected.22 // and how confidently it was selected.
18 type ColorCapabilities struct {23 type ColorCapabilities struct {
@@ -20,6 +25,9 @@ type ColorCapabilities struct {
20 Source Source25 Source Source
21 }26 }
22 27
28+// Valid reports whether both the mode and the source are defined values.
29+func (c ColorCapabilities) Valid() bool { return c.Mode.Valid() && c.Source.Valid() }
30+
23 // RGB reports whether the attachment can receive RGB ANSI output.31 // RGB reports whether the attachment can receive RGB ANSI output.
24 func (c ColorCapabilities) RGB() bool { return c.Mode == TrueColor }32 func (c ColorCapabilities) RGB() bool { return c.Mode == TrueColor }
25 33
@@ -28,6 +36,10 @@ func (c ColorCapabilities) Colors() int {
28 switch c.Mode {36 switch c.Mode {
29 case ANSI256:37 case ANSI256:
30 return 25638 return 256
39+ case ANSI16:
40+ return 16
41+ case Monochrome:
42+ return 0
31 default:43 default:
32 return 1 << 2444 return 1 << 24
33 }45 }
@@ -40,8 +52,14 @@ const (
40 SourceUnknown Source = iota52 SourceUnknown Source = iota
41 SourceHeuristic53 SourceHeuristic
42 SourceDeclared54 SourceDeclared
55+ // SourceForced is an explicit user override that detection must not
56+ // second-guess and that never warrants a downgrade notice.
57+ SourceForced
43 )58 )
44 59
60+// Valid reports whether s is one of the defined sources.
61+func (s Source) Valid() bool { return s <= SourceForced }
62+
45 // Application identifies a known terminal application when its environment63 // Application identifies a known terminal application when its environment
46 // provides a trustworthy origin signal.64 // provides a trustworthy origin signal.
47 type Application uint865 type Application uint8
@@ -111,12 +129,17 @@ func environmentValues(env []string) map[string]string {
111 }129 }
112 130
113 // Resolve selects attachment capabilities from the client environment and the131 // Resolve selects attachment capabilities from the client environment and the
114-// client's explicit color declaration. A declared truecolor client upgrades a132+// client's color claim. A declared or forced client color is used verbatim. A
115-// weaker environment detection to declared TrueColor.133+// heuristic truecolor claim upgrades a weaker daemon-side detection, because
116-func Resolve(env []string, declaredTrueColor bool) Capabilities {134+// the daemon may not see the client environment that produced the inference.
135+// Any other claim falls back to environment detection.
136+func Resolve(env []string, declared ColorCapabilities) Capabilities {
117 caps := Detect(env)137 caps := Detect(env)
118- if declaredTrueColor && !caps.Color.RGB() {138+ switch {
119- caps.Color = ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}139+ case declared.Source == SourceDeclared || declared.Source == SourceForced:
140+ caps.Color = declared
141+ case declared.Source == SourceHeuristic && declared.RGB() && !caps.Color.RGB():
142+ caps.Color = declared
120 }143 }
121 return caps144 return caps
122 }145 }
M internal/domain/terminalcap/capabilities_test.go +33 -7
@@ -16,6 +16,8 @@ func TestColorCapabilities(t *testing.T) {
1616 {name: "zero value is truecolor", in: ColorCapabilities{}, wantRGB: true, wantColors: 16777216},
1717 {name: "truecolor", in: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantRGB: true, wantColors: 16777216},
1818 {name: "ansi256", in: ColorCapabilities{Mode: ANSI256}, wantRGB: false, wantColors: 256},
19+ {name: "ansi16", in: ColorCapabilities{Mode: ANSI16, Source: SourceForced}, wantRGB: false, wantColors: 16},
20+ {name: "monochrome", in: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, wantRGB: false, wantColors: 0},
1921 }
2022 for _, tt := range tests {
2123 t.Run(tt.name, func(t *testing.T) {
@@ -25,20 +27,44 @@ func TestColorCapabilities(t *testing.T) {
2527 }
2628 }
2729
30+func TestColorCapabilitiesValid(t *testing.T) {
31+ tests := []struct {
32+ name string
33+ in ColorCapabilities
34+ want bool
35+ }{
36+ {name: "zero value", in: ColorCapabilities{}, want: true},
37+ {name: "every mode and source", in: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, want: true},
38+ {name: "unknown mode", in: ColorCapabilities{Mode: Monochrome + 1}},
39+ {name: "unknown source", in: ColorCapabilities{Source: SourceForced + 1}},
40+ }
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ require.Equal(t, tt.want, tt.in.Valid())
44+ })
45+ }
46+}
47+
2848 func TestResolve(t *testing.T) {
2949 tests := []struct {
3050 name string
3151 env []string
32- declared bool
52+ declared ColorCapabilities
3353 want ColorCapabilities
3454 wantApp Application
3555 }{
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},
56+ {name: "unknown claim falls back to detection", env: []string{"TERM=xterm-256color"}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}},
57+ {name: "unknown truecolor claim falls back to detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}},
58+ {name: "unknown claim with detected truecolor", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
59+ {name: "declared truecolor overrides indexed detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
60+ {name: "declared truecolor on unknown terminal", env: []string{"TERM=unknown"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
61+ {name: "declared 256 overrides detected truecolor verbatim", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, declared: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}},
62+ {name: "forced 16 colors", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, declared: ColorCapabilities{Mode: ANSI16, Source: SourceForced}, want: ColorCapabilities{Mode: ANSI16, Source: SourceForced}},
63+ {name: "forced monochrome", env: nil, declared: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, want: ColorCapabilities{Mode: Monochrome, Source: SourceForced}},
64+ {name: "heuristic truecolor upgrades weaker detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, want: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}},
65+ {name: "heuristic truecolor keeps stronger detection", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, want: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, wantApp: ApplicationKitty},
66+ {name: "heuristic 16 colors falls back to detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: ANSI16, Source: SourceHeuristic}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}},
67+ {name: "declared claim keeps detected application", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantApp: ApplicationKitty},
4268 }
4369 for _, tt := range tests {
4470 t.Run(tt.name, func(t *testing.T) {
@@ -16,6 +16,8 @@ func TestColorCapabilities(t *testing.T) {
16 {name: "zero value is truecolor", in: ColorCapabilities{}, wantRGB: true, wantColors: 16777216},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},17 {name: "truecolor", in: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantRGB: true, wantColors: 16777216},
18 {name: "ansi256", in: ColorCapabilities{Mode: ANSI256}, wantRGB: false, wantColors: 256},18 {name: "ansi256", in: ColorCapabilities{Mode: ANSI256}, wantRGB: false, wantColors: 256},
19+ {name: "ansi16", in: ColorCapabilities{Mode: ANSI16, Source: SourceForced}, wantRGB: false, wantColors: 16},
20+ {name: "monochrome", in: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, wantRGB: false, wantColors: 0},
19 }21 }
20 for _, tt := range tests {22 for _, tt := range tests {
21 t.Run(tt.name, func(t *testing.T) {23 t.Run(tt.name, func(t *testing.T) {
@@ -25,20 +27,44 @@ func TestColorCapabilities(t *testing.T) {
25 }27 }
26 }28 }
27 29
30+func TestColorCapabilitiesValid(t *testing.T) {
31+ tests := []struct {
32+ name string
33+ in ColorCapabilities
34+ want bool
35+ }{
36+ {name: "zero value", in: ColorCapabilities{}, want: true},
37+ {name: "every mode and source", in: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, want: true},
38+ {name: "unknown mode", in: ColorCapabilities{Mode: Monochrome + 1}},
39+ {name: "unknown source", in: ColorCapabilities{Source: SourceForced + 1}},
40+ }
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ require.Equal(t, tt.want, tt.in.Valid())
44+ })
45+ }
46+}
47+
28 func TestResolve(t *testing.T) {48 func TestResolve(t *testing.T) {
29 tests := []struct {49 tests := []struct {
30 name string50 name string
31 env []string51 env []string
32- declared bool52+ declared ColorCapabilities
33 want ColorCapabilities53 want ColorCapabilities
34 wantApp Application54 wantApp Application
35 }{55 }{
36- {name: "256 color without declaration stays indexed", env: []string{"TERM=xterm-256color"}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}},56+ {name: "unknown claim falls back to detection", 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}},57+ {name: "unknown truecolor claim falls back to detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}},
38- {name: "declaration upgrades unknown terminal", env: []string{"TERM=unknown"}, declared: true, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},58+ {name: "unknown claim with detected truecolor", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, 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},59+ {name: "declared truecolor overrides indexed detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
40- {name: "environment truecolor without declaration", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},60+ {name: "declared truecolor on unknown terminal", env: []string{"TERM=unknown"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, 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},61+ {name: "declared 256 overrides detected truecolor verbatim", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, declared: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}},
62+ {name: "forced 16 colors", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, declared: ColorCapabilities{Mode: ANSI16, Source: SourceForced}, want: ColorCapabilities{Mode: ANSI16, Source: SourceForced}},
63+ {name: "forced monochrome", env: nil, declared: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, want: ColorCapabilities{Mode: Monochrome, Source: SourceForced}},
64+ {name: "heuristic truecolor upgrades weaker detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, want: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}},
65+ {name: "heuristic truecolor keeps stronger detection", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, want: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, wantApp: ApplicationKitty},
66+ {name: "heuristic 16 colors falls back to detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: ANSI16, Source: SourceHeuristic}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}},
67+ {name: "declared claim keeps detected application", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantApp: ApplicationKitty},
42 }68 }
43 for _, tt := range tests {69 for _, tt := range tests {
44 t.Run(tt.name, func(t *testing.T) {70 t.Run(tt.name, func(t *testing.T) {
M internal/protocol/session.go +6 -4
@@ -10,13 +10,14 @@ import (
1010
1111 renderer "github.com/bnema/vev-vt"
1212 "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/domain/terminalcap"
1314 )
1415
1516 // Version is the negotiated vev session protocol version. It carries the
1617 // daemonmux physical preamble conversation (multiplex.proto), whose
1718 // negotiated ceilings and accepted daemon binding ride the same wire
1819 // version as the session and broker conversations.
19-const Version uint16 = 64
20+const Version uint16 = 65
2021
2122 // HandshakeTimeout bounds every transport handshake from connect through the
2223 // first committed publication. It excludes the preceding client-local
@@ -110,7 +111,9 @@ type Hello struct {
110111 PixelHeight int
111112 TermEnv string
112113 Cwd string
113- TrueColor bool
114+ // Color is the color output capability the client claims for this
115+ // attachment. It travels only in Hello; Theme carries no capability.
116+ Color terminalcap.ColorCapabilities
114117 // KittyDirectGraphics is an explicit declaration that the client has
115118 // probed its direct outer terminal and it accepts Kitty graphics output.
116119 KittyDirectGraphics bool
@@ -158,7 +161,6 @@ type Theme struct {
158161 Foreground renderer.RGB
159162 HasBackground bool
160163 Background renderer.RGB
161- TrueColor bool
162164 SchemeKnown bool
163165 Light bool
164166 PaletteKnown uint16
@@ -436,7 +438,7 @@ func ValidateHello(h Hello) error {
436438 if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {
437439 return fmt.Errorf("%w: geometry", ErrInvalidHello)
438440 }
439- if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || (h.Remote && h.ClientPID != 0) {
441+ if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || !h.Color.Valid() || (h.Remote && h.ClientPID != 0) {
440442 return ErrInvalidHello
441443 }
442444 if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {
@@ -10,13 +10,14 @@ import (
10 10
11 renderer "github.com/bnema/vev-vt"11 renderer "github.com/bnema/vev-vt"
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/domain/terminalcap"
13 )14 )
14 15
15 // Version is the negotiated vev session protocol version. It carries the16 // Version is the negotiated vev session protocol version. It carries the
16 // daemonmux physical preamble conversation (multiplex.proto), whose17 // daemonmux physical preamble conversation (multiplex.proto), whose
17 // negotiated ceilings and accepted daemon binding ride the same wire18 // negotiated ceilings and accepted daemon binding ride the same wire
18 // version as the session and broker conversations.19 // version as the session and broker conversations.
19-const Version uint16 = 6420+const Version uint16 = 65
20 21
21 // HandshakeTimeout bounds every transport handshake from connect through the22 // HandshakeTimeout bounds every transport handshake from connect through the
22 // first committed publication. It excludes the preceding client-local23 // first committed publication. It excludes the preceding client-local
@@ -110,7 +111,9 @@ type Hello struct {
110 PixelHeight int111 PixelHeight int
111 TermEnv string112 TermEnv string
112 Cwd string113 Cwd string
113- TrueColor bool114+ // Color is the color output capability the client claims for this
115+ // attachment. It travels only in Hello; Theme carries no capability.
116+ Color terminalcap.ColorCapabilities
114 // KittyDirectGraphics is an explicit declaration that the client has117 // KittyDirectGraphics is an explicit declaration that the client has
115 // probed its direct outer terminal and it accepts Kitty graphics output.118 // probed its direct outer terminal and it accepts Kitty graphics output.
116 KittyDirectGraphics bool119 KittyDirectGraphics bool
@@ -158,7 +161,6 @@ type Theme struct {
158 Foreground renderer.RGB161 Foreground renderer.RGB
159 HasBackground bool162 HasBackground bool
160 Background renderer.RGB163 Background renderer.RGB
161- TrueColor bool
162 SchemeKnown bool164 SchemeKnown bool
163 Light bool165 Light bool
164 PaletteKnown uint16166 PaletteKnown uint16
@@ -436,7 +438,7 @@ func ValidateHello(h Hello) error {
436 if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {438 if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {
437 return fmt.Errorf("%w: geometry", ErrInvalidHello)439 return fmt.Errorf("%w: geometry", ErrInvalidHello)
438 }440 }
439- if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || (h.Remote && h.ClientPID != 0) {441+ if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || !h.Color.Valid() || (h.Remote && h.ClientPID != 0) {
440 return ErrInvalidHello442 return ErrInvalidHello
441 }443 }
442 if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {444 if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(64), Version)
13+ require.Equal(t, uint16(65), Version)
1414 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestVersionRemainsWireCompatible(t *testing.T) {12 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(64), Version)13+ require.Equal(t, uint16(65), Version)
14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
15 }15 }
16 16
M internal/protocol/wire/baseline_bench_test.go +3 -2
@@ -7,6 +7,7 @@ import (
77 "google.golang.org/protobuf/proto"
88
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/terminalcap"
1011 "github.com/bnema/vev/internal/protocol"
1112 )
1213
@@ -24,7 +25,7 @@ func baselineHello() protocol.Hello {
2425 Size: domain.Size{Cols: 120, Rows: 40},
2526 TermEnv: "xterm-256color",
2627 Cwd: "/tmp/project",
27- TrueColor: true,
28+ Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared},
2829 MaxOutputInFlight: 8,
2930 }
3031 }
@@ -48,7 +49,7 @@ func BenchmarkHelloEnvelope(b *testing.B) {
4849 Rows: uint32(hello.Size.Rows),
4950 TermEnv: hello.TermEnv,
5051 Cwd: hello.Cwd,
51- TrueColor: hello.TrueColor,
52+ Color: &ColorCapabilities{Mode: ColorMode_COLOR_MODE_ANSI256, Source: ColorSource_COLOR_SOURCE_DECLARED},
5253 MaxOutputInFlight: uint32(hello.MaxOutputInFlight),
5354 }
5455 b.ReportAllocs()
@@ -7,6 +7,7 @@ import (
7 "google.golang.org/protobuf/proto"7 "google.golang.org/protobuf/proto"
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/protocol"11 "github.com/bnema/vev/internal/protocol"
11 )12 )
12 13
@@ -24,7 +25,7 @@ func baselineHello() protocol.Hello {
24 Size: domain.Size{Cols: 120, Rows: 40},25 Size: domain.Size{Cols: 120, Rows: 40},
25 TermEnv: "xterm-256color",26 TermEnv: "xterm-256color",
26 Cwd: "/tmp/project",27 Cwd: "/tmp/project",
27- TrueColor: true,28+ Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared},
28 MaxOutputInFlight: 8,29 MaxOutputInFlight: 8,
29 }30 }
30 }31 }
@@ -48,7 +49,7 @@ func BenchmarkHelloEnvelope(b *testing.B) {
48 Rows: uint32(hello.Size.Rows),49 Rows: uint32(hello.Size.Rows),
49 TermEnv: hello.TermEnv,50 TermEnv: hello.TermEnv,
50 Cwd: hello.Cwd,51 Cwd: hello.Cwd,
51- TrueColor: hello.TrueColor,52+ Color: &ColorCapabilities{Mode: ColorMode_COLOR_MODE_ANSI256, Source: ColorSource_COLOR_SOURCE_DECLARED},
52 MaxOutputInFlight: uint32(hello.MaxOutputInFlight),53 MaxOutputInFlight: uint32(hello.MaxOutputInFlight),
53 }54 }
54 b.ReportAllocs()55 b.ReportAllocs()
M internal/protocol/wire/common.pb.go +222 -36 generated file: collapsed
M internal/protocol/wire/envelope_inventory_test.go +2 -2
@@ -15,8 +15,8 @@ import (
1515 // shares a payload type except route navigation failure, and the preamble
1616 // constants agree with the schema authority.
1717 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 64 {
19- t.Fatalf("protocol.Version = %d, want 64", protocol.Version)
18+ if protocol.Version != 65 {
19+ t.Fatalf("protocol.Version = %d, want 65", protocol.Version)
2020 }
2121 if ProtocolEpoch != 1 {
2222 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
@@ -15,8 +15,8 @@ import (
15 // shares a payload type except route navigation failure, and the preamble15 // shares a payload type except route navigation failure, and the preamble
16 // constants agree with the schema authority.16 // constants agree with the schema authority.
17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 64 {18+ if protocol.Version != 65 {
19- t.Fatalf("protocol.Version = %d, want 64", protocol.Version)19+ t.Fatalf("protocol.Version = %d, want 65", protocol.Version)
20 }20 }
21 if ProtocolEpoch != 1 {21 if ProtocolEpoch != 1 {
22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
M internal/protocol/wire/schema/common.proto +26 -0
@@ -65,6 +65,32 @@ message RGB {
6565 uint32 b = 3;
6666 }
6767
68+// ColorMode is the closed color output mode of one attachment
69+// (terminalcap.ColorMode). UNSPECIFIED is never a valid value on the wire.
70+enum ColorMode {
71+ COLOR_MODE_UNSPECIFIED = 0;
72+ COLOR_MODE_TRUE_COLOR = 1;
73+ COLOR_MODE_ANSI256 = 2;
74+ COLOR_MODE_ANSI16 = 3;
75+ COLOR_MODE_MONOCHROME = 4;
76+}
77+
78+// ColorSource records how confidently a color mode was selected
79+// (terminalcap.Source). UNSPECIFIED is the explicit "unknown" source.
80+enum ColorSource {
81+ COLOR_SOURCE_UNSPECIFIED = 0;
82+ COLOR_SOURCE_HEURISTIC = 1;
83+ COLOR_SOURCE_DECLARED = 2;
84+ COLOR_SOURCE_FORCED = 3;
85+}
86+
87+// ColorCapabilities is the color output capability a client claims for one
88+// attachment (terminalcap.ColorCapabilities).
89+message ColorCapabilities {
90+ ColorMode mode = 1;
91+ ColorSource source = 2;
92+}
93+
6894 // CellStyle is the bounded style block of one preview cell
6995 // (renderer.Style). Indexed colors use int32 so -1 (unset) survives.
7096 message CellStyle {
@@ -65,6 +65,32 @@ message RGB {
65 uint32 b = 3;65 uint32 b = 3;
66 }66 }
67 67
68+// ColorMode is the closed color output mode of one attachment
69+// (terminalcap.ColorMode). UNSPECIFIED is never a valid value on the wire.
70+enum ColorMode {
71+ COLOR_MODE_UNSPECIFIED = 0;
72+ COLOR_MODE_TRUE_COLOR = 1;
73+ COLOR_MODE_ANSI256 = 2;
74+ COLOR_MODE_ANSI16 = 3;
75+ COLOR_MODE_MONOCHROME = 4;
76+}
77+
78+// ColorSource records how confidently a color mode was selected
79+// (terminalcap.Source). UNSPECIFIED is the explicit "unknown" source.
80+enum ColorSource {
81+ COLOR_SOURCE_UNSPECIFIED = 0;
82+ COLOR_SOURCE_HEURISTIC = 1;
83+ COLOR_SOURCE_DECLARED = 2;
84+ COLOR_SOURCE_FORCED = 3;
85+}
86+
87+// ColorCapabilities is the color output capability a client claims for one
88+// attachment (terminalcap.ColorCapabilities).
89+message ColorCapabilities {
90+ ColorMode mode = 1;
91+ ColorSource source = 2;
92+}
93+
68 // CellStyle is the bounded style block of one preview cell94 // CellStyle is the bounded style block of one preview cell
69 // (renderer.Style). Indexed colors use int32 so -1 (unset) survives.95 // (renderer.Style). Indexed colors use int32 so -1 (unset) survives.
70 message CellStyle {96 message CellStyle {
M internal/protocol/wire/schema/session.proto +5 -1
@@ -21,7 +21,8 @@ message Hello {
2121 uint32 pixel_height = 9;
2222 string term_env = 10;
2323 string cwd = 11;
24- bool true_color = 12;
24+ reserved 12;
25+ reserved "true_color";
2526 uint32 max_output_in_flight = 13;
2627 repeated string env = 14;
2728 reserved 15;
@@ -39,6 +40,9 @@ message Hello {
3940 uint32 terminal_focus = 24;
4041 // PID of a local client process, for bar scripts. 0 when unknown or remote.
4142 uint32 client_pid = 25;
43+ // Color output capability the client claims. Required: a missing message
44+ // or an UNSPECIFIED mode is a decode error.
45+ ColorCapabilities color = 26;
4246 }
4347
4448 // Welcome answers an accepted attach.
@@ -21,7 +21,8 @@ message Hello {
21 uint32 pixel_height = 9;21 uint32 pixel_height = 9;
22 string term_env = 10;22 string term_env = 10;
23 string cwd = 11;23 string cwd = 11;
24- bool true_color = 12;24+ reserved 12;
25+ reserved "true_color";
25 uint32 max_output_in_flight = 13;26 uint32 max_output_in_flight = 13;
26 repeated string env = 14;27 repeated string env = 14;
27 reserved 15;28 reserved 15;
@@ -39,6 +40,9 @@ message Hello {
39 uint32 terminal_focus = 24;40 uint32 terminal_focus = 24;
40 // PID of a local client process, for bar scripts. 0 when unknown or remote.41 // PID of a local client process, for bar scripts. 0 when unknown or remote.
41 uint32 client_pid = 25;42 uint32 client_pid = 25;
43+ // Color output capability the client claims. Required: a missing message
44+ // or an UNSPECIFIED mode is a decode error.
45+ ColorCapabilities color = 26;
42 }46 }
43 47
44 // Welcome answers an accepted attach.48 // Welcome answers an accepted attach.
M internal/protocol/wire/schema/terminal.proto +2 -1
@@ -62,7 +62,8 @@ message Theme {
6262 RGB foreground = 2;
6363 bool has_background = 3;
6464 RGB background = 4;
65- bool true_color = 5;
65+ reserved 5;
66+ reserved "true_color";
6667 bool scheme_known = 6;
6768 bool light = 7;
6869 uint32 palette_known = 8;
@@ -62,7 +62,8 @@ message Theme {
62 RGB foreground = 2;62 RGB foreground = 2;
63 bool has_background = 3;63 bool has_background = 3;
64 RGB background = 4;64 RGB background = 4;
65- bool true_color = 5;65+ reserved 5;
66+ reserved "true_color";
66 bool scheme_known = 6;67 bool scheme_known = 6;
67 bool light = 7;68 bool light = 7;
68 uint32 palette_known = 8;69 uint32 palette_known = 8;
M internal/protocol/wire/session.pb.go +46 -42 generated file: collapsed
M internal/protocol/wire/terminal.pb.go +4 -13 generated file: collapsed
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.Color.RGB() })
164+ retained := in.themes.update(func(*protocol.Theme) {})
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.Color.RGB() })164+ retained := in.themes.update(func(*protocol.Theme) {})
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 +0 -1
@@ -189,7 +189,6 @@ func TestAttachmentInputPublishesClearedThenDefinitivePalette(t *testing.T) {
189189 first.awaitQueries(t, paletteColorBatch, 1)
190190 require.True(t, strings.HasPrefix(first.term.written(), "\x1b[Hready"), "the query follows the committed frame")
191191 cleared := first.awaitThemes(t, 1)[0]
192- require.True(t, cleared.TrueColor)
193192 require.Zero(t, cleared.PaletteKnown)
194193 require.False(t, cleared.HasForeground)
195194
@@ -189,7 +189,6 @@ func TestAttachmentInputPublishesClearedThenDefinitivePalette(t *testing.T) {
189 first.awaitQueries(t, paletteColorBatch, 1)189 first.awaitQueries(t, paletteColorBatch, 1)
190 require.True(t, strings.HasPrefix(first.term.written(), "\x1b[Hready"), "the query follows the committed frame")190 require.True(t, strings.HasPrefix(first.term.written(), "\x1b[Hready"), "the query follows the committed frame")
191 cleared := first.awaitThemes(t, 1)[0]191 cleared := first.awaitThemes(t, 1)[0]
192- require.True(t, cleared.TrueColor)
193 require.Zero(t, cleared.PaletteKnown)192 require.Zero(t, cleared.PaletteKnown)
194 require.False(t, cleared.HasForeground)193 require.False(t, cleared.HasForeground)
195 194
M internal/usecase/client/attachment_session.go +1 -1
@@ -752,7 +752,7 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
752752 PixelHeight: geometry.PixelHeight,
753753 TermEnv: w.cfg.TermEnv,
754754 Cwd: cwd,
755- TrueColor: w.cfg.Color.RGB(),
755+ Color: w.cfg.Color,
756756 // A remote attach reaches the terminal through this client, so both
757757 // declarations describe this client's outer terminal.
758758 KittyDirectGraphics: w.cfg.Capabilities.KittyGraphics,
@@ -752,7 +752,7 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
752 PixelHeight: geometry.PixelHeight,752 PixelHeight: geometry.PixelHeight,
753 TermEnv: w.cfg.TermEnv,753 TermEnv: w.cfg.TermEnv,
754 Cwd: cwd,754 Cwd: cwd,
755- TrueColor: w.cfg.Color.RGB(),755+ Color: w.cfg.Color,
756 // A remote attach reaches the terminal through this client, so both756 // A remote attach reaches the terminal through this client, so both
757 // declarations describe this client's outer terminal.757 // declarations describe this client's outer terminal.
758 KittyDirectGraphics: w.cfg.Capabilities.KittyGraphics,758 KittyDirectGraphics: w.cfg.Capabilities.KittyGraphics,
M internal/usecase/client/attachment_session_test.go +6 -5
@@ -529,11 +529,12 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
529529 tests := []struct {
530530 name string
531531 env AttachmentEnvironment
532- want bool
532+ want terminalcap.ColorCapabilities
533533 }{
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}}},
534+ {name: "unset defaults to truecolor", want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
535+ {name: "supplied truecolor", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}},
536+ {name: "supplied ansi256", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
537+ {name: "supplied forced ansi16", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}},
537538 }
538539 for _, tt := range tests {
539540 t.Run(tt.name, func(t *testing.T) {
@@ -554,7 +555,7 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
554555 hello := awaitHello(t, stream)
555556 require.Equal(t, tt.env.TermEnv, hello.TermEnv)
556557 require.Equal(t, tt.env.Cwd, hello.Cwd)
557- require.Equal(t, tt.want, hello.TrueColor)
558+ require.Equal(t, tt.want, hello.Color)
558559 require.NoError(t, stream.Close())
559560 <-events
560561 })
@@ -529,11 +529,12 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
529 tests := []struct {529 tests := []struct {
530 name string530 name string
531 env AttachmentEnvironment531 env AttachmentEnvironment
532- want bool532+ want terminalcap.ColorCapabilities
533 }{533 }{
534- {name: "unset defaults to truecolor", want: true},534+ {name: "unset defaults to truecolor", want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
535- {name: "supplied truecolor", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}}, want: true},535+ {name: "supplied truecolor", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}},
536- {name: "supplied ansi256", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}},536+ {name: "supplied ansi256", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
537+ {name: "supplied forced ansi16", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}},
537 }538 }
538 for _, tt := range tests {539 for _, tt := range tests {
539 t.Run(tt.name, func(t *testing.T) {540 t.Run(tt.name, func(t *testing.T) {
@@ -554,7 +555,7 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
554 hello := awaitHello(t, stream)555 hello := awaitHello(t, stream)
555 require.Equal(t, tt.env.TermEnv, hello.TermEnv)556 require.Equal(t, tt.env.TermEnv, hello.TermEnv)
556 require.Equal(t, tt.env.Cwd, hello.Cwd)557 require.Equal(t, tt.env.Cwd, hello.Cwd)
557- require.Equal(t, tt.want, hello.TrueColor)558+ require.Equal(t, tt.want, hello.Color)
558 require.NoError(t, stream.Close())559 require.NoError(t, stream.Close())
559 <-events560 <-events
560 })561 })
M internal/usecase/client/palette_generation.go +0 -1
@@ -141,7 +141,6 @@ func (c *paletteGenerationCoordinator) start(retained protocol.Theme, replacemen
141141 id: id,
142142 phase: generationCollecting,
143143 theme: protocol.Theme{
144- TrueColor: retained.TrueColor,
145144 SchemeKnown: retained.SchemeKnown,
146145 Light: retained.Light,
147146 },
@@ -141,7 +141,6 @@ func (c *paletteGenerationCoordinator) start(retained protocol.Theme, replacemen
141 id: id,141 id: id,
142 phase: generationCollecting,142 phase: generationCollecting,
143 theme: protocol.Theme{143 theme: protocol.Theme{
144- TrueColor: retained.TrueColor,
145 SchemeKnown: retained.SchemeKnown,144 SchemeKnown: retained.SchemeKnown,
146 Light: retained.Light,145 Light: retained.Light,
147 },146 },
M internal/usecase/client/palette_generation_test.go +0 -2
@@ -54,7 +54,6 @@ func (c *manualPaletteClock) fireCompletion(g *paletteGenerationCoordinator, id
5454
5555 func generationTheme() protocol.Theme {
5656 return protocol.Theme{
57- TrueColor: true,
5857 HasForeground: true,
5958 Foreground: renderer.RGB{R: 1, G: 2, B: 3},
6059 HasBackground: true,
@@ -119,7 +118,6 @@ func TestPaletteGenerationInitialPublishesClearedThenDefinitive(t *testing.T) {
119118 paletteActionPublishFinal,
120119 }, actionKinds(final))
121120 got := findAction(t, final, paletteActionPublishFinal).theme
122- require.True(t, got.TrueColor)
123121 require.True(t, got.HasForeground)
124122 require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, got.Foreground)
125123 require.True(t, got.HasBackground)
@@ -54,7 +54,6 @@ func (c *manualPaletteClock) fireCompletion(g *paletteGenerationCoordinator, id
54 54
55 func generationTheme() protocol.Theme {55 func generationTheme() protocol.Theme {
56 return protocol.Theme{56 return protocol.Theme{
57- TrueColor: true,
58 HasForeground: true,57 HasForeground: true,
59 Foreground: renderer.RGB{R: 1, G: 2, B: 3},58 Foreground: renderer.RGB{R: 1, G: 2, B: 3},
60 HasBackground: true,59 HasBackground: true,
@@ -119,7 +118,6 @@ func TestPaletteGenerationInitialPublishesClearedThenDefinitive(t *testing.T) {
119 paletteActionPublishFinal,118 paletteActionPublishFinal,
120 }, actionKinds(final))119 }, actionKinds(final))
121 got := findAction(t, final, paletteActionPublishFinal).theme120 got := findAction(t, final, paletteActionPublishFinal).theme
122- require.True(t, got.TrueColor)
123 require.True(t, got.HasForeground)121 require.True(t, got.HasForeground)
124 require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, got.Foreground)122 require.Equal(t, renderer.RGB{R: 10, G: 11, B: 12}, got.Foreground)
125 require.True(t, got.HasBackground)123 require.True(t, got.HasBackground)
M internal/usecase/colorprofile/profile.go +10 -3
@@ -9,10 +9,17 @@ import (
99 )
1010
1111 // Profile returns the renderer profile for c. A terminal without RGB support
12-// gets indexed colors, so RGB surfaces are quantized instead of dropped.
12+// gets a reduced palette, so RGB surfaces are quantized instead of dropped.
13+// Unknown modes fall back to the conservative 256-color profile.
1314 func Profile(c terminalcap.ColorCapabilities) ansi.ColorProfile {
14- if c.RGB() {
15+ switch c.Mode {
16+ case terminalcap.TrueColor:
1517 return ansi.ColorProfileTrueColor
18+ case terminalcap.ANSI16:
19+ return ansi.ColorProfileANSI16
20+ case terminalcap.Monochrome:
21+ return ansi.ColorProfileMonochrome
22+ default:
23+ return ansi.ColorProfileANSI256
1624 }
17- return ansi.ColorProfileANSI256
1825 }
@@ -9,10 +9,17 @@ import (
9 )9 )
10 10
11 // Profile returns the renderer profile for c. A terminal without RGB support11 // Profile returns the renderer profile for c. A terminal without RGB support
12-// gets indexed colors, so RGB surfaces are quantized instead of dropped.12+// gets a reduced palette, so RGB surfaces are quantized instead of dropped.
13+// Unknown modes fall back to the conservative 256-color profile.
13 func Profile(c terminalcap.ColorCapabilities) ansi.ColorProfile {14 func Profile(c terminalcap.ColorCapabilities) ansi.ColorProfile {
14- if c.RGB() {15+ switch c.Mode {
16+ case terminalcap.TrueColor:
15 return ansi.ColorProfileTrueColor17 return ansi.ColorProfileTrueColor
18+ case terminalcap.ANSI16:
19+ return ansi.ColorProfileANSI16
20+ case terminalcap.Monochrome:
21+ return ansi.ColorProfileMonochrome
22+ default:
23+ return ansi.ColorProfileANSI256
16 }24 }
17- return ansi.ColorProfileANSI256
18 }25 }
M internal/usecase/colorprofile/profile_test.go +4 -0
@@ -17,6 +17,10 @@ func TestProfile(t *testing.T) {
1717 {name: "zero value is truecolor", in: terminalcap.ColorCapabilities{}, want: ansi.ColorProfileTrueColor},
1818 {name: "truecolor", in: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, want: ansi.ColorProfileTrueColor},
1919 {name: "ansi256", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, want: ansi.ColorProfileANSI256},
20+ {name: "ansi16", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}, want: ansi.ColorProfileANSI16},
21+ {name: "monochrome", in: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome}, want: ansi.ColorProfileMonochrome},
22+ {name: "forced source does not change mapping", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}, want: ansi.ColorProfileANSI16},
23+ {name: "unknown mode is conservative", in: terminalcap.ColorCapabilities{Mode: terminalcap.ColorMode(99)}, want: ansi.ColorProfileANSI256},
2024 }
2125 for _, tt := range tests {
2226 t.Run(tt.name, func(t *testing.T) {
@@ -17,6 +17,10 @@ func TestProfile(t *testing.T) {
17 {name: "zero value is truecolor", in: terminalcap.ColorCapabilities{}, want: ansi.ColorProfileTrueColor},17 {name: "zero value is truecolor", in: terminalcap.ColorCapabilities{}, want: ansi.ColorProfileTrueColor},
18 {name: "truecolor", in: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, 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},19 {name: "ansi256", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, want: ansi.ColorProfileANSI256},
20+ {name: "ansi16", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}, want: ansi.ColorProfileANSI16},
21+ {name: "monochrome", in: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome}, want: ansi.ColorProfileMonochrome},
22+ {name: "forced source does not change mapping", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}, want: ansi.ColorProfileANSI16},
23+ {name: "unknown mode is conservative", in: terminalcap.ColorCapabilities{Mode: terminalcap.ColorMode(99)}, want: ansi.ColorProfileANSI256},
20 }24 }
21 for _, tt := range tests {25 for _, tt := range tests {
22 t.Run(tt.name, func(t *testing.T) {26 t.Run(tt.name, func(t *testing.T) {
M internal/usecase/daemon/client.go +21 -3
@@ -693,14 +693,33 @@ func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domai
693693 return ac, nil
694694 }
695695
696+// colorDowngradeNotice reports the toast shown when a declared (not forced)
697+// terminal renders with fewer than TrueColor colors. A forced mode is a user
698+// choice and never warrants a notice.
699+func colorDowngradeNotice(color terminalcap.ColorCapabilities) (string, bool) {
700+ if color.Source != terminalcap.SourceDeclared {
701+ return "", false
702+ }
703+ switch color.Mode {
704+ case terminalcap.ANSI256:
705+ return "TrueColor was not detected; rendering with 256 colors.", true
706+ case terminalcap.ANSI16:
707+ return "TrueColor was not detected; rendering with 16 colors.", true
708+ case terminalcap.Monochrome:
709+ return "TrueColor was not detected; rendering without colors.", true
710+ default:
711+ return "", false
712+ }
713+}
714+
696715 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
697716 d.touchMRU(sess)
698717 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
699- if ac.terminalCapabilities.Color.Source == terminalcap.SourceDeclared && !ac.terminalCapabilities.Color.RGB() {
718+ if message, ok := colorDowngradeNotice(ac.terminalCapabilities.Color); ok {
700719 d.publishToast(ac, domain.Notification{
701720 Code: domain.NoticeUser,
702721 Severity: domain.NoticeWarn,
703- Message: "TrueColor was not detected; rendering with 256 colors.",
722+ Message: message,
704723 Time: d.clock.Now(),
705724 Count: 1,
706725 SessionID: sess.id,
@@ -947,7 +966,6 @@ func themeFromMessage(msg protocol.Theme) themeui.Theme {
947966 PaletteKnown: msg.PaletteKnown,
948967 HasFG: msg.HasForeground,
949968 HasBG: msg.HasBackground,
950- TrueColor: msg.TrueColor,
951969 Known: msg.HasForeground && msg.HasBackground,
952970 SchemeKnown: msg.SchemeKnown,
953971 Light: msg.Light,
@@ -693,14 +693,33 @@ func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domai
693 return ac, nil693 return ac, nil
694 }694 }
695 695
696+// colorDowngradeNotice reports the toast shown when a declared (not forced)
697+// terminal renders with fewer than TrueColor colors. A forced mode is a user
698+// choice and never warrants a notice.
699+func colorDowngradeNotice(color terminalcap.ColorCapabilities) (string, bool) {
700+ if color.Source != terminalcap.SourceDeclared {
701+ return "", false
702+ }
703+ switch color.Mode {
704+ case terminalcap.ANSI256:
705+ return "TrueColor was not detected; rendering with 256 colors.", true
706+ case terminalcap.ANSI16:
707+ return "TrueColor was not detected; rendering with 16 colors.", true
708+ case terminalcap.Monochrome:
709+ return "TrueColor was not detected; rendering without colors.", true
710+ default:
711+ return "", false
712+ }
713+}
714+
696 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {715 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
697 d.touchMRU(sess)716 d.touchMRU(sess)
698 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)717 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
699- if ac.terminalCapabilities.Color.Source == terminalcap.SourceDeclared && !ac.terminalCapabilities.Color.RGB() {718+ if message, ok := colorDowngradeNotice(ac.terminalCapabilities.Color); ok {
700 d.publishToast(ac, domain.Notification{719 d.publishToast(ac, domain.Notification{
701 Code: domain.NoticeUser,720 Code: domain.NoticeUser,
702 Severity: domain.NoticeWarn,721 Severity: domain.NoticeWarn,
703- Message: "TrueColor was not detected; rendering with 256 colors.",722+ Message: message,
704 Time: d.clock.Now(),723 Time: d.clock.Now(),
705 Count: 1,724 Count: 1,
706 SessionID: sess.id,725 SessionID: sess.id,
@@ -947,7 +966,6 @@ func themeFromMessage(msg protocol.Theme) themeui.Theme {
947 PaletteKnown: msg.PaletteKnown,966 PaletteKnown: msg.PaletteKnown,
948 HasFG: msg.HasForeground,967 HasFG: msg.HasForeground,
949 HasBG: msg.HasBackground,968 HasBG: msg.HasBackground,
950- TrueColor: msg.TrueColor,
951 Known: msg.HasForeground && msg.HasBackground,969 Known: msg.HasForeground && msg.HasBackground,
952 SchemeKnown: msg.SchemeKnown,970 SchemeKnown: msg.SchemeKnown,
953 Light: msg.Light,971 Light: msg.Light,
A internal/usecase/daemon/color_notice_test.go +68 -0
@@ -0,0 +1,68 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain/terminalcap"
9+)
10+
11+func TestColorDowngradeNotice(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ color terminalcap.ColorCapabilities
15+ want string
16+ }{
17+ {name: "declared 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}, want: "TrueColor was not detected; rendering with 256 colors."},
18+ {name: "declared 16 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}, want: "TrueColor was not detected; rendering with 16 colors."},
19+ {name: "declared monochrome", color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}, want: "TrueColor was not detected; rendering without colors."},
20+ {name: "declared truecolor", color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}},
21+ {name: "forced 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceForced}},
22+ {name: "forced 16 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}},
23+ {name: "forced monochrome", color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceForced}},
24+ {name: "heuristic 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceHeuristic}},
25+ {name: "unknown 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
26+ {name: "zero value", color: terminalcap.ColorCapabilities{}},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ got, ok := colorDowngradeNotice(tt.color)
31+ require.Equal(t, tt.want != "", ok)
32+ require.Equal(t, tt.want, got)
33+ })
34+ }
35+}
36+
37+// TestFinishAttachedClientColorToast drives the real attach completion path:
38+// only a declared reduced mode toasts, and a forced mode never does.
39+func TestFinishAttachedClientColorToast(t *testing.T) {
40+ tests := []struct {
41+ name string
42+ color terminalcap.ColorCapabilities
43+ wantToast string
44+ }{
45+ {name: "declared 256 colors toasts", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}, wantToast: "TrueColor was not detected; rendering with 256 colors."},
46+ {name: "declared 16 colors toasts", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}, wantToast: "TrueColor was not detected; rendering with 16 colors."},
47+ {name: "forced 16 colors is silent", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}},
48+ {name: "declared truecolor is silent", color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}},
49+ }
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) {
52+ pty, release := newBlockingPTY(t)
53+ defer release()
54+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
55+ ac.terminalCapabilities = terminalcap.Capabilities{Color: tt.color}
56+
57+ d.finishAttachedClient(sess, ac, attachClientOptions{})
58+
59+ toasts, _ := visibleToasts(ac)
60+ if tt.wantToast == "" {
61+ require.Empty(t, toasts)
62+ return
63+ }
64+ require.Len(t, toasts, 1)
65+ require.Equal(t, tt.wantToast, toasts[0].Message)
66+ })
67+ }
68+}
@@ -0,0 +1,68 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain/terminalcap"
9+)
10+
11+func TestColorDowngradeNotice(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ color terminalcap.ColorCapabilities
15+ want string
16+ }{
17+ {name: "declared 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}, want: "TrueColor was not detected; rendering with 256 colors."},
18+ {name: "declared 16 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}, want: "TrueColor was not detected; rendering with 16 colors."},
19+ {name: "declared monochrome", color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}, want: "TrueColor was not detected; rendering without colors."},
20+ {name: "declared truecolor", color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}},
21+ {name: "forced 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceForced}},
22+ {name: "forced 16 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}},
23+ {name: "forced monochrome", color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceForced}},
24+ {name: "heuristic 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceHeuristic}},
25+ {name: "unknown 256 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
26+ {name: "zero value", color: terminalcap.ColorCapabilities{}},
27+ }
28+ for _, tt := range tests {
29+ t.Run(tt.name, func(t *testing.T) {
30+ got, ok := colorDowngradeNotice(tt.color)
31+ require.Equal(t, tt.want != "", ok)
32+ require.Equal(t, tt.want, got)
33+ })
34+ }
35+}
36+
37+// TestFinishAttachedClientColorToast drives the real attach completion path:
38+// only a declared reduced mode toasts, and a forced mode never does.
39+func TestFinishAttachedClientColorToast(t *testing.T) {
40+ tests := []struct {
41+ name string
42+ color terminalcap.ColorCapabilities
43+ wantToast string
44+ }{
45+ {name: "declared 256 colors toasts", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}, wantToast: "TrueColor was not detected; rendering with 256 colors."},
46+ {name: "declared 16 colors toasts", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}, wantToast: "TrueColor was not detected; rendering with 16 colors."},
47+ {name: "forced 16 colors is silent", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceForced}},
48+ {name: "declared truecolor is silent", color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}},
49+ }
50+ for _, tt := range tests {
51+ t.Run(tt.name, func(t *testing.T) {
52+ pty, release := newBlockingPTY(t)
53+ defer release()
54+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
55+ ac.terminalCapabilities = terminalcap.Capabilities{Color: tt.color}
56+
57+ d.finishAttachedClient(sess, ac, attachClientOptions{})
58+
59+ toasts, _ := visibleToasts(ac)
60+ if tt.wantToast == "" {
61+ require.Empty(t, toasts)
62+ return
63+ }
64+ require.Len(t, toasts, 1)
65+ require.Equal(t, tt.wantToast, toasts[0].Message)
66+ })
67+ }
68+}
M internal/usecase/daemon/daemon.go +1 -1
@@ -1477,7 +1477,7 @@ 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.Resolve(h.Env, h.TrueColor)
1480+ terminalCapabilities := terminalcap.Resolve(h.Env, h.Color)
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.
@@ -1477,7 +1477,7 @@ 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.Resolve(h.Env, h.TrueColor)1480+ terminalCapabilities := terminalcap.Resolve(h.Env, h.Color)
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.
M internal/usecase/daemon/floating_render_test.go +0 -1
@@ -737,7 +737,6 @@ func benchmarkCapturedRenderState() capturedRenderState {
737737 PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14,
738738 HasFG: true,
739739 HasBG: true,
740- TrueColor: true,
741740 Known: true,
742741 SchemeKnown: true,
743742 UsePalette: true,
@@ -737,7 +737,6 @@ func benchmarkCapturedRenderState() capturedRenderState {
737 PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14,737 PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14,
738 HasFG: true,738 HasFG: true,
739 HasBG: true,739 HasBG: true,
740- TrueColor: true,
741 Known: true,740 Known: true,
742 SchemeKnown: true,741 SchemeKnown: true,
743 UsePalette: true,742 UsePalette: true,
M internal/usecase/daemon/overlay_backdrop_test.go +1 -1
@@ -11,7 +11,7 @@ import (
1111 )
1212
1313 func backdropTheme() themeui.Theme {
14- return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
14+ return themeui.Theme{Known: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
1515 }
1616
1717 func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) {
@@ -11,7 +11,7 @@ import (
11 )11 )
12 12
13 func backdropTheme() themeui.Theme {13 func backdropTheme() themeui.Theme {
14- return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}}14+ return themeui.Theme{Known: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
15 }15 }
16 16
17 func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) {17 func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) {
M internal/usecase/daemon/palette_test.go +1 -2
@@ -131,11 +131,10 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
131131 paletteColors[10] = paletteColors[2]
132132 accentTheme := themeui.Theme{
133133 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
134- HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
134+ HasFG: true, HasBG: true, Known: true, UsePalette: true,
135135 Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
136136 }
137137 indexedTheme := accentTheme
138- indexedTheme.TrueColor = false
139138 neutralTheme := accentTheme
140139 neutralTheme.PaletteKnown = 0
141140 neutralTheme.SchemeKnown = false
@@ -131,11 +131,10 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
131 paletteColors[10] = paletteColors[2]131 paletteColors[10] = paletteColors[2]
132 accentTheme := themeui.Theme{132 accentTheme := themeui.Theme{
133 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},133 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
134- HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,134+ HasFG: true, HasBG: true, Known: true, UsePalette: true,
135 Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,135 Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
136 }136 }
137 indexedTheme := accentTheme137 indexedTheme := accentTheme
138- indexedTheme.TrueColor = false
139 neutralTheme := accentTheme138 neutralTheme := accentTheme
140 neutralTheme.PaletteKnown = 0139 neutralTheme.PaletteKnown = 0
141 neutralTheme.SchemeKnown = false140 neutralTheme.SchemeKnown = false
M internal/usecase/daemon/render_coordinator_test.go +1 -1
@@ -1221,7 +1221,7 @@ func TestProducerInvalidations(t *testing.T) {
12211221 file: "client.go",
12221222 name: "client theme application",
12231223 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1224- d.applyTheme(sess, ac, protocol.Theme{TrueColor: true})
1224+ d.applyTheme(sess, ac, protocol.Theme{SchemeKnown: true})
12251225 },
12261226 },
12271227 {
@@ -1221,7 +1221,7 @@ func TestProducerInvalidations(t *testing.T) {
1221 file: "client.go",1221 file: "client.go",
1222 name: "client theme application",1222 name: "client theme application",
1223 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {1223 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1224- d.applyTheme(sess, ac, protocol.Theme{TrueColor: true})1224+ d.applyTheme(sess, ac, protocol.Theme{SchemeKnown: true})
1225 },1225 },
1226 },1226 },
1227 {1227 {
M internal/usecase/daemon/render_pipeline_cache_test.go +2 -3
@@ -94,7 +94,7 @@ func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) {
9494 }
9595
9696 func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *testing.T) {
97- theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
97+ theme := themeui.Theme{Known: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
9898 initial := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme)
9999 committed := composeFrame(initial, composeCacheInput{})
100100 require.Equal(t, '│', committed.frame.At(20, 1).Rune)
@@ -170,7 +170,7 @@ func TestLayoutFingerprintWeightChangeInvalidatesGeometryCache(t *testing.T) {
170170 }
171171
172172 func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) {
173- theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}}
173+ theme := themeui.Theme{Known: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}}
174174 neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}}
175175 expected := themeui.NewDimmer(theme).Dim(neutralBorder)
176176
@@ -197,7 +197,6 @@ func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
197197 state := cachedStackTitleState("collapsed", 1, true)
198198 state.theme = themeui.Theme{
199199 Known: true,
200- TrueColor: true,
201200 HasFG: true,
202201 HasBG: true,
203202 Foreground: renderer.RGB{R: 220, G: 210, B: 200},
@@ -94,7 +94,7 @@ func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) {
94 }94 }
95 95
96 func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *testing.T) {96 func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *testing.T) {
97- theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}97+ theme := themeui.Theme{Known: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
98 initial := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme)98 initial := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme)
99 committed := composeFrame(initial, composeCacheInput{})99 committed := composeFrame(initial, composeCacheInput{})
100 require.Equal(t, '│', committed.frame.At(20, 1).Rune)100 require.Equal(t, '│', committed.frame.At(20, 1).Rune)
@@ -170,7 +170,7 @@ func TestLayoutFingerprintWeightChangeInvalidatesGeometryCache(t *testing.T) {
170 }170 }
171 171
172 func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) {172 func TestComposeFrameUsesCachedNeutralStructuralBorder(t *testing.T) {
173- theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}}173+ theme := themeui.Theme{Known: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 210, B: 200}, Background: renderer.RGB{R: 20, G: 30, B: 40}}
174 neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}}174 neutralBorder := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 170, G: 80, B: 30}}
175 expected := themeui.NewDimmer(theme).Dim(neutralBorder)175 expected := themeui.NewDimmer(theme).Dim(neutralBorder)
176 176
@@ -197,7 +197,6 @@ func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
197 state := cachedStackTitleState("collapsed", 1, true)197 state := cachedStackTitleState("collapsed", 1, true)
198 state.theme = themeui.Theme{198 state.theme = themeui.Theme{
199 Known: true,199 Known: true,
200- TrueColor: true,
201 HasFG: true,200 HasFG: true,
202 HasBG: true,201 HasBG: true,
203 Foreground: renderer.RGB{R: 220, G: 210, B: 200},202 Foreground: renderer.RGB{R: 220, G: 210, B: 200},
M internal/usecase/daemon/resume_test.go +2 -1
@@ -14,6 +14,7 @@ import (
1414
1515 vt "github.com/bnema/vev-vt"
1616 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/terminalcap"
1718 "github.com/bnema/vev/internal/ports"
1819 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1920 "github.com/bnema/vev/internal/protocol"
@@ -1594,7 +1595,7 @@ func TestResumeParkedRefreshesSessionBoundFuturePTYEnvironment(t *testing.T) {
15941595
15951596 resumeHello := helloResumeCapable(protocol.IntentResume, "work", token)
15961597 resumeHello.Env = []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "WAYLAND_DISPLAY=wayland-1"}
1597- resumeHello.TrueColor = true
1598+ resumeHello.Color = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}
15981599 _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
15991600 require.NoError(t, err)
16001601 require.True(t, ok)
@@ -14,6 +14,7 @@ import (
14 14
15 vt "github.com/bnema/vev-vt"15 vt "github.com/bnema/vev-vt"
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 portsmocks "github.com/bnema/vev/internal/ports/mocks"19 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 "github.com/bnema/vev/internal/protocol"20 "github.com/bnema/vev/internal/protocol"
@@ -1594,7 +1595,7 @@ func TestResumeParkedRefreshesSessionBoundFuturePTYEnvironment(t *testing.T) {
1594 1595
1595 resumeHello := helloResumeCapable(protocol.IntentResume, "work", token)1596 resumeHello := helloResumeCapable(protocol.IntentResume, "work", token)
1596 resumeHello.Env = []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "WAYLAND_DISPLAY=wayland-1"}1597 resumeHello.Env = []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "WAYLAND_DISPLAY=wayland-1"}
1597- resumeHello.TrueColor = true1598+ resumeHello.Color = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}
1598 _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})1599 _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
1599 require.NoError(t, err)1600 require.NoError(t, err)
1600 require.True(t, ok)1601 require.True(t, ok)
M internal/usecase/daemon/session_test.go +5 -4
@@ -16,6 +16,7 @@ import (
1616 "github.com/stretchr/testify/require"
1717
1818 "github.com/bnema/vev/internal/domain"
19+ "github.com/bnema/vev/internal/domain/terminalcap"
1920 "github.com/bnema/vev/internal/persist"
2021 "github.com/bnema/vev/internal/ports"
2122 portsmocks "github.com/bnema/vev/internal/ports/mocks"
@@ -1618,7 +1619,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
16181619 tr := newMockServerConnection(t)
16191620 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
16201621 tr.EXPECT().Close().Return(nil).Maybe()
1621- sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
1622+ sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz}, tr)
16221623 require.NoError(t, err)
16231624 defer func() {
16241625 _ = d.killSession(sess, protocol.ReasonServerShutdown, false)
@@ -1663,9 +1664,9 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
16631664 tr2 := newMockServerConnection(t)
16641665 tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe()
16651666 tr2.EXPECT().Close().Return(nil).Maybe()
1666- sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: false}, tr1)
1667+ sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}}, tr1)
16671668 require.NoError(t, err)
1668- _, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz, TrueColor: true}, tr2)
1669+ _, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz}, tr2)
16691670 require.NoError(t, err)
16701671 defer func() {
16711672 _ = d.killSession(sess, protocol.ReasonServerShutdown, false)
@@ -1704,7 +1705,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
17041705 tr := newMockServerConnection(t)
17051706 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
17061707 tr.EXPECT().Close().Return(nil).Maybe()
1707- sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
1708+ sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz}, tr)
17081709 require.NoError(t, err)
17091710 // The source coordinator is deliberately made pending so the switch must
17101711 // invalidate it rather than letting its stale callback render for ac.
@@ -16,6 +16,7 @@ import (
16 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
17 17
18 "github.com/bnema/vev/internal/domain"18 "github.com/bnema/vev/internal/domain"
19+ "github.com/bnema/vev/internal/domain/terminalcap"
19 "github.com/bnema/vev/internal/persist"20 "github.com/bnema/vev/internal/persist"
20 "github.com/bnema/vev/internal/ports"21 "github.com/bnema/vev/internal/ports"
21 portsmocks "github.com/bnema/vev/internal/ports/mocks"22 portsmocks "github.com/bnema/vev/internal/ports/mocks"
@@ -1618,7 +1619,7 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1618 tr := newMockServerConnection(t)1619 tr := newMockServerConnection(t)
1619 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()1620 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1620 tr.EXPECT().Close().Return(nil).Maybe()1621 tr.EXPECT().Close().Return(nil).Maybe()
1621- sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)1622+ sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz}, tr)
1622 require.NoError(t, err)1623 require.NoError(t, err)
1623 defer func() {1624 defer func() {
1624 _ = d.killSession(sess, protocol.ReasonServerShutdown, false)1625 _ = d.killSession(sess, protocol.ReasonServerShutdown, false)
@@ -1663,9 +1664,9 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1663 tr2 := newMockServerConnection(t)1664 tr2 := newMockServerConnection(t)
1664 tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe()1665 tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1665 tr2.EXPECT().Close().Return(nil).Maybe()1666 tr2.EXPECT().Close().Return(nil).Maybe()
1666- sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: false}, tr1)1667+ sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}}, tr1)
1667 require.NoError(t, err)1668 require.NoError(t, err)
1668- _, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz, TrueColor: true}, tr2)1669+ _, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz}, tr2)
1669 require.NoError(t, err)1670 require.NoError(t, err)
1670 defer func() {1671 defer func() {
1671 _ = d.killSession(sess, protocol.ReasonServerShutdown, false)1672 _ = d.killSession(sess, protocol.ReasonServerShutdown, false)
@@ -1704,7 +1705,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1704 tr := newMockServerConnection(t)1705 tr := newMockServerConnection(t)
1705 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()1706 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1706 tr.EXPECT().Close().Return(nil).Maybe()1707 tr.EXPECT().Close().Return(nil).Maybe()
1707- sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)1708+ sess, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz}, tr)
1708 require.NoError(t, err)1709 require.NoError(t, err)
1709 // The source coordinator is deliberately made pending so the switch must1710 // The source coordinator is deliberately made pending so the switch must
1710 // invalidate it rather than letting its stale callback render for ac.1711 // invalidate it rather than letting its stale callback render for ac.
M internal/usecase/daemon/status_test.go +7 -11
@@ -297,7 +297,6 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
297297 HasFG: true,
298298 HasBG: true,
299299 Known: true,
300- TrueColor: true,
301300 }
302301 styles := themeui.NewStyles(theme)
303302 require.False(t, styles.TabTitle.Equal(styles.StatusBar), "sanity: muted title style must differ from the base style on a truecolor theme")
@@ -445,7 +444,6 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
445444 Background: renderer.RGB{R: 10, G: 20, B: 30},
446445 HasFG: true,
447446 HasBG: true,
448- TrueColor: true,
449447 Known: true,
450448 })
451449
@@ -523,7 +521,7 @@ func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) {
523521 d.applyTheme(sess, ac, protocol.Theme{
524522 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
525523 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
526- TrueColor: true, SchemeKnown: true, Light: !tc.want.Light,
524+ SchemeKnown: true, Light: !tc.want.Light,
527525 })
528526
529527 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
@@ -578,7 +576,7 @@ func TestAutoThemeDetachClearsPaneColorScheme(t *testing.T) {
578576 d.applyTheme(sess, ac, protocol.Theme{
579577 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
580578 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
581- TrueColor: true, SchemeKnown: true, Light: true,
579+ SchemeKnown: true, Light: true,
582580 })
583581 assertSessionColorScheme(t, sess, true)
584582
@@ -595,7 +593,7 @@ func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) {
595593 d.applyTheme(sess, ac, protocol.Theme{
596594 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
597595 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
598- TrueColor: true, SchemeKnown: true, Light: true,
596+ SchemeKnown: true, Light: true,
599597 })
600598 assertSessionColorScheme(t, sess, true)
601599
@@ -614,7 +612,7 @@ func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) {
614612 d.applyTheme(sess, ac, protocol.Theme{
615613 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
616614 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
617- TrueColor: true, SchemeKnown: true, Light: true,
615+ SchemeKnown: true, Light: true,
618616 })
619617
620618 d.clientGone(sess, ac, ac.transport(), true)
@@ -631,12 +629,11 @@ func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) {
631629 d.applyTheme(sess, ac, protocol.Theme{
632630 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
633631 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
634- TrueColor: true, SchemeKnown: true, Light: true,
632+ SchemeKnown: true, Light: true,
635633 })
636634 d.applyTheme(sess, ac, protocol.Theme{
637635 HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
638636 HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
639- TrueColor: true,
640637 })
641638
642639 assertSessionDefaultColors(t, sess, renderer.RGB{R: 7, G: 8, B: 9}, renderer.RGB{R: 10, G: 11, B: 12})
@@ -1094,7 +1091,7 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) {
10941091 }
10951092
10961093 func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
1097- theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, TrueColor: true, Known: true}
1094+ theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, Known: true}
10981095 styles := themeui.NewStyles(theme)
10991096 row := make([]renderer.Cell, 16)
11001097
@@ -1141,7 +1138,7 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi
11411138 }
11421139
11431140 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
1144- theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
1141+ theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, Known: true, UsePalette: true}
11451142 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
11461143 theme.Palette[10] = theme.Palette[2]
11471144 theme.PaletteKnown = 1<<2 | 1<<10
@@ -1344,7 +1341,6 @@ func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) {
13441341 Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
13451342 HasFG: true,
13461343 HasBG: true,
1347- TrueColor: true,
13481344 Known: true,
13491345 UsePalette: true,
13501346 }
@@ -297,7 +297,6 @@ func TestDrawTopBarSnapshotStylesTabNameAndTitle(t *testing.T) {
297 HasFG: true,297 HasFG: true,
298 HasBG: true,298 HasBG: true,
299 Known: true,299 Known: true,
300- TrueColor: true,
301 }300 }
302 styles := themeui.NewStyles(theme)301 styles := themeui.NewStyles(theme)
303 require.False(t, styles.TabTitle.Equal(styles.StatusBar), "sanity: muted title style must differ from the base style on a truecolor theme")302 require.False(t, styles.TabTitle.Equal(styles.StatusBar), "sanity: muted title style must differ from the base style on a truecolor theme")
@@ -445,7 +444,6 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
445 Background: renderer.RGB{R: 10, G: 20, B: 30},444 Background: renderer.RGB{R: 10, G: 20, B: 30},
446 HasFG: true,445 HasFG: true,
447 HasBG: true,446 HasBG: true,
448- TrueColor: true,
449 Known: true,447 Known: true,
450 })448 })
451 449
@@ -523,7 +521,7 @@ func TestApplyThemeForcedBuiltinThemePropagatesToChromeAndPanes(t *testing.T) {
523 d.applyTheme(sess, ac, protocol.Theme{521 d.applyTheme(sess, ac, protocol.Theme{
524 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},522 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
525 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},523 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
526- TrueColor: true, SchemeKnown: true, Light: !tc.want.Light,524+ SchemeKnown: true, Light: !tc.want.Light,
527 })525 })
528 526
529 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)527 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
@@ -578,7 +576,7 @@ func TestAutoThemeDetachClearsPaneColorScheme(t *testing.T) {
578 d.applyTheme(sess, ac, protocol.Theme{576 d.applyTheme(sess, ac, protocol.Theme{
579 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},577 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
580 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},578 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
581- TrueColor: true, SchemeKnown: true, Light: true,579+ SchemeKnown: true, Light: true,
582 })580 })
583 assertSessionColorScheme(t, sess, true)581 assertSessionColorScheme(t, sess, true)
584 582
@@ -595,7 +593,7 @@ func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) {
595 d.applyTheme(sess, ac, protocol.Theme{593 d.applyTheme(sess, ac, protocol.Theme{
596 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},594 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
597 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},595 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
598- TrueColor: true, SchemeKnown: true, Light: true,596+ SchemeKnown: true, Light: true,
599 })597 })
600 assertSessionColorScheme(t, sess, true)598 assertSessionColorScheme(t, sess, true)
601 599
@@ -614,7 +612,7 @@ func TestForcedThemeDetachPreservesBuiltinOnPanes(t *testing.T) {
614 d.applyTheme(sess, ac, protocol.Theme{612 d.applyTheme(sess, ac, protocol.Theme{
615 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},613 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
616 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},614 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
617- TrueColor: true, SchemeKnown: true, Light: true,615+ SchemeKnown: true, Light: true,
618 })616 })
619 617
620 d.clientGone(sess, ac, ac.transport(), true)618 d.clientGone(sess, ac, ac.transport(), true)
@@ -631,12 +629,11 @@ func TestApplyThemeAutoUnknownDoesNotClobberPaneColorScheme(t *testing.T) {
631 d.applyTheme(sess, ac, protocol.Theme{629 d.applyTheme(sess, ac, protocol.Theme{
632 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},630 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
633 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},631 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
634- TrueColor: true, SchemeKnown: true, Light: true,632+ SchemeKnown: true, Light: true,
635 })633 })
636 d.applyTheme(sess, ac, protocol.Theme{634 d.applyTheme(sess, ac, protocol.Theme{
637 HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},635 HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
638 HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},636 HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
639- TrueColor: true,
640 })637 })
641 638
642 assertSessionDefaultColors(t, sess, renderer.RGB{R: 7, G: 8, B: 9}, renderer.RGB{R: 10, G: 11, B: 12})639 assertSessionDefaultColors(t, sess, renderer.RGB{R: 7, G: 8, B: 9}, renderer.RGB{R: 10, G: 11, B: 12})
@@ -1094,7 +1091,7 @@ func TestStatusBarRendersMRUNamesAndInlineBell(t *testing.T) {
1094 }1091 }
1095 1092
1096 func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {1093 func TestStatusBarCurrentSessionUsesAccentStyle(t *testing.T) {
1097- theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, TrueColor: true, Known: true}1094+ theme := themeui.Theme{Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}, HasFG: true, HasBG: true, Known: true}
1098 styles := themeui.NewStyles(theme)1095 styles := themeui.NewStyles(theme)
1099 row := make([]renderer.Cell, 16)1096 row := make([]renderer.Cell, 16)
1100 1097
@@ -1141,7 +1138,7 @@ func TestStatusBarContextualRanksPreserveOriginalRanksAndSelectedAccent(t *testi
1141 }1138 }
1142 1139
1143 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {1140 func TestStatusBarMRUGradientTruecolorAndPlainFallback(t *testing.T) {
1144- theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}1141+ theme := themeui.Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, Known: true, UsePalette: true}
1145 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}1142 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
1146 theme.Palette[10] = theme.Palette[2]1143 theme.Palette[10] = theme.Palette[2]
1147 theme.PaletteKnown = 1<<2 | 1<<101144 theme.PaletteKnown = 1<<2 | 1<<10
@@ -1344,7 +1341,6 @@ func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) {
1344 Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},1341 Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
1345 HasFG: true,1342 HasFG: true,
1346 HasBG: true,1343 HasBG: true,
1347- TrueColor: true,
1348 Known: true,1344 Known: true,
1349 UsePalette: true,1345 UsePalette: true,
1350 }1346 }
M internal/usecase/daemon/theme_cache_test.go +2 -2
@@ -18,7 +18,7 @@ func cacheTheme() themeui.Theme {
1818 palette[10] = palette[2]
1919 return themeui.Theme{
2020 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
21- HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
21+ HasFG: true, HasBG: true, Known: true, UsePalette: true,
2222 Palette: palette, PaletteKnown: 1<<2 | 1<<10,
2323 }
2424 }
@@ -26,7 +26,7 @@ func cacheTheme() themeui.Theme {
2626 func TestThemeAccentHotReloadRebuildsAppliedSnapshot(t *testing.T) {
2727 d, sess, ac, _ := newManualSessionWithPTYs(t)
2828 raw := cacheTheme()
29- d.applyTheme(sess, ac, protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, TrueColor: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown})
29+ d.applyTheme(sess, ac, protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown})
3030 before := ac.getAppliedTheme()
3131
3232 cfg := domain.Defaults()
@@ -18,7 +18,7 @@ func cacheTheme() themeui.Theme {
18 palette[10] = palette[2]18 palette[10] = palette[2]
19 return themeui.Theme{19 return themeui.Theme{
20 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},20 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
21- HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,21+ HasFG: true, HasBG: true, Known: true, UsePalette: true,
22 Palette: palette, PaletteKnown: 1<<2 | 1<<10,22 Palette: palette, PaletteKnown: 1<<2 | 1<<10,
23 }23 }
24 }24 }
@@ -26,7 +26,7 @@ func cacheTheme() themeui.Theme {
26 func TestThemeAccentHotReloadRebuildsAppliedSnapshot(t *testing.T) {26 func TestThemeAccentHotReloadRebuildsAppliedSnapshot(t *testing.T) {
27 d, sess, ac, _ := newManualSessionWithPTYs(t)27 d, sess, ac, _ := newManualSessionWithPTYs(t)
28 raw := cacheTheme()28 raw := cacheTheme()
29- d.applyTheme(sess, ac, protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, TrueColor: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown})29+ d.applyTheme(sess, ac, protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown})
30 before := ac.getAppliedTheme()30 before := ac.getAppliedTheme()
31 31
32 cfg := domain.Defaults()32 cfg := domain.Defaults()
M internal/usecase/daemon/theme_integration_test.go +2 -4
@@ -14,8 +14,7 @@ import (
1414 func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) protocol.Theme {
1515 out := protocol.Theme{
1616 Foreground: foreground, Background: background,
17- HasForeground: true, HasBackground: true, TrueColor: true,
18- SchemeKnown: true,
17+ HasForeground: true, HasBackground: true, SchemeKnown: true,
1918 }
2019 for slot, color := range palette {
2120 out.Palette[slot] = color
@@ -175,8 +174,7 @@ func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) {
175174 d.ApplyConfig(tt.config)
176175 got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{
177176 Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown,
178- HasFG: true, HasBG: true, TrueColor: true,
179- }).Resolved.Styles)
177+ HasFG: true, HasBG: true}).Resolved.Styles)
180178 require.Equal(t, []byte(tt.want), got)
181179 })
182180 }
@@ -14,8 +14,7 @@ import (
14 func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) protocol.Theme {14 func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) protocol.Theme {
15 out := protocol.Theme{15 out := protocol.Theme{
16 Foreground: foreground, Background: background,16 Foreground: foreground, Background: background,
17- HasForeground: true, HasBackground: true, TrueColor: true,17+ HasForeground: true, HasBackground: true, SchemeKnown: true,
18- SchemeKnown: true,
19 }18 }
20 for slot, color := range palette {19 for slot, color := range palette {
21 out.Palette[slot] = color20 out.Palette[slot] = color
@@ -175,8 +174,7 @@ func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) {
175 d.ApplyConfig(tt.config)174 d.ApplyConfig(tt.config)
176 got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{175 got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{
177 Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown,176 Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown,
178- HasFG: true, HasBG: true, TrueColor: true,177+ HasFG: true, HasBG: true}).Resolved.Styles)
179- }).Resolved.Styles)
180 require.Equal(t, []byte(tt.want), got)178 require.Equal(t, []byte(tt.want), got)
181 })179 })
182 }180 }
M internal/usecase/palette/model_test.go +1 -2
@@ -503,11 +503,10 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T)
503503 paletteColors[10] = paletteColors[2]
504504 accentTheme := themeui.Theme{
505505 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
506- HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
506+ HasFG: true, HasBG: true, Known: true, UsePalette: true,
507507 Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
508508 }
509509 indexedTheme := accentTheme
510- indexedTheme.TrueColor = false
511510 paletteOffTheme := accentTheme
512511 paletteOffTheme.UsePalette = false
513512 neutralTheme := paletteOffTheme
@@ -503,11 +503,10 @@ func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T)
503 paletteColors[10] = paletteColors[2]503 paletteColors[10] = paletteColors[2]
504 accentTheme := themeui.Theme{504 accentTheme := themeui.Theme{
505 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},505 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
506- HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,506+ HasFG: true, HasBG: true, Known: true, UsePalette: true,
507 Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,507 Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
508 }508 }
509 indexedTheme := accentTheme509 indexedTheme := accentTheme
510- indexedTheme.TrueColor = false
511 paletteOffTheme := accentTheme510 paletteOffTheme := accentTheme
512 paletteOffTheme.UsePalette = false511 paletteOffTheme.UsePalette = false
513 neutralTheme := paletteOffTheme512 neutralTheme := paletteOffTheme
M internal/usecase/theme/accent_resolve_test.go +1 -3
@@ -131,7 +131,6 @@ func TestResolveAccentAuto(t *testing.T) {
131131 name: "known colors remain semantic without truecolor output",
132132 theme: func() Theme {
133133 t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal})
134- t.TrueColor = false
135134 return t
136135 }(),
137136 want: Accent{RGB: teal, Slot: 2, Known: true},
@@ -206,7 +205,7 @@ func TestResolveAccentExplicitSlot(t *testing.T) {
206205 },
207206 {
208207 name: "explicit known slot remains semantic without truecolor output",
209- theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(),
208+ theme: paletteTheme(map[int]renderer.RGB{3: teal}),
210209 policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3},
211210 want: Accent{RGB: teal, Slot: 3, Known: true},
212211 },
@@ -273,7 +272,6 @@ func paletteTheme(colors map[int]renderer.RGB) Theme {
273272 Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
274273 HasFG: true,
275274 HasBG: true,
276- TrueColor: true,
277275 Known: true,
278276 UsePalette: true,
279277 }
@@ -131,7 +131,6 @@ func TestResolveAccentAuto(t *testing.T) {
131 name: "known colors remain semantic without truecolor output",131 name: "known colors remain semantic without truecolor output",
132 theme: func() Theme {132 theme: func() Theme {
133 t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal})133 t := paletteTheme(map[int]renderer.RGB{2: teal, 10: teal})
134- t.TrueColor = false
135 return t134 return t
136 }(),135 }(),
137 want: Accent{RGB: teal, Slot: 2, Known: true},136 want: Accent{RGB: teal, Slot: 2, Known: true},
@@ -206,7 +205,7 @@ func TestResolveAccentExplicitSlot(t *testing.T) {
206 },205 },
207 {206 {
208 name: "explicit known slot remains semantic without truecolor output",207 name: "explicit known slot remains semantic without truecolor output",
209- theme: func() Theme { t := paletteTheme(map[int]renderer.RGB{3: teal}); t.TrueColor = false; return t }(),208+ theme: paletteTheme(map[int]renderer.RGB{3: teal}),
210 policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3},209 policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 3},
211 want: Accent{RGB: teal, Slot: 3, Known: true},210 want: Accent{RGB: teal, Slot: 3, Known: true},
212 },211 },
@@ -273,7 +272,6 @@ func paletteTheme(colors map[int]renderer.RGB) Theme {
273 Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},272 Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a},
274 HasFG: true,273 HasFG: true,
275 HasBG: true,274 HasBG: true,
276- TrueColor: true,
277 Known: true,275 Known: true,
278 UsePalette: true,276 UsePalette: true,
279 }277 }
M internal/usecase/theme/benchmark_test.go +0 -1
@@ -26,7 +26,6 @@ func benchmarkPaletteTheme() Theme {
2626 PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14,
2727 HasFG: true,
2828 HasBG: true,
29- TrueColor: true,
3029 Known: true,
3130 SchemeKnown: true,
3231 UsePalette: true,
@@ -26,7 +26,6 @@ func benchmarkPaletteTheme() Theme {
26 PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14,26 PaletteKnown: 1<<2 | 1<<4 | 1<<10 | 1<<12 | 1<<14,
27 HasFG: true,27 HasFG: true,
28 HasBG: true,28 HasBG: true,
29- TrueColor: true,
30 Known: true,29 Known: true,
31 SchemeKnown: true,30 SchemeKnown: true,
32 UsePalette: true,31 UsePalette: true,
M internal/usecase/theme/neutral_history_test.go +1 -1
@@ -8,7 +8,7 @@ import (
88 )
99
1010 func TestNeutralHistoryGradient(t *testing.T) {
11- theme := Theme{Foreground: renderer.RGB{R: 216, G: 216, B: 216}, Background: renderer.RGB{R: 16, G: 16, B: 16}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
11+ theme := Theme{Foreground: renderer.RGB{R: 216, G: 216, B: 216}, Background: renderer.RGB{R: 16, G: 16, B: 16}, HasFG: true, HasBG: true, Known: true}
1212 styles := NewStyles(theme)
1313 require.Equal(t, theme.Foreground, styles.TabActive.BackgroundRGB)
1414 for count := 1; count <= 9; count++ {
@@ -8,7 +8,7 @@ import (
8 )8 )
9 9
10 func TestNeutralHistoryGradient(t *testing.T) {10 func TestNeutralHistoryGradient(t *testing.T) {
11- theme := Theme{Foreground: renderer.RGB{R: 216, G: 216, B: 216}, Background: renderer.RGB{R: 16, G: 16, B: 16}, HasFG: true, HasBG: true, Known: true, TrueColor: true}11+ theme := Theme{Foreground: renderer.RGB{R: 216, G: 216, B: 216}, Background: renderer.RGB{R: 16, G: 16, B: 16}, HasFG: true, HasBG: true, Known: true}
12 styles := NewStyles(theme)12 styles := NewStyles(theme)
13 require.Equal(t, theme.Foreground, styles.TabActive.BackgroundRGB)13 require.Equal(t, theme.Foreground, styles.TabActive.BackgroundRGB)
14 for count := 1; count <= 9; count++ {14 for count := 1; count <= 9; count++ {
M internal/usecase/theme/phase3_review_test.go +3 -4
@@ -27,7 +27,6 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) {
2727 name: "explicit known slot without truecolor remains semantic",
2828 theme: func() Theme {
2929 t := paletteTheme(map[int]renderer.RGB{5: teal})
30- t.TrueColor = false
3130 return t
3231 }(),
3332 policy: explicit,
@@ -37,7 +36,7 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) {
3736 name: "automatic absent osc palette keeps dark scheme indexed blue fallback",
3837 theme: Theme{
3938 Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 8, G: 9, B: 10},
40- HasFG: true, HasBG: true, TrueColor: true, Known: true, SchemeKnown: true, UsePalette: true,
39+ HasFG: true, HasBG: true, Known: true, SchemeKnown: true, UsePalette: true,
4140 },
4241 policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
4342 want: Accent{Slot: 12, IndexedOnly: true},
@@ -45,14 +44,14 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) {
4544 {
4645 name: "automatic absent defaults keeps light scheme indexed blue fallback",
4746 theme: Theme{
48- TrueColor: true, Known: true, SchemeKnown: true, Light: true, UsePalette: true,
47+ Known: true, SchemeKnown: true, Light: true, UsePalette: true,
4948 },
5049 policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
5150 want: Accent{Slot: 4, IndexedOnly: true},
5251 },
5352 {
5453 name: "automatic absent palette and unknown scheme stays neutral",
55- theme: Theme{TrueColor: true, Known: true, UsePalette: true},
54+ theme: Theme{Known: true, UsePalette: true},
5655 policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
5756 want: Accent{},
5857 },
@@ -27,7 +27,6 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) {
27 name: "explicit known slot without truecolor remains semantic",27 name: "explicit known slot without truecolor remains semantic",
28 theme: func() Theme {28 theme: func() Theme {
29 t := paletteTheme(map[int]renderer.RGB{5: teal})29 t := paletteTheme(map[int]renderer.RGB{5: teal})
30- t.TrueColor = false
31 return t30 return t
32 }(),31 }(),
33 policy: explicit,32 policy: explicit,
@@ -37,7 +36,7 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) {
37 name: "automatic absent osc palette keeps dark scheme indexed blue fallback",36 name: "automatic absent osc palette keeps dark scheme indexed blue fallback",
38 theme: Theme{37 theme: Theme{
39 Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 8, G: 9, B: 10},38 Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 8, G: 9, B: 10},
40- HasFG: true, HasBG: true, TrueColor: true, Known: true, SchemeKnown: true, UsePalette: true,39+ HasFG: true, HasBG: true, Known: true, SchemeKnown: true, UsePalette: true,
41 },40 },
42 policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},41 policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
43 want: Accent{Slot: 12, IndexedOnly: true},42 want: Accent{Slot: 12, IndexedOnly: true},
@@ -45,14 +44,14 @@ func TestResolveAccentIndexedFallbackTruthTable(t *testing.T) {
45 {44 {
46 name: "automatic absent defaults keeps light scheme indexed blue fallback",45 name: "automatic absent defaults keeps light scheme indexed blue fallback",
47 theme: Theme{46 theme: Theme{
48- TrueColor: true, Known: true, SchemeKnown: true, Light: true, UsePalette: true,47+ Known: true, SchemeKnown: true, Light: true, UsePalette: true,
49 },48 },
50 policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},49 policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
51 want: Accent{Slot: 4, IndexedOnly: true},50 want: Accent{Slot: 4, IndexedOnly: true},
52 },51 },
53 {52 {
54 name: "automatic absent palette and unknown scheme stays neutral",53 name: "automatic absent palette and unknown scheme stays neutral",
55- theme: Theme{TrueColor: true, Known: true, UsePalette: true},54+ theme: Theme{Known: true, UsePalette: true},
56 policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},55 policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto},
57 want: Accent{},56 want: Accent{},
58 },57 },
M internal/usecase/theme/ramp_test.go +3 -5
@@ -54,7 +54,7 @@ func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) {
5454 theme := Theme{
5555 Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0},
5656 Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59},
57- HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true,
57+ HasFG: true, HasBG: true, Known: true, UsePalette: true,
5858 }
5959 accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}
6060 ramp := BuildRamp(theme, accent)
@@ -155,7 +155,6 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
155155
156156 func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {
157157 theme := rampTheme(false)
158- theme.TrueColor = false
159158 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
160159 theme.PaletteKnown = 1 << 2
161160 indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
@@ -172,7 +171,6 @@ func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {
172171 })
173172 }
174173
175- theme.TrueColor = true
176174 theme.UsePalette = false
177175 off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
178176 require.False(t, off.Accent.Known)
@@ -188,7 +186,7 @@ func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {
188186
189187 func rampTheme(light bool) Theme {
190188 if light {
191- return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
189+ return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, Known: true, UsePalette: true}
192190 }
193- return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
191+ return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, Known: true, UsePalette: true}
194192 }
@@ -54,7 +54,7 @@ func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) {
54 theme := Theme{54 theme := Theme{
55 Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0},55 Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0},
56 Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59},56 Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59},
57- HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true,57+ HasFG: true, HasBG: true, Known: true, UsePalette: true,
58 }58 }
59 accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}59 accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}
60 ramp := BuildRamp(theme, accent)60 ramp := BuildRamp(theme, accent)
@@ -155,7 +155,6 @@ func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
155 155
156 func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {156 func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {
157 theme := rampTheme(false)157 theme := rampTheme(false)
158- theme.TrueColor = false
159 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}158 theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
160 theme.PaletteKnown = 1 << 2159 theme.PaletteKnown = 1 << 2
161 indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})160 indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
@@ -172,7 +171,6 @@ func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {
172 })171 })
173 }172 }
174 173
175- theme.TrueColor = true
176 theme.UsePalette = false174 theme.UsePalette = false
177 off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})175 off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
178 require.False(t, off.Accent.Known)176 require.False(t, off.Accent.Known)
@@ -188,7 +186,7 @@ func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {
188 186
189 func rampTheme(light bool) Theme {187 func rampTheme(light bool) Theme {
190 if light {188 if light {
191- return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}189+ return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, Known: true, UsePalette: true}
192 }190 }
193- return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}191+ return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, Known: true, UsePalette: true}
194 }192 }
M internal/usecase/theme/theme.go +4 -9
@@ -19,13 +19,10 @@ type Theme struct {
1919 PaletteKnown uint16
2020 HasFG bool
2121 HasBG bool
22- // TrueColor records output capability for transport compatibility. Semantic
23- // styles remain RGB; the attachment renderer quantizes them when necessary.
24- TrueColor bool
25- Known bool
26- SchemeKnown bool
27- Light bool
28- UsePalette bool
22+ Known bool
23+ SchemeKnown bool
24+ Light bool
25+ UsePalette bool
2926 }
3027
3128 // PaletteColor returns a palette color only when palette inheritance is
@@ -43,7 +40,6 @@ var (
4340 Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18},
4441 HasFG: true,
4542 HasBG: true,
46- TrueColor: true,
4743 Known: true,
4844 SchemeKnown: true,
4945 Light: false,
@@ -53,7 +49,6 @@ var (
5349 Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8},
5450 HasFG: true,
5551 HasBG: true,
56- TrueColor: true,
5752 Known: true,
5853 SchemeKnown: true,
5954 Light: true,
@@ -19,13 +19,10 @@ type Theme struct {
19 PaletteKnown uint1619 PaletteKnown uint16
20 HasFG bool20 HasFG bool
21 HasBG bool21 HasBG bool
22- // TrueColor records output capability for transport compatibility. Semantic22+ Known bool
23- // styles remain RGB; the attachment renderer quantizes them when necessary.23+ SchemeKnown bool
24- TrueColor bool24+ Light bool
25- Known bool25+ UsePalette bool
26- SchemeKnown bool
27- Light bool
28- UsePalette bool
29 }26 }
30 27
31 // PaletteColor returns a palette color only when palette inheritance is28 // PaletteColor returns a palette color only when palette inheritance is
@@ -43,7 +40,6 @@ var (
43 Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18},40 Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18},
44 HasFG: true,41 HasFG: true,
45 HasBG: true,42 HasBG: true,
46- TrueColor: true,
47 Known: true,43 Known: true,
48 SchemeKnown: true,44 SchemeKnown: true,
49 Light: false,45 Light: false,
@@ -53,7 +49,6 @@ var (
53 Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8},49 Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8},
54 HasFG: true,50 HasFG: true,
55 HasBG: true,51 HasBG: true,
56- TrueColor: true,
57 Known: true,52 Known: true,
58 SchemeKnown: true,53 SchemeKnown: true,
59 Light: true,54 Light: true,
M internal/usecase/theme/theme_test.go +7 -9
@@ -271,8 +271,7 @@ func TestDimmer(t *testing.T) {
271271 theme := Theme{
272272 Foreground: renderer.RGB{R: 240, G: 240, B: 240},
273273 Background: renderer.RGB{R: 10, G: 20, B: 30},
274- HasFG: true, HasBG: true, Known: true, TrueColor: true,
275- }
274+ HasFG: true, HasBG: true, Known: true}
276275 dimmer := NewDimmer(theme, WithForegroundDimming(55))
277276
278277 t.Run("fades foreground toward the faded cell background", func(t *testing.T) {
@@ -344,7 +343,7 @@ func TestDimmer(t *testing.T) {
344343 }
345344
346345 func TestDimmerDefaults(t *testing.T) {
347- theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
346+ theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true}
348347 tests := []struct {
349348 name string
350349 in renderer.Style
@@ -439,8 +438,7 @@ func TestDimmerUsesReportedPalette(t *testing.T) {
439438 foreground := renderer.RGB{R: 200, G: 200, B: 200}
440439 baseTheme := Theme{
441440 Foreground: foreground, Background: background,
442- HasFG: true, HasBG: true, Known: true, TrueColor: true,
443- }
441+ HasFG: true, HasBG: true, Known: true}
444442
445443 reportedSlot2 := renderer.RGB{R: 60, G: 180, B: 75}
446444 reportedSlot4 := renderer.RGB{R: 30, G: 100, B: 200}
@@ -585,7 +583,7 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) {
585583 t.Fatalf("selection fallback=%+v want inverse", got)
586584 }
587585
588- theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
586+ theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true}
589587 if got, want := Blend(renderer.RGB{R: 0, G: 0, B: 0}, renderer.RGB{R: 100, G: 200, B: 255}, 0.25), (renderer.RGB{R: 25, G: 50, B: 64}); got != want {
590588 t.Fatalf("blend=%+v want %+v", got, want)
591589 }
@@ -608,7 +606,7 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) {
608606 }
609607
610608 func TestEmphasisStyle(t *testing.T) {
611- usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
609+ usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true}
612610 base := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}}
613611
614612 tests := []struct {
@@ -646,7 +644,7 @@ func TestEmphasisStyle(t *testing.T) {
646644 }
647645
648646 func TestMutedVariantStyle(t *testing.T) {
649- usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
647+ usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true}
650648 rgbBase := renderer.Style{
651649 HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100},
652650 HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0},
@@ -686,7 +684,7 @@ func TestMutedVariantStyle(t *testing.T) {
686684 {
687685 name: "base without RGB background and theme without background returns base unchanged",
688686 base: fgOnlyBase,
689- theme: Theme{Known: true, TrueColor: true, HasFG: true},
687+ theme: Theme{Known: true, HasFG: true},
690688 want: fgOnlyBase,
691689 },
692690 {
@@ -271,8 +271,7 @@ func TestDimmer(t *testing.T) {
271 theme := Theme{271 theme := Theme{
272 Foreground: renderer.RGB{R: 240, G: 240, B: 240},272 Foreground: renderer.RGB{R: 240, G: 240, B: 240},
273 Background: renderer.RGB{R: 10, G: 20, B: 30},273 Background: renderer.RGB{R: 10, G: 20, B: 30},
274- HasFG: true, HasBG: true, Known: true, TrueColor: true,274+ HasFG: true, HasBG: true, Known: true}
275- }
276 dimmer := NewDimmer(theme, WithForegroundDimming(55))275 dimmer := NewDimmer(theme, WithForegroundDimming(55))
277 276
278 t.Run("fades foreground toward the faded cell background", func(t *testing.T) {277 t.Run("fades foreground toward the faded cell background", func(t *testing.T) {
@@ -344,7 +343,7 @@ func TestDimmer(t *testing.T) {
344 }343 }
345 344
346 func TestDimmerDefaults(t *testing.T) {345 func TestDimmerDefaults(t *testing.T) {
347- theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}346+ theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true}
348 tests := []struct {347 tests := []struct {
349 name string348 name string
350 in renderer.Style349 in renderer.Style
@@ -439,8 +438,7 @@ func TestDimmerUsesReportedPalette(t *testing.T) {
439 foreground := renderer.RGB{R: 200, G: 200, B: 200}438 foreground := renderer.RGB{R: 200, G: 200, B: 200}
440 baseTheme := Theme{439 baseTheme := Theme{
441 Foreground: foreground, Background: background,440 Foreground: foreground, Background: background,
442- HasFG: true, HasBG: true, Known: true, TrueColor: true,441+ HasFG: true, HasBG: true, Known: true}
443- }
444 442
445 reportedSlot2 := renderer.RGB{R: 60, G: 180, B: 75}443 reportedSlot2 := renderer.RGB{R: 60, G: 180, B: 75}
446 reportedSlot4 := renderer.RGB{R: 30, G: 100, B: 200}444 reportedSlot4 := renderer.RGB{R: 30, G: 100, B: 200}
@@ -585,7 +583,7 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) {
585 t.Fatalf("selection fallback=%+v want inverse", got)583 t.Fatalf("selection fallback=%+v want inverse", got)
586 }584 }
587 585
588- theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}586+ theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true}
589 if got, want := Blend(renderer.RGB{R: 0, G: 0, B: 0}, renderer.RGB{R: 100, G: 200, B: 255}, 0.25), (renderer.RGB{R: 25, G: 50, B: 64}); got != want {587 if got, want := Blend(renderer.RGB{R: 0, G: 0, B: 0}, renderer.RGB{R: 100, G: 200, B: 255}, 0.25), (renderer.RGB{R: 25, G: 50, B: 64}); got != want {
590 t.Fatalf("blend=%+v want %+v", got, want)588 t.Fatalf("blend=%+v want %+v", got, want)
591 }589 }
@@ -608,7 +606,7 @@ func TestStyleHelpersFallbackAndThemed(t *testing.T) {
608 }606 }
609 607
610 func TestEmphasisStyle(t *testing.T) {608 func TestEmphasisStyle(t *testing.T) {
611- usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}609+ usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true}
612 base := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}}610 base := renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}}
613 611
614 tests := []struct {612 tests := []struct {
@@ -646,7 +644,7 @@ func TestEmphasisStyle(t *testing.T) {
646 }644 }
647 645
648 func TestMutedVariantStyle(t *testing.T) {646 func TestMutedVariantStyle(t *testing.T) {
649- usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}647+ usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true}
650 rgbBase := renderer.Style{648 rgbBase := renderer.Style{
651 HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100},649 HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 100, G: 100, B: 100},
652 HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0},650 HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 0, G: 0, B: 0},
@@ -686,7 +684,7 @@ func TestMutedVariantStyle(t *testing.T) {
686 {684 {
687 name: "base without RGB background and theme without background returns base unchanged",685 name: "base without RGB background and theme without background returns base unchanged",
688 base: fgOnlyBase,686 base: fgOnlyBase,
689- theme: Theme{Known: true, TrueColor: true, HasFG: true},687+ theme: Theme{Known: true, HasFG: true},
690 want: fgOnlyBase,688 want: fgOnlyBase,
691 },689 },
692 {690 {
M transport_replay_integration_test.go +1 -1
@@ -60,7 +60,7 @@ func TestThemeGenerationTransportSequences(t *testing.T) {
6060 cleared := protocol.Theme{
6161 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
6262 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
63- TrueColor: true, SchemeKnown: true,
63+ SchemeKnown: true,
6464 }
6565 palette := [16]renderer.RGB{2: {R: 125, G: 181, B: 181}, 10: {R: 125, G: 181, B: 181}}
6666 definitive := cleared
@@ -60,7 +60,7 @@ func TestThemeGenerationTransportSequences(t *testing.T) {
60 cleared := protocol.Theme{60 cleared := protocol.Theme{
61 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},61 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
62 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},62 HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
63- TrueColor: true, SchemeKnown: true,63+ SchemeKnown: true,
64 }64 }
65 palette := [16]renderer.RGB{2: {R: 125, G: 181, B: 181}, 10: {R: 125, G: 181, B: 181}}65 palette := [16]renderer.RGB{2: {R: 125, G: 181, B: 181}, 10: {R: 125, G: 181, B: 181}}
66 definitive := cleared66 definitive := cleared

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