detached a06b956

feat(terminal): support ANSI-16 and monochrome color profiles (#306)

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

Introduce terminalcap.ColorCapabilities (Mode, Source) with RGB() and Colors(),
rename Indexed256 to ANSI256, and move the Detect-then-declared-truecolor
precedence into terminalcap.Resolve. Replace DetectTrueColor and the plain
TrueColor bool plumbing in the client with ColorCapabilities, and share one
usecase/colorprofile.Profile mapping between the daemon output renderer and the
client pickers. Wire fields (Hello.TrueColor, Theme.TrueColor) are unchanged and
filled from RGB().

* 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.

* feat(terminal): detect ANSI-16 and monochrome terminals

Classify TERM values without a truecolor signal: dumb, vt52/100/102/220 and
-m/-mono names are monochrome; linux, ansi, cons25 and -16color/-color names
are 16 colors. Add terminalcap.ParseColorMode for explicit overrides.

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

Resolve an explicit color mode client-side (VEV_COLORS over terminal.colors,
auto means detection) and send it as a Forced Hello.Color; the picker gets the
same value. Web and headless UI-driver clients now declare forced truecolor
instead of inheriting the gateway process TERM.

* fix(daemon): word color downgrade notice per mode

* feat(theme): keep vev UI legible in ANSI-16 and monochrome

* fix(daemon): re-negotiate color profile when an attachment resumes

A resumed attachment kept the color mode and renderer profile from the
parked link, so a reconnect with a different Hello.Color encoded output
for the wrong terminal. Apply the latest Hello color, swap the renderer
profile, and re-resolve the chrome theme. Cover per-attachment profiles
across full, incremental, reset and resume paints.

* fix(client): mark picker problems without color in monochrome

Monochrome drops the fixed problem dot colors, leaving error, warning and
healthy rows indistinguishable. Draw error and warning dots bold and dim
the tree lines instead of relying on the grey foreground. Truecolor,
256-color and ANSI-16 bytes are unchanged.

* docs(terminal): document color modes, detection and overrides

* perf(theme): keep NewDimmer inlinable by reading DimByAttribute in Dim

* fix(daemon): publish color downgrade notice on resume and swap renderer only on mode change

* docs(terminal): clarify color override timing and heuristic claim resolution

* refactor(color): remove unused ColorCapabilities.Colors, pulseStyle wrapper and exported ReducedColor; document drawClientToast as info-only
commit
a06b956a6dd506f4d85b4011c75b3c38fd0a1195
author
bnema · · 2026-10-03 14:19 UTC
committer
GitHub · · 2026-10-03 14:19 UTC
parent
8a884f0
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
90 files · +2340 -338
M boundary_test.go +3 -0
@@ -116,6 +116,9 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) {
116116 {"daemon rejects client", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/client", false, false},
117117 {"daemon rejects broker", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/broker", false, false},
118118 {"daemon rejects app", modulePath + "/internal/usecase/daemon", modulePath + "/internal/app", false, false},
119+ // Shared color-profile policy is a leaf use case both sides may import.
120+ {"client accepts colorprofile", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/colorprofile", false, true},
121+ {"daemon accepts colorprofile", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/colorprofile", false, true},
119122 }
120123 for _, tt := range tests {
121124 t.Run(tt.name, func(t *testing.T) {
@@ -116,6 +116,9 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) {
116 {"daemon rejects client", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/client", false, false},116 {"daemon rejects client", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/client", false, false},
117 {"daemon rejects broker", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/broker", false, false},117 {"daemon rejects broker", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/broker", false, false},
118 {"daemon rejects app", modulePath + "/internal/usecase/daemon", modulePath + "/internal/app", false, false},118 {"daemon rejects app", modulePath + "/internal/usecase/daemon", modulePath + "/internal/app", false, false},
119+ // Shared color-profile policy is a leaf use case both sides may import.
120+ {"client accepts colorprofile", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/colorprofile", false, true},
121+ {"daemon accepts colorprofile", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/colorprofile", false, true},
119 }122 }
120 for _, tt := range tests {123 for _, tt := range tests {
121 t.Run(tt.name, func(t *testing.T) {124 t.Run(tt.name, func(t *testing.T) {
M 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/architecture.md +3 -2
@@ -1,6 +1,6 @@
11 # Architecture
22
3-vev uses a hexagonal core with typed session messages at the client and daemon boundary. Binary framing and carriage selection stay outside the use cases. The current strict protocol version is 59.
3+vev uses a hexagonal core with typed session messages at the client and daemon boundary. Binary framing and carriage selection stay outside the use cases. The current strict protocol version is 65.
44
55 `CommandResult` has a closed explicit outcome contract:
66
@@ -24,6 +24,7 @@ Every result keeps its request ID for strict correlation.
2424 carrying `Envelope{Payload}` (one complete serialized directional
2525 envelope, no type byte).
2626 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.
27+- `internal/usecase/colorprofile`: the single `terminalcap.ColorCapabilities` → vev-vt color profile mapping shared by the client picker and daemon output renderer.
2728 - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition.
2829 - `internal/adapters/sessionwire`: translates between typed session
2930 connections and raw wire transports: directional `oneof` envelope
@@ -131,7 +132,7 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
131132 `go tool buf generate`, and handle the new variant in `sessionwire`.
132133 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.
133134 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
134-- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `64`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
135+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `65`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
135136
136137 ## Broker
137138
@@ -1,6 +1,6 @@
1 # Architecture1 # Architecture
2 2
3-vev uses a hexagonal core with typed session messages at the client and daemon boundary. Binary framing and carriage selection stay outside the use cases. The current strict protocol version is 59.3+vev uses a hexagonal core with typed session messages at the client and daemon boundary. Binary framing and carriage selection stay outside the use cases. The current strict protocol version is 65.
4 4
5 `CommandResult` has a closed explicit outcome contract:5 `CommandResult` has a closed explicit outcome contract:
6 6
@@ -24,6 +24,7 @@ Every result keeps its request ID for strict correlation.
24 carrying `Envelope{Payload}` (one complete serialized directional24 carrying `Envelope{Payload}` (one complete serialized directional
25 envelope, no type byte).25 envelope, no type byte).
26 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.26 - `internal/ports`: application-facing interfaces and the values required by those interfaces. It contains no codecs, raw frames, environment policy, or worker implementations.
27+- `internal/usecase/colorprofile`: the single `terminalcap.ColorCapabilities` → vev-vt color profile mapping shared by the client picker and daemon output renderer.
27 - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition.28 - `internal/usecase`: client, daemon, broker, and supporting application behavior. Production use cases may consume semantic protocol packages but never `protocol/wire` or concrete adapters. The broker may use its own subpackages but cannot import sibling use cases. Client, daemon, and remote-registry use cases cannot own or import each other; they collaborate only through ports and application composition.
28 - `internal/adapters/sessionwire`: translates between typed session29 - `internal/adapters/sessionwire`: translates between typed session
29 connections and raw wire transports: directional `oneof` envelope30 connections and raw wire transports: directional `oneof` envelope
@@ -131,7 +132,7 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
131 `go tool buf generate`, and handle the new variant in `sessionwire`.132 `go tool buf generate`, and handle the new variant in `sessionwire`.
132 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.133 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.
133 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.134 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
134-- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `64`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.135+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `65`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
135 136
136 ## Broker137 ## Broker
137 138
M docs/configuration.md +5 -1
@@ -43,6 +43,9 @@ ephemeral.close-on-exit = on
4343 # Use the kitty keyboard protocol when the terminal supports it (enables Ctrl+1..9).
4444 keyboard.kitty-protocol = on
4545
46+# Outer terminal colors: auto (detect), truecolor, 256, 16, or mono.
47+terminal.colors = auto
48+
4649 # Rebindable actions. Leave a line out to keep its built-in binding.
4750 open-palette = alt+space
4851 toggle-floating-pane = alt+f
@@ -207,7 +210,8 @@ If the daemon refuses to start because its session data is broken, do not edit t
207210 | `theme.accent = 0`–`15` | Use exactly that ANSI color as the accent. |
208211
209212 - vev only colors its own UI (bars, borders, palette). Pane content is never recolored.
210-- Tinted backgrounds need a truecolor terminal. Otherwise the accent only colors text and borders.
213+- Tinted backgrounds need a truecolor or 256-color terminal. On 16-color and monochrome terminals vev uses bold, reverse, and faint instead. See [terminal colors](terminal.md#colors).
214+- `terminal.colors` forces the color mode of your terminal (`auto`, `truecolor`, `256`, `16`, `mono`). `VEV_COLORS` overrides it. See [terminal colors](terminal.md#colors).
211215 - vev follows light/dark switches your terminal reports. It cannot see palette changes the terminal does not report.
212216
213217 ## Bindings
@@ -43,6 +43,9 @@ ephemeral.close-on-exit = on
43 # Use the kitty keyboard protocol when the terminal supports it (enables Ctrl+1..9).43 # Use the kitty keyboard protocol when the terminal supports it (enables Ctrl+1..9).
44 keyboard.kitty-protocol = on44 keyboard.kitty-protocol = on
45 45
46+# Outer terminal colors: auto (detect), truecolor, 256, 16, or mono.
47+terminal.colors = auto
48+
46 # Rebindable actions. Leave a line out to keep its built-in binding.49 # Rebindable actions. Leave a line out to keep its built-in binding.
47 open-palette = alt+space50 open-palette = alt+space
48 toggle-floating-pane = alt+f51 toggle-floating-pane = alt+f
@@ -207,7 +210,8 @@ If the daemon refuses to start because its session data is broken, do not edit t
207 | `theme.accent = 0`–`15` | Use exactly that ANSI color as the accent. |210 | `theme.accent = 0`–`15` | Use exactly that ANSI color as the accent. |
208 211
209 - vev only colors its own UI (bars, borders, palette). Pane content is never recolored.212 - vev only colors its own UI (bars, borders, palette). Pane content is never recolored.
210-- Tinted backgrounds need a truecolor terminal. Otherwise the accent only colors text and borders.213+- Tinted backgrounds need a truecolor or 256-color terminal. On 16-color and monochrome terminals vev uses bold, reverse, and faint instead. See [terminal colors](terminal.md#colors).
214+- `terminal.colors` forces the color mode of your terminal (`auto`, `truecolor`, `256`, `16`, `mono`). `VEV_COLORS` overrides it. See [terminal colors](terminal.md#colors).
211 - vev follows light/dark switches your terminal reports. It cannot see palette changes the terminal does not report.215 - vev follows light/dark switches your terminal reports. It cannot see palette changes the terminal does not report.
212 216
213 ## Bindings217 ## Bindings
M docs/protocol.md +12 -0
@@ -88,6 +88,18 @@ 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. A `HEURISTIC` truecolor claim
98+upgrades a weaker daemon-side detection; any other `HEURISTIC` claim falls back
99+to detection.
100+(`internal/domain/terminalcap.Resolve`; wire tests in
101+`sessionwire/proto_convert_test.go:TestProtoHelloColorWire`.)
102+
91103 Command requests additionally carry a 10-second result deadline
92104 (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per
93105 connection and correlated by `RequestID`. Every dispatched request produces
@@ -88,6 +88,18 @@ 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. A `HEURISTIC` truecolor claim
98+upgrades a weaker daemon-side detection; any other `HEURISTIC` claim falls back
99+to detection.
100+(`internal/domain/terminalcap.Resolve`; wire tests in
101+`sessionwire/proto_convert_test.go:TestProtoHelloColorWire`.)
102+
91 Command requests additionally carry a 10-second result deadline103 Command requests additionally carry a 10-second result deadline
92 (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per104 (`daemon/command_tracker.go:CommandRequestTimeout`), tracked per
93 connection and correlated by `RequestID`. Every dispatched request produces105 connection and correlated by `RequestID`. Every dispatched request produces
M docs/terminal.md +49 -1
@@ -13,7 +13,55 @@ Both set `COLORTERM=truecolor` and `TERM_PROGRAM=vev`.
1313
1414 - vev checks with `infocmp -x xterm-direct` before each new pane (1 second timeout). Existing panes keep their `TERM`.
1515 - With `xterm-256color`, apps must read `COLORTERM` to use RGB colors.
16-- If your outer terminal does not support truecolor, vev converts colors to 256 and shows a one-time warning.
16+
17+### Outer terminal color modes
18+
19+vev picks a color mode for each attachment, so several clients on one session can use different modes. Modes: `truecolor`, `256`, `16`, `monochrome`.
20+
21+Detection reads the client's `TERM` and `COLORTERM`. The first matching row wins:
22+
23+| Order | Signal | Mode |
24+|---|---|---|
25+| 1 | `COLORTERM` is `truecolor` or `24bit`, or `TERM` is `xterm-direct` or ends in `-direct` | truecolor |
26+| 2 | `TERM=xterm-kitty` inside kitty | truecolor |
27+| 3 | `TERM` is `dumb`, `vt52`, `vt100`, `vt102`, `vt220`, or ends in `-m` or `-mono` | monochrome |
28+| 4 | `TERM` contains `256color` | 256 |
29+| 5 | `TERM` is `linux`, `ansi`, `cons25`, or ends in `-16color` or `-color` | 16 (8-color terminals use the first 8) |
30+| 6 | anything else | 256 (unverified guess) |
31+
32+When detection finds a terminal with fewer than truecolor colors, vev shows a one-time notice. The notice is not shown for the unverified guess or for a forced mode.
33+
34+### Override the mode
35+
36+| Where | Example | Notes |
37+|---|---|---|
38+| `VEV_COLORS` environment variable | `VEV_COLORS=16 vev` | Wins over the config key. An invalid value is ignored and logged as a warning. |
39+| `terminal.colors` in the [configuration](configuration.md) | `terminal.colors = mono` | Applies to every client that has no `VEV_COLORS`. |
40+
41+Values: `auto`, `truecolor`, `256`, `16`, `mono`. `auto` (or an empty value) means detection. A forced mode never shows the notice.
42+
43+`VEV_COLORS` and `terminal.colors` are read when the client starts; restart the client to apply a change. A config reload in the running daemon does not change attached clients.
44+
45+### What each mode changes
46+
47+Pane content:
48+
49+| Mode | Pane colors |
50+|---|---|
51+| truecolor | Unchanged. |
52+| 256 | RGB colors map to the xterm 256-color palette. |
53+| 16 | RGB and indexed colors 16-255 map to the nearest of the 16 ANSI colors. Underline color is dropped. |
54+| monochrome | All colors are dropped. Attributes (bold, underline, reverse, and so on) stay. |
55+
56+vev's own UI (bars, borders, pickers, overlays):
57+
58+| Mode | UI |
59+|---|---|
60+| truecolor, 256 | Full theme. |
61+| 16 | No tinted backgrounds. Active and selected items use bold and reverse, secondary text uses faint. The accent is a terminal palette slot. |
62+| monochrome | Same as 16 with no color at all. |
63+
64+The browser terminal and the headless `--ui-driver` always use truecolor, whatever `TERM`, `COLORTERM`, `VEV_COLORS`, or `terminal.colors` say.
1765
1866 ### Install `xterm-direct`
1967
@@ -13,7 +13,55 @@ Both set `COLORTERM=truecolor` and `TERM_PROGRAM=vev`.
13 13
14 - vev checks with `infocmp -x xterm-direct` before each new pane (1 second timeout). Existing panes keep their `TERM`.14 - vev checks with `infocmp -x xterm-direct` before each new pane (1 second timeout). Existing panes keep their `TERM`.
15 - With `xterm-256color`, apps must read `COLORTERM` to use RGB colors.15 - With `xterm-256color`, apps must read `COLORTERM` to use RGB colors.
16-- If your outer terminal does not support truecolor, vev converts colors to 256 and shows a one-time warning.16+
17+### Outer terminal color modes
18+
19+vev picks a color mode for each attachment, so several clients on one session can use different modes. Modes: `truecolor`, `256`, `16`, `monochrome`.
20+
21+Detection reads the client's `TERM` and `COLORTERM`. The first matching row wins:
22+
23+| Order | Signal | Mode |
24+|---|---|---|
25+| 1 | `COLORTERM` is `truecolor` or `24bit`, or `TERM` is `xterm-direct` or ends in `-direct` | truecolor |
26+| 2 | `TERM=xterm-kitty` inside kitty | truecolor |
27+| 3 | `TERM` is `dumb`, `vt52`, `vt100`, `vt102`, `vt220`, or ends in `-m` or `-mono` | monochrome |
28+| 4 | `TERM` contains `256color` | 256 |
29+| 5 | `TERM` is `linux`, `ansi`, `cons25`, or ends in `-16color` or `-color` | 16 (8-color terminals use the first 8) |
30+| 6 | anything else | 256 (unverified guess) |
31+
32+When detection finds a terminal with fewer than truecolor colors, vev shows a one-time notice. The notice is not shown for the unverified guess or for a forced mode.
33+
34+### Override the mode
35+
36+| Where | Example | Notes |
37+|---|---|---|
38+| `VEV_COLORS` environment variable | `VEV_COLORS=16 vev` | Wins over the config key. An invalid value is ignored and logged as a warning. |
39+| `terminal.colors` in the [configuration](configuration.md) | `terminal.colors = mono` | Applies to every client that has no `VEV_COLORS`. |
40+
41+Values: `auto`, `truecolor`, `256`, `16`, `mono`. `auto` (or an empty value) means detection. A forced mode never shows the notice.
42+
43+`VEV_COLORS` and `terminal.colors` are read when the client starts; restart the client to apply a change. A config reload in the running daemon does not change attached clients.
44+
45+### What each mode changes
46+
47+Pane content:
48+
49+| Mode | Pane colors |
50+|---|---|
51+| truecolor | Unchanged. |
52+| 256 | RGB colors map to the xterm 256-color palette. |
53+| 16 | RGB and indexed colors 16-255 map to the nearest of the 16 ANSI colors. Underline color is dropped. |
54+| monochrome | All colors are dropped. Attributes (bold, underline, reverse, and so on) stay. |
55+
56+vev's own UI (bars, borders, pickers, overlays):
57+
58+| Mode | UI |
59+|---|---|
60+| truecolor, 256 | Full theme. |
61+| 16 | No tinted backgrounds. Active and selected items use bold and reverse, secondary text uses faint. The accent is a terminal palette slot. |
62+| monochrome | Same as 16 with no color at all. |
63+
64+The browser terminal and the headless `--ui-driver` always use truecolor, whatever `TERM`, `COLORTERM`, `VEV_COLORS`, or `terminal.colors` say.
17 65
18 ### Install `xterm-direct`66 ### Install `xterm-direct`
19 67
M internal/adapters/config/config.go +10 -0
@@ -13,6 +13,7 @@ import (
1313 "time"
1414
1515 "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/domain/terminalcap"
1617 "github.com/bnema/vev/internal/ports"
1718 )
1819
@@ -37,6 +38,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3738 seenTabsKeys := make(map[string]bool)
3839 seenEphemeralKeys := make(map[string]bool)
3940 seenKeyboardKeys := make(map[string]bool)
41+ seenTerminalKeys := make(map[string]bool)
4042 seenScrollbackKeys := make(map[string]bool)
4143 seenWebKeys := make(map[string]bool)
4244
@@ -171,6 +173,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
171173 continue
172174 }
173175 cfg.Keyboard.KittyProtocol = on
176+ case key == "terminal.colors":
177+ warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo)
178+ mode, auto, err := terminalcap.ParseColorMode(value)
179+ if err != nil {
180+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid terminal.colors %q (want auto, truecolor, 256, 16, or mono)", value)})
181+ continue
182+ }
183+ cfg.Terminal = domain.TerminalConfig{Colors: mode, ColorsSet: !auto}
174184 case key == "copy.word-separators":
175185 warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
176186 separators, ok := parseConfigString(value)
@@ -13,6 +13,7 @@ import (
13 "time"13 "time"
14 14
15 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/domain/terminalcap"
16 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
17 )18 )
18 19
@@ -37,6 +38,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
37 seenTabsKeys := make(map[string]bool)38 seenTabsKeys := make(map[string]bool)
38 seenEphemeralKeys := make(map[string]bool)39 seenEphemeralKeys := make(map[string]bool)
39 seenKeyboardKeys := make(map[string]bool)40 seenKeyboardKeys := make(map[string]bool)
41+ seenTerminalKeys := make(map[string]bool)
40 seenScrollbackKeys := make(map[string]bool)42 seenScrollbackKeys := make(map[string]bool)
41 seenWebKeys := make(map[string]bool)43 seenWebKeys := make(map[string]bool)
42 44
@@ -171,6 +173,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
171 continue173 continue
172 }174 }
173 cfg.Keyboard.KittyProtocol = on175 cfg.Keyboard.KittyProtocol = on
176+ case key == "terminal.colors":
177+ warnings = warnDuplicateKey(warnings, seenTerminalKeys, key, lineNo)
178+ mode, auto, err := terminalcap.ParseColorMode(value)
179+ if err != nil {
180+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid terminal.colors %q (want auto, truecolor, 256, 16, or mono)", value)})
181+ continue
182+ }
183+ cfg.Terminal = domain.TerminalConfig{Colors: mode, ColorsSet: !auto}
174 case key == "copy.word-separators":184 case key == "copy.word-separators":
175 warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)185 warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo)
176 separators, ok := parseConfigString(value)186 separators, ok := parseConfigString(value)
A internal/adapters/config/terminal_colors_test.go +42 -0
@@ -0,0 +1,42 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/terminalcap"
11+)
12+
13+func TestParseTerminalColors(t *testing.T) {
14+ for _, tt := range []struct {
15+ name string
16+ input string
17+ want domain.TerminalConfig
18+ warnings int
19+ }{
20+ {"default is auto", "", domain.TerminalConfig{}, 0},
21+ {"auto", "terminal.colors = auto", domain.TerminalConfig{}, 0},
22+ {"truecolor", "terminal.colors = truecolor", domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, 0},
23+ {"256", "terminal.colors = 256", domain.TerminalConfig{Colors: terminalcap.ANSI256, ColorsSet: true}, 0},
24+ {"16", "terminal.colors = 16", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 0},
25+ {"mono", "terminal.colors = mono", domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, 0},
26+ {"case-insensitive", "terminal.colors = TrueColor", domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, 0},
27+ {"empty value is auto", "terminal.colors =", domain.TerminalConfig{}, 0},
28+ {"inline comment", "terminal.colors = 16 # linux console", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 0},
29+ {"invalid keeps default", "terminal.colors = rainbow", domain.TerminalConfig{}, 1},
30+ {"invalid keeps earlier valid", "terminal.colors = 16\nterminal.colors = rainbow", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 2},
31+ {"duplicate last wins with warning", "terminal.colors = 16\nterminal.colors = mono", domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, 1},
32+ {"later auto clears earlier override", "terminal.colors = 16\nterminal.colors = auto", domain.TerminalConfig{}, 1},
33+ } {
34+ t.Run(tt.name, func(t *testing.T) {
35+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
36+ require.NoError(t, err)
37+ require.Equal(t, tt.want, cfg.Terminal)
38+ require.Len(t, warnings, tt.warnings)
39+ require.Empty(t, cfg.BindingEntries)
40+ })
41+ }
42+}
@@ -0,0 +1,42 @@
1+package config
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/terminalcap"
11+)
12+
13+func TestParseTerminalColors(t *testing.T) {
14+ for _, tt := range []struct {
15+ name string
16+ input string
17+ want domain.TerminalConfig
18+ warnings int
19+ }{
20+ {"default is auto", "", domain.TerminalConfig{}, 0},
21+ {"auto", "terminal.colors = auto", domain.TerminalConfig{}, 0},
22+ {"truecolor", "terminal.colors = truecolor", domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, 0},
23+ {"256", "terminal.colors = 256", domain.TerminalConfig{Colors: terminalcap.ANSI256, ColorsSet: true}, 0},
24+ {"16", "terminal.colors = 16", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 0},
25+ {"mono", "terminal.colors = mono", domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, 0},
26+ {"case-insensitive", "terminal.colors = TrueColor", domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, 0},
27+ {"empty value is auto", "terminal.colors =", domain.TerminalConfig{}, 0},
28+ {"inline comment", "terminal.colors = 16 # linux console", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 0},
29+ {"invalid keeps default", "terminal.colors = rainbow", domain.TerminalConfig{}, 1},
30+ {"invalid keeps earlier valid", "terminal.colors = 16\nterminal.colors = rainbow", domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, 2},
31+ {"duplicate last wins with warning", "terminal.colors = 16\nterminal.colors = mono", domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, 1},
32+ {"later auto clears earlier override", "terminal.colors = 16\nterminal.colors = auto", domain.TerminalConfig{}, 1},
33+ } {
34+ t.Run(tt.name, func(t *testing.T) {
35+ cfg, warnings, err := Parse(strings.NewReader(tt.input))
36+ require.NoError(t, err)
37+ require.Equal(t, tt.want, cfg.Terminal)
38+ require.Len(t, warnings, tt.warnings)
39+ require.Empty(t, cfg.BindingEntries)
40+ })
41+ }
42+}
M internal/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/broker_client.go +82 -10
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "log/slog"
88 "os"
9+ "strings"
910 "sync"
1011 "time"
1112
@@ -283,7 +284,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
283284 if reader == nil {
284285 reader = clipboard.New()
285286 }
286- picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor)
287+ picker := client.NewPicker(clk, 0, attachmentEnv.Color)
287288 presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk}
288289 supervisor, err := client.NewSupervisor(client.SupervisorConfig{
289290 Logger: cfg.Logger,
@@ -612,20 +613,91 @@ var (
612613 // process environment themselves.
613614 func terminalAttachmentEnvironment() client.AttachmentEnvironment {
614615 return client.AttachmentEnvironment{
615- TermEnv: os.Getenv("TERM"),
616- Cwd: currentWorkingDirectory(),
617- TrueColor: terminalcap.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
618- PID: uint32(os.Getpid()),
616+ TermEnv: os.Getenv("TERM"),
617+ Cwd: currentWorkingDirectory(),
618+ Color: detectTerminalColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
619+ PID: uint32(os.Getpid()),
619620 }
620621 }
621622
622-// outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for
623-// a client that owns a real outer terminal: it probes that terminal once and
624-// applies the user's keyboard setting. Virtual terminals never probe.
625-func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
623+// detectTerminalColor derives the color capability from the process
624+// environment. Explicit TERM/COLORTERM values override env entries.
625+func detectTerminalColor(termEnv, colorTerm string, env []string) terminalcap.ColorCapabilities {
626+ detectionEnv := make([]string, 0, len(env)+2)
627+ detectionEnv = append(detectionEnv, env...)
628+ detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm)
629+ return terminalcap.Detect(detectionEnv).Color
630+}
631+
632+// envColors is the client-side color-mode override. It wins over the
633+// terminal.colors config key and is read by each client process; the daemon
634+// never sees it except as the Forced Hello.Color it produces.
635+const envColors = "VEV_COLORS"
636+
637+// virtualTerminalColor is the color capability declared by clients whose
638+// terminal is an in-process RGB-capable VT mirror (the browser terminal and the
639+// headless UI driver) rather than the gateway process' controlling terminal.
640+// It is forced so the gateway's own TERM/COLORTERM never downgrades them.
641+var virtualTerminalColor = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced}
642+
643+// virtualTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a
644+// client that renders into an RGB-capable virtual terminal: it declares forced
645+// truecolor instead of detecting from the gateway process environment.
646+func virtualTerminalAttachmentEnvironment() client.AttachmentEnvironment {
647+ env := terminalAttachmentEnvironment()
648+ env.Color = virtualTerminalColor
649+ return env
650+}
651+
652+// resolveColorOverride applies the explicit color override on top of the
653+// detected capability: a valid VEV_COLORS wins over terminal.colors, and
654+// "auto" at either level means detection. An empty VEV_COLORS is unset, so the
655+// config value still applies. An invalid VEV_COLORS is reported through log
656+// and ignored. A forced result carries SourceForced.
657+func resolveColorOverride(detected terminalcap.ColorCapabilities, envValue string, cfg domain.TerminalConfig, log *slog.Logger) terminalcap.ColorCapabilities {
658+ if strings.TrimSpace(envValue) != "" {
659+ mode, auto, err := terminalcap.ParseColorMode(envValue)
660+ switch {
661+ case err != nil:
662+ if log != nil {
663+ log.Warn("ignoring invalid "+envColors, "value", envValue, "err", err)
664+ }
665+ case auto:
666+ return detected
667+ default:
668+ return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
669+ }
670+ }
671+ if cfg.ColorsSet {
672+ return terminalcap.ColorCapabilities{Mode: cfg.Colors, Source: terminalcap.SourceForced}
673+ }
674+ return detected
675+}
676+
677+// userTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a
678+// client whose terminal is the user's real one: the detected color capability
679+// yields to VEV_COLORS and terminal.colors. The single resolved value feeds
680+// both the picker and every Hello.
681+func userTerminalAttachmentEnvironment(log *slog.Logger, cfg domain.TerminalConfig) client.AttachmentEnvironment {
626682 env := terminalAttachmentEnvironment()
683+ env.Color = resolveColorOverride(env.Color, os.Getenv(envColors), cfg, log)
684+ return env
685+}
686+
687+// configuredTerminalAttachmentEnvironment loads the user's config and returns
688+// userTerminalAttachmentEnvironment for it.
689+func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
690+ return userTerminalAttachmentEnvironment(log, loadConfigOrDefaults(log, platform.ConfigPath()).Terminal)
691+}
692+
693+// outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment
694+// for a client that owns a real outer terminal: it probes that terminal once
695+// and applies the user's keyboard setting. Virtual terminals never probe.
696+func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
697+ cfg := loadConfigOrDefaults(log, platform.ConfigPath())
698+ env := userTerminalAttachmentEnvironment(log, cfg.Terminal)
627699 env.ProbeTerminal = true
628- env.KittyKeyboard = loadConfigOrDefaults(log, platform.ConfigPath()).Keyboard.KittyProtocol
700+ env.KittyKeyboard = cfg.Keyboard.KittyProtocol
629701 return env
630702 }
631703
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "log/slog"7 "log/slog"
8 "os"8 "os"
9+ "strings"
9 "sync"10 "sync"
10 "time"11 "time"
11 12
@@ -283,7 +284,7 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
283 if reader == nil {284 if reader == nil {
284 reader = clipboard.New()285 reader = clipboard.New()
285 }286 }
286- picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor)287+ picker := client.NewPicker(clk, 0, attachmentEnv.Color)
287 presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk}288 presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState, clock: clk}
288 supervisor, err := client.NewSupervisor(client.SupervisorConfig{289 supervisor, err := client.NewSupervisor(client.SupervisorConfig{
289 Logger: cfg.Logger,290 Logger: cfg.Logger,
@@ -612,20 +613,91 @@ var (
612 // process environment themselves.613 // process environment themselves.
613 func terminalAttachmentEnvironment() client.AttachmentEnvironment {614 func terminalAttachmentEnvironment() client.AttachmentEnvironment {
614 return client.AttachmentEnvironment{615 return client.AttachmentEnvironment{
615- TermEnv: os.Getenv("TERM"),616+ TermEnv: os.Getenv("TERM"),
616- Cwd: currentWorkingDirectory(),617+ Cwd: currentWorkingDirectory(),
617- TrueColor: terminalcap.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),618+ Color: detectTerminalColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
618- PID: uint32(os.Getpid()),619+ PID: uint32(os.Getpid()),
619 }620 }
620 }621 }
621 622
622-// outerTerminalAttachmentEnvironment extends terminalAttachmentEnvironment for623+// detectTerminalColor derives the color capability from the process
623-// a client that owns a real outer terminal: it probes that terminal once and624+// environment. Explicit TERM/COLORTERM values override env entries.
624-// applies the user's keyboard setting. Virtual terminals never probe.625+func detectTerminalColor(termEnv, colorTerm string, env []string) terminalcap.ColorCapabilities {
625-func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {626+ detectionEnv := make([]string, 0, len(env)+2)
627+ detectionEnv = append(detectionEnv, env...)
628+ detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm)
629+ return terminalcap.Detect(detectionEnv).Color
630+}
631+
632+// envColors is the client-side color-mode override. It wins over the
633+// terminal.colors config key and is read by each client process; the daemon
634+// never sees it except as the Forced Hello.Color it produces.
635+const envColors = "VEV_COLORS"
636+
637+// virtualTerminalColor is the color capability declared by clients whose
638+// terminal is an in-process RGB-capable VT mirror (the browser terminal and the
639+// headless UI driver) rather than the gateway process' controlling terminal.
640+// It is forced so the gateway's own TERM/COLORTERM never downgrades them.
641+var virtualTerminalColor = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced}
642+
643+// virtualTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a
644+// client that renders into an RGB-capable virtual terminal: it declares forced
645+// truecolor instead of detecting from the gateway process environment.
646+func virtualTerminalAttachmentEnvironment() client.AttachmentEnvironment {
647+ env := terminalAttachmentEnvironment()
648+ env.Color = virtualTerminalColor
649+ return env
650+}
651+
652+// resolveColorOverride applies the explicit color override on top of the
653+// detected capability: a valid VEV_COLORS wins over terminal.colors, and
654+// "auto" at either level means detection. An empty VEV_COLORS is unset, so the
655+// config value still applies. An invalid VEV_COLORS is reported through log
656+// and ignored. A forced result carries SourceForced.
657+func resolveColorOverride(detected terminalcap.ColorCapabilities, envValue string, cfg domain.TerminalConfig, log *slog.Logger) terminalcap.ColorCapabilities {
658+ if strings.TrimSpace(envValue) != "" {
659+ mode, auto, err := terminalcap.ParseColorMode(envValue)
660+ switch {
661+ case err != nil:
662+ if log != nil {
663+ log.Warn("ignoring invalid "+envColors, "value", envValue, "err", err)
664+ }
665+ case auto:
666+ return detected
667+ default:
668+ return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
669+ }
670+ }
671+ if cfg.ColorsSet {
672+ return terminalcap.ColorCapabilities{Mode: cfg.Colors, Source: terminalcap.SourceForced}
673+ }
674+ return detected
675+}
676+
677+// userTerminalAttachmentEnvironment is terminalAttachmentEnvironment for a
678+// client whose terminal is the user's real one: the detected color capability
679+// yields to VEV_COLORS and terminal.colors. The single resolved value feeds
680+// both the picker and every Hello.
681+func userTerminalAttachmentEnvironment(log *slog.Logger, cfg domain.TerminalConfig) client.AttachmentEnvironment {
626 env := terminalAttachmentEnvironment()682 env := terminalAttachmentEnvironment()
683+ env.Color = resolveColorOverride(env.Color, os.Getenv(envColors), cfg, log)
684+ return env
685+}
686+
687+// configuredTerminalAttachmentEnvironment loads the user's config and returns
688+// userTerminalAttachmentEnvironment for it.
689+func configuredTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
690+ return userTerminalAttachmentEnvironment(log, loadConfigOrDefaults(log, platform.ConfigPath()).Terminal)
691+}
692+
693+// outerTerminalAttachmentEnvironment extends userTerminalAttachmentEnvironment
694+// for a client that owns a real outer terminal: it probes that terminal once
695+// and applies the user's keyboard setting. Virtual terminals never probe.
696+func outerTerminalAttachmentEnvironment(log *slog.Logger) client.AttachmentEnvironment {
697+ cfg := loadConfigOrDefaults(log, platform.ConfigPath())
698+ env := userTerminalAttachmentEnvironment(log, cfg.Terminal)
627 env.ProbeTerminal = true699 env.ProbeTerminal = true
628- env.KittyKeyboard = loadConfigOrDefaults(log, platform.ConfigPath()).Keyboard.KittyProtocol700+ env.KittyKeyboard = cfg.Keyboard.KittyProtocol
629 return env701 return env
630 }702 }
631 703
M internal/app/broker_client_test.go +99 -0
@@ -3,9 +3,11 @@
33 package app
44
55 import (
6+ "bytes"
67 "context"
78 "errors"
89 "io"
10+ "log/slog"
911 "os"
1012 "slices"
1113 "sync"
@@ -18,6 +20,7 @@ import (
1820 "github.com/bnema/vev/internal/adapters/brokeripc"
1921 "github.com/bnema/vev/internal/adapters/clock"
2022 "github.com/bnema/vev/internal/domain"
23+ "github.com/bnema/vev/internal/domain/terminalcap"
2124 "github.com/bnema/vev/internal/ports"
2225 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2326 "github.com/bnema/vev/internal/protocol"
@@ -1162,3 +1165,99 @@ func awaitTerminalCompositionEpoch(t *testing.T, service ports.BrokerService) po
11621165 }
11631166 }
11641167 }
1168+
1169+func TestDetectTerminalColor(t *testing.T) {
1170+ tests := []struct {
1171+ name string
1172+ term string
1173+ colorTerm string
1174+ env []string
1175+ want terminalcap.ColorCapabilities
1176+ }{
1177+ {name: "kitty environment keeps heuristic truecolor", term: "xterm-kitty", env: []string{"KITTY_WINDOW_ID=1"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceHeuristic}},
1178+ {name: "explicit COLORTERM overrides env", term: "xterm-256color", colorTerm: "truecolor", env: []string{"COLORTERM="}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}},
1179+ {name: "explicit TERM overrides env", term: "xterm-256color", env: []string{"TERM=foot-direct"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}},
1180+ {name: "unknown terminal is indexed", term: "unknown", want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
1181+ }
1182+ for _, tt := range tests {
1183+ t.Run(tt.name, func(t *testing.T) {
1184+ require.Equal(t, tt.want, detectTerminalColor(tt.term, tt.colorTerm, tt.env))
1185+ })
1186+ }
1187+}
1188+
1189+func TestResolveColorOverride(t *testing.T) {
1190+ detected := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}
1191+ forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities {
1192+ return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
1193+ }
1194+ tests := []struct {
1195+ name string
1196+ env string
1197+ cfg domain.TerminalConfig
1198+ want terminalcap.ColorCapabilities
1199+ wantWarn bool
1200+ }{
1201+ {name: "nothing set uses detection", want: detected},
1202+ {name: "config auto uses detection", cfg: domain.TerminalConfig{}, want: detected},
1203+ {name: "config forces mode", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)},
1204+ {name: "config forces truecolor", cfg: domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, want: forced(terminalcap.TrueColor)},
1205+ {name: "env forces mode", env: "mono", want: forced(terminalcap.Monochrome)},
1206+ {name: "env is case-insensitive and trimmed", env: " TrueColor ", want: forced(terminalcap.TrueColor)},
1207+ {name: "env wins over config", env: "256", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: forced(terminalcap.ANSI256)},
1208+ {name: "env auto overrides config with detection", env: "auto", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: detected},
1209+ {name: "blank env falls through to config", env: " ", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)},
1210+ {name: "invalid env falls back to config with a warning", env: "rainbow", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16), wantWarn: true},
1211+ {name: "invalid env falls back to detection with a warning", env: "24bit", want: detected, wantWarn: true},
1212+ }
1213+ for _, tt := range tests {
1214+ t.Run(tt.name, func(t *testing.T) {
1215+ var logs bytes.Buffer
1216+ log := slog.New(slog.NewTextHandler(&logs, nil))
1217+ require.Equal(t, tt.want, resolveColorOverride(detected, tt.env, tt.cfg, log))
1218+ if tt.wantWarn {
1219+ require.Contains(t, logs.String(), "VEV_COLORS")
1220+ } else {
1221+ require.Empty(t, logs.String())
1222+ }
1223+ })
1224+ }
1225+ t.Run("nil logger tolerates invalid env", func(t *testing.T) {
1226+ require.Equal(t, detected, resolveColorOverride(detected, "rainbow", domain.TerminalConfig{}, nil))
1227+ })
1228+}
1229+
1230+func TestTerminalAttachmentEnvironmentColor(t *testing.T) {
1231+ forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities {
1232+ return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
1233+ }
1234+ tests := []struct {
1235+ name string
1236+ term string
1237+ colors string
1238+ cfg domain.TerminalConfig
1239+ wantUser terminalcap.ColorCapabilities
1240+ }{
1241+ {name: "dumb process terminal", term: "dumb", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}},
1242+ {name: "linux console", term: "linux", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}},
1243+ {name: "env overrides process terminal", term: "dumb", colors: "truecolor", wantUser: forced(terminalcap.TrueColor)},
1244+ {name: "config overrides process terminal", term: "xterm-256color", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, wantUser: forced(terminalcap.ANSI16)},
1245+ }
1246+ for _, tt := range tests {
1247+ t.Run(tt.name, func(t *testing.T) {
1248+ t.Setenv("TERM", tt.term)
1249+ t.Setenv("COLORTERM", "")
1250+ t.Setenv("KITTY_WINDOW_ID", "")
1251+ t.Setenv("KITTY_PID", "")
1252+ t.Setenv("KITTY_LISTEN_ON", "")
1253+ t.Setenv(envColors, tt.colors)
1254+
1255+ require.Equal(t, tt.wantUser, userTerminalAttachmentEnvironment(nil, tt.cfg).Color)
1256+ // Web and headless UI-driver clients render into RGB VT mirrors and
1257+ // ignore both the gateway process terminal and the user override.
1258+ virtual := virtualTerminalAttachmentEnvironment()
1259+ require.Equal(t, forced(terminalcap.TrueColor), virtual.Color)
1260+ require.False(t, virtual.ProbeTerminal)
1261+ })
1262+ }
1263+}
@@ -3,9 +3,11 @@
3 package app3 package app
4 4
5 import (5 import (
6+ "bytes"
6 "context"7 "context"
7 "errors"8 "errors"
8 "io"9 "io"
10+ "log/slog"
9 "os"11 "os"
10 "slices"12 "slices"
11 "sync"13 "sync"
@@ -18,6 +20,7 @@ import (
18 "github.com/bnema/vev/internal/adapters/brokeripc"20 "github.com/bnema/vev/internal/adapters/brokeripc"
19 "github.com/bnema/vev/internal/adapters/clock"21 "github.com/bnema/vev/internal/adapters/clock"
20 "github.com/bnema/vev/internal/domain"22 "github.com/bnema/vev/internal/domain"
23+ "github.com/bnema/vev/internal/domain/terminalcap"
21 "github.com/bnema/vev/internal/ports"24 "github.com/bnema/vev/internal/ports"
22 portsmocks "github.com/bnema/vev/internal/ports/mocks"25 portsmocks "github.com/bnema/vev/internal/ports/mocks"
23 "github.com/bnema/vev/internal/protocol"26 "github.com/bnema/vev/internal/protocol"
@@ -1162,3 +1165,99 @@ func awaitTerminalCompositionEpoch(t *testing.T, service ports.BrokerService) po
1162 }1165 }
1163 }1166 }
1164 }1167 }
1168+
1169+func TestDetectTerminalColor(t *testing.T) {
1170+ tests := []struct {
1171+ name string
1172+ term string
1173+ colorTerm string
1174+ env []string
1175+ want terminalcap.ColorCapabilities
1176+ }{
1177+ {name: "kitty environment keeps heuristic truecolor", term: "xterm-kitty", env: []string{"KITTY_WINDOW_ID=1"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceHeuristic}},
1178+ {name: "explicit COLORTERM overrides env", term: "xterm-256color", colorTerm: "truecolor", env: []string{"COLORTERM="}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}},
1179+ {name: "explicit TERM overrides env", term: "xterm-256color", env: []string{"TERM=foot-direct"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}},
1180+ {name: "unknown terminal is indexed", term: "unknown", want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
1181+ }
1182+ for _, tt := range tests {
1183+ t.Run(tt.name, func(t *testing.T) {
1184+ require.Equal(t, tt.want, detectTerminalColor(tt.term, tt.colorTerm, tt.env))
1185+ })
1186+ }
1187+}
1188+
1189+func TestResolveColorOverride(t *testing.T) {
1190+ detected := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}
1191+ forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities {
1192+ return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
1193+ }
1194+ tests := []struct {
1195+ name string
1196+ env string
1197+ cfg domain.TerminalConfig
1198+ want terminalcap.ColorCapabilities
1199+ wantWarn bool
1200+ }{
1201+ {name: "nothing set uses detection", want: detected},
1202+ {name: "config auto uses detection", cfg: domain.TerminalConfig{}, want: detected},
1203+ {name: "config forces mode", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)},
1204+ {name: "config forces truecolor", cfg: domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, want: forced(terminalcap.TrueColor)},
1205+ {name: "env forces mode", env: "mono", want: forced(terminalcap.Monochrome)},
1206+ {name: "env is case-insensitive and trimmed", env: " TrueColor ", want: forced(terminalcap.TrueColor)},
1207+ {name: "env wins over config", env: "256", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: forced(terminalcap.ANSI256)},
1208+ {name: "env auto overrides config with detection", env: "auto", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: detected},
1209+ {name: "blank env falls through to config", env: " ", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)},
1210+ {name: "invalid env falls back to config with a warning", env: "rainbow", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16), wantWarn: true},
1211+ {name: "invalid env falls back to detection with a warning", env: "24bit", want: detected, wantWarn: true},
1212+ }
1213+ for _, tt := range tests {
1214+ t.Run(tt.name, func(t *testing.T) {
1215+ var logs bytes.Buffer
1216+ log := slog.New(slog.NewTextHandler(&logs, nil))
1217+ require.Equal(t, tt.want, resolveColorOverride(detected, tt.env, tt.cfg, log))
1218+ if tt.wantWarn {
1219+ require.Contains(t, logs.String(), "VEV_COLORS")
1220+ } else {
1221+ require.Empty(t, logs.String())
1222+ }
1223+ })
1224+ }
1225+ t.Run("nil logger tolerates invalid env", func(t *testing.T) {
1226+ require.Equal(t, detected, resolveColorOverride(detected, "rainbow", domain.TerminalConfig{}, nil))
1227+ })
1228+}
1229+
1230+func TestTerminalAttachmentEnvironmentColor(t *testing.T) {
1231+ forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities {
1232+ return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
1233+ }
1234+ tests := []struct {
1235+ name string
1236+ term string
1237+ colors string
1238+ cfg domain.TerminalConfig
1239+ wantUser terminalcap.ColorCapabilities
1240+ }{
1241+ {name: "dumb process terminal", term: "dumb", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}},
1242+ {name: "linux console", term: "linux", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}},
1243+ {name: "env overrides process terminal", term: "dumb", colors: "truecolor", wantUser: forced(terminalcap.TrueColor)},
1244+ {name: "config overrides process terminal", term: "xterm-256color", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, wantUser: forced(terminalcap.ANSI16)},
1245+ }
1246+ for _, tt := range tests {
1247+ t.Run(tt.name, func(t *testing.T) {
1248+ t.Setenv("TERM", tt.term)
1249+ t.Setenv("COLORTERM", "")
1250+ t.Setenv("KITTY_WINDOW_ID", "")
1251+ t.Setenv("KITTY_PID", "")
1252+ t.Setenv("KITTY_LISTEN_ON", "")
1253+ t.Setenv(envColors, tt.colors)
1254+
1255+ require.Equal(t, tt.wantUser, userTerminalAttachmentEnvironment(nil, tt.cfg).Color)
1256+ // Web and headless UI-driver clients render into RGB VT mirrors and
1257+ // ignore both the gateway process terminal and the user override.
1258+ virtual := virtualTerminalAttachmentEnvironment()
1259+ require.Equal(t, forced(terminalcap.TrueColor), virtual.Color)
1260+ require.False(t, virtual.ProbeTerminal)
1261+ })
1262+ }
1263+}
M internal/app/broker_offline_client_render_test.go +2 -1
@@ -12,6 +12,7 @@ import (
1212 "github.com/stretchr/testify/require"
1313
1414 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/terminalcap"
1516 "github.com/bnema/vev/internal/ports"
1617 "github.com/bnema/vev/internal/protocol"
1718 "github.com/bnema/vev/internal/protocol/catalogue"
@@ -116,7 +117,7 @@ func (w *offlineRenderTerminalWriter) Write(data []byte) (int, error) {
116117 // daemon with a single session, so Render produces a real frame at any size.
117118 func offlineRenderPicker(t *testing.T) *client.Picker {
118119 t.Helper()
119- picker := client.NewPicker(nil, 0, true)
120+ picker := client.NewPicker(nil, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
120121 picker.ApplySnapshot(ports.BrokerSnapshot{
121122 Epoch: 1,
122123 Revision: 1,
@@ -12,6 +12,7 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/terminalcap"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/protocol"17 "github.com/bnema/vev/internal/protocol"
17 "github.com/bnema/vev/internal/protocol/catalogue"18 "github.com/bnema/vev/internal/protocol/catalogue"
@@ -116,7 +117,7 @@ func (w *offlineRenderTerminalWriter) Write(data []byte) (int, error) {
116 // daemon with a single session, so Render produces a real frame at any size.117 // daemon with a single session, so Render produces a real frame at any size.
117 func offlineRenderPicker(t *testing.T) *client.Picker {118 func offlineRenderPicker(t *testing.T) *client.Picker {
118 t.Helper()119 t.Helper()
119- picker := client.NewPicker(nil, 0, true)120+ picker := client.NewPicker(nil, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
120 picker.ApplySnapshot(ports.BrokerSnapshot{121 picker.ApplySnapshot(ports.BrokerSnapshot{
121 Epoch: 1,122 Epoch: 1,
122 Revision: 1,123 Revision: 1,
M internal/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/app/ui_driver.go +2 -2
@@ -240,7 +240,7 @@ func runHeadlessUIDriver(ctx context.Context, options uiDriverOptions) error {
240240 defer terminal.Close()
241241 ui := client.NewUI(terminal, clk)
242242 sessionEnv := terminalSessionEnvironment()
243- attachmentEnv := terminalAttachmentEnvironment()
243+ attachmentEnv := virtualTerminalAttachmentEnvironment()
244244 attachmentEnv.Cwd = sessionEnv.Cwd
245245 return runUIDriverClient(ctx, brokerClientConfig{
246246 Logger: log,
@@ -495,7 +495,7 @@ func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget
495495 return err
496496 }
497497 sessionEnv := terminalSessionEnvironment()
498- attachmentEnv := terminalAttachmentEnvironment()
498+ attachmentEnv := configuredTerminalAttachmentEnvironment(log)
499499 attachmentEnv.Cwd = sessionEnv.Cwd
500500 return runBrokerClient(ctx, brokerClientConfig{
501501 Logger: log,
@@ -240,7 +240,7 @@ func runHeadlessUIDriver(ctx context.Context, options uiDriverOptions) error {
240 defer terminal.Close()240 defer terminal.Close()
241 ui := client.NewUI(terminal, clk)241 ui := client.NewUI(terminal, clk)
242 sessionEnv := terminalSessionEnvironment()242 sessionEnv := terminalSessionEnvironment()
243- attachmentEnv := terminalAttachmentEnvironment()243+ attachmentEnv := virtualTerminalAttachmentEnvironment()
244 attachmentEnv.Cwd = sessionEnv.Cwd244 attachmentEnv.Cwd = sessionEnv.Cwd
245 return runUIDriverClient(ctx, brokerClientConfig{245 return runUIDriverClient(ctx, brokerClientConfig{
246 Logger: log,246 Logger: log,
@@ -495,7 +495,7 @@ func runAttachWithOptions(ctx context.Context, intent uint8, name, remoteTarget
495 return err495 return err
496 }496 }
497 sessionEnv := terminalSessionEnvironment()497 sessionEnv := terminalSessionEnvironment()
498- attachmentEnv := terminalAttachmentEnvironment()498+ attachmentEnv := configuredTerminalAttachmentEnvironment(log)
499 attachmentEnv.Cwd = sessionEnv.Cwd499 attachmentEnv.Cwd = sessionEnv.Cwd
500 return runBrokerClient(ctx, brokerClientConfig{500 return runBrokerClient(ctx, brokerClientConfig{
501 Logger: log,501 Logger: log,
M internal/app/web_daemon.go +1 -1
@@ -194,7 +194,7 @@ func (webTerminal) PublishContext(ports.UIContext) error { return ports.ErrUIUna
194194 func runWebTerminalClient(ctx context.Context, terminal *webterm.Terminal) error {
195195 callbacks := terminalBrokerCallbacks()
196196 sessionEnv := terminalSessionEnvironment()
197- attachmentEnv := terminalAttachmentEnvironment()
197+ attachmentEnv := virtualTerminalAttachmentEnvironment()
198198 attachmentEnv.Cwd = sessionEnv.Cwd
199199 return runBrokerClient(ctx, brokerClientConfig{
200200 Connector: webTerminalConnector(),
@@ -194,7 +194,7 @@ func (webTerminal) PublishContext(ports.UIContext) error { return ports.ErrUIUna
194 func runWebTerminalClient(ctx context.Context, terminal *webterm.Terminal) error {194 func runWebTerminalClient(ctx context.Context, terminal *webterm.Terminal) error {
195 callbacks := terminalBrokerCallbacks()195 callbacks := terminalBrokerCallbacks()
196 sessionEnv := terminalSessionEnvironment()196 sessionEnv := terminalSessionEnvironment()
197- attachmentEnv := terminalAttachmentEnvironment()197+ attachmentEnv := virtualTerminalAttachmentEnvironment()
198 attachmentEnv.Cwd = sessionEnv.Cwd198 attachmentEnv.Cwd = sessionEnv.Cwd
199 return runBrokerClient(ctx, brokerClientConfig{199 return runBrokerClient(ctx, brokerClientConfig{
200 Connector: webTerminalConnector(),200 Connector: webTerminalConnector(),
M internal/domain/config.go +16 -1
@@ -1,6 +1,10 @@
11 package domain
22
3-import "time"
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/domain/terminalcap"
7+)
48
59 // MinBarInterval is the minimum supported bar refresh interval.
610 const MinBarInterval = time.Second
@@ -98,6 +102,16 @@ type KeyboardConfig struct {
98102 KittyProtocol bool
99103 }
100104
105+// TerminalConfig contains client-side outer-terminal settings. It is read by
106+// each client at startup and never applied by the daemon, so a change takes
107+// effect on the next client process.
108+type TerminalConfig struct {
109+ // Colors is the forced color mode when ColorsSet is true. A false
110+ // ColorsSet (terminal.colors = auto, the default) uses detection.
111+ Colors terminalcap.ColorMode
112+ ColorsSet bool
113+}
114+
101115 // ScrollbackConfig is vev's per-pane retention policy. The terminal library
102116 // only enforces these application-selected limits. Zero megabytes disables
103117 // history; zero lines removes the additional line ceiling.
@@ -139,6 +153,7 @@ type Config struct {
139153 Tabs TabsConfig
140154 Ephemeral EphemeralConfig
141155 Keyboard KeyboardConfig
156+ Terminal TerminalConfig
142157 Scrollback ScrollbackConfig
143158 }
144159
@@ -1,6 +1,10 @@
1 package domain1 package domain
2 2
3-import "time"3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/domain/terminalcap"
7+)
4 8
5 // MinBarInterval is the minimum supported bar refresh interval.9 // MinBarInterval is the minimum supported bar refresh interval.
6 const MinBarInterval = time.Second10 const MinBarInterval = time.Second
@@ -98,6 +102,16 @@ type KeyboardConfig struct {
98 KittyProtocol bool102 KittyProtocol bool
99 }103 }
100 104
105+// TerminalConfig contains client-side outer-terminal settings. It is read by
106+// each client at startup and never applied by the daemon, so a change takes
107+// effect on the next client process.
108+type TerminalConfig struct {
109+ // Colors is the forced color mode when ColorsSet is true. A false
110+ // ColorsSet (terminal.colors = auto, the default) uses detection.
111+ Colors terminalcap.ColorMode
112+ ColorsSet bool
113+}
114+
101 // ScrollbackConfig is vev's per-pane retention policy. The terminal library115 // ScrollbackConfig is vev's per-pane retention policy. The terminal library
102 // only enforces these application-selected limits. Zero megabytes disables116 // only enforces these application-selected limits. Zero megabytes disables
103 // history; zero lines removes the additional line ceiling.117 // history; zero lines removes the additional line ceiling.
@@ -139,6 +153,7 @@ type Config struct {
139 Tabs TabsConfig153 Tabs TabsConfig
140 Ephemeral EphemeralConfig154 Ephemeral EphemeralConfig
141 Keyboard KeyboardConfig155 Keyboard KeyboardConfig
156+ Terminal TerminalConfig
142 Scrollback ScrollbackConfig157 Scrollback ScrollbackConfig
143 }158 }
144 159
M internal/domain/terminalcap/capabilities.go +102 -25
@@ -1,7 +1,10 @@
11 // Package terminalcap owns pure terminal capability values and detection policy.
22 package terminalcap
33
4-import "strings"
4+import (
5+ "fmt"
6+ "strings"
7+)
58
69 // ColorMode is the color output mode selected for one attachment.
710 type ColorMode uint8
@@ -10,9 +13,27 @@ const (
1013 // TrueColor is the zero value so manually constructed attachments retain the
1114 // historical renderer behavior; live Hello paths always use the detector.
1215 TrueColor ColorMode = iota
13- Indexed256
16+ ANSI256
17+ ANSI16
18+ Monochrome
1419 )
1520
21+// Valid reports whether m is one of the defined color modes.
22+func (m ColorMode) Valid() bool { return m <= Monochrome }
23+
24+// ColorCapabilities is the color output capability selected for one attachment
25+// and how confidently it was selected.
26+type ColorCapabilities struct {
27+ Mode ColorMode
28+ Source Source
29+}
30+
31+// Valid reports whether both the mode and the source are defined values.
32+func (c ColorCapabilities) Valid() bool { return c.Mode.Valid() && c.Source.Valid() }
33+
34+// RGB reports whether the attachment can receive RGB ANSI output.
35+func (c ColorCapabilities) RGB() bool { return c.Mode == TrueColor }
36+
1637 // Source records how confidently a terminal capability was selected.
1738 type Source uint8
1839
@@ -20,8 +41,14 @@ const (
2041 SourceUnknown Source = iota
2142 SourceHeuristic
2243 SourceDeclared
44+ // SourceForced is an explicit user override that detection must not
45+ // second-guess and that never warrants a downgrade notice.
46+ SourceForced
2347 )
2448
49+// Valid reports whether s is one of the defined sources.
50+func (s Source) Valid() bool { return s <= SourceForced }
51+
2552 // Application identifies a known terminal application when its environment
2653 // provides a trustworthy origin signal.
2754 type Application uint8
@@ -33,8 +60,7 @@ const (
3360
3461 // Capabilities describes the output features selected for one client attachment.
3562 type Capabilities struct {
36- ColorMode ColorMode
37- ColorSource Source
63+ Color ColorCapabilities
3864 Application Application
3965 KittyGraphics bool
4066 }
@@ -43,15 +69,12 @@ type Capabilities struct {
4369 // accepted the Kitty graphics protocol. Environment detection never sets it.
4470 func (c Capabilities) SupportsKittyGraphics() bool { return c.KittyGraphics }
4571
46-// TrueColor reports whether this attachment can receive RGB ANSI output.
47-func (c Capabilities) TrueColor() bool { return c.ColorMode == TrueColor }
48-
4972 // Detect derives conservative attachment capabilities from a client environment.
5073 func Detect(env []string) Capabilities {
5174 values := environmentValues(env)
5275 term := strings.ToLower(strings.TrimSpace(values["TERM"]))
5376 colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"]))
54- caps := Capabilities{ColorMode: Indexed256}
77+ caps := Capabilities{Color: ColorCapabilities{Mode: ANSI256}}
5578
5679 if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" {
5780 caps.Application = ApplicationKitty
@@ -60,29 +83,76 @@ func Detect(env []string) Capabilities {
6083 kittyIdentity := term == "xterm-kitty" && caps.Application == ApplicationKitty
6184 switch colorTerm {
6285 case "truecolor", "24bit":
63- caps.ColorMode = TrueColor
64- caps.ColorSource = SourceDeclared
86+ caps.Color.Mode = TrueColor
87+ caps.Color.Source = SourceDeclared
6588 }
6689 if term == "xterm-direct" || strings.HasSuffix(term, "-direct") {
67- caps.ColorMode = TrueColor
68- caps.ColorSource = SourceDeclared
90+ caps.Color.Mode = TrueColor
91+ caps.Color.Source = SourceDeclared
6992 }
7093 if kittyIdentity {
71- if caps.ColorSource == SourceUnknown {
72- caps.ColorSource = SourceHeuristic
94+ if caps.Color.Source == SourceUnknown {
95+ caps.Color.Source = SourceHeuristic
7396 }
74- caps.ColorMode = TrueColor
97+ caps.Color.Mode = TrueColor
7598 return caps
7699 }
77- if caps.ColorSource == SourceDeclared {
100+ if caps.Color.Source == SourceDeclared {
78101 return caps
79102 }
80- if strings.Contains(term, "256color") || term == "dumb" {
81- caps.ColorSource = SourceDeclared
103+ if mode, ok := termColorMode(term); ok {
104+ caps.Color.Mode = mode
105+ caps.Color.Source = SourceDeclared
82106 }
83107 return caps
84108 }
85109
110+// termColorMode classifies a lowercase TERM value that carries no truecolor
111+// signal. It is deliberately conservative: only well-known terminfo names map
112+// to a constrained mode, and anything else reports ok=false so the caller keeps
113+// its unverified 256-color default. The rules apply in this order:
114+//
115+// - monochrome: dumb, vt52, vt100, vt102, vt220, or a name ending in -m or -mono;
116+// - 256 colors: any name containing "256color";
117+// - 16 colors: linux, ansi, cons25, or a name ending in -16color or -color.
118+func termColorMode(term string) (ColorMode, bool) {
119+ switch term {
120+ case "dumb", "vt52", "vt100", "vt102", "vt220":
121+ return Monochrome, true
122+ case "linux", "ansi", "cons25":
123+ return ANSI16, true
124+ }
125+ switch {
126+ case strings.HasSuffix(term, "-m"), strings.HasSuffix(term, "-mono"):
127+ return Monochrome, true
128+ case strings.Contains(term, "256color"):
129+ return ANSI256, true
130+ case strings.HasSuffix(term, "-16color"), strings.HasSuffix(term, "-color"):
131+ return ANSI16, true
132+ }
133+ return 0, false
134+}
135+
136+// ParseColorMode parses a user color override: auto, truecolor, 256, 16, or
137+// mono, case-insensitively and ignoring surrounding space. An empty value is
138+// auto. When auto is true the caller must use detection and mode is
139+// meaningless; an unknown value returns an error.
140+func ParseColorMode(value string) (mode ColorMode, auto bool, err error) {
141+ switch strings.ToLower(strings.TrimSpace(value)) {
142+ case "", "auto":
143+ return TrueColor, true, nil
144+ case "truecolor":
145+ return TrueColor, false, nil
146+ case "256":
147+ return ANSI256, false, nil
148+ case "16":
149+ return ANSI16, false, nil
150+ case "mono":
151+ return Monochrome, false, nil
152+ }
153+ return TrueColor, false, fmt.Errorf("invalid color mode %q (want auto, truecolor, 256, 16, or mono)", value)
154+}
155+
86156 func environmentValues(env []string) map[string]string {
87157 values := make(map[string]string, len(env))
88158 for _, entry := range env {
@@ -94,11 +164,18 @@ func environmentValues(env []string) map[string]string {
94164 return values
95165 }
96166
97-// DetectTrueColor reports whether the supplied terminal environment advertises
98-// direct color support. Explicit TERM/COLORTERM values override env entries.
99-func DetectTrueColor(termEnv, colorTerm string, env []string) bool {
100- detectionEnv := make([]string, 0, len(env)+2)
101- detectionEnv = append(detectionEnv, env...)
102- detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm)
103- return Detect(detectionEnv).TrueColor()
167+// Resolve selects attachment capabilities from the client environment and the
168+// client's color claim. A declared or forced client color is used verbatim. A
169+// heuristic truecolor claim upgrades a weaker daemon-side detection, because
170+// the daemon may not see the client environment that produced the inference.
171+// Any other claim falls back to environment detection.
172+func Resolve(env []string, declared ColorCapabilities) Capabilities {
173+ caps := Detect(env)
174+ switch {
175+ case declared.Source == SourceDeclared || declared.Source == SourceForced:
176+ caps.Color = declared
177+ case declared.Source == SourceHeuristic && declared.RGB() && !caps.Color.RGB():
178+ caps.Color = declared
179+ }
180+ return caps
104181 }
@@ -1,7 +1,10 @@
1 // Package terminalcap owns pure terminal capability values and detection policy.1 // Package terminalcap owns pure terminal capability values and detection policy.
2 package terminalcap2 package terminalcap
3 3
4-import "strings"4+import (
5+ "fmt"
6+ "strings"
7+)
5 8
6 // ColorMode is the color output mode selected for one attachment.9 // ColorMode is the color output mode selected for one attachment.
7 type ColorMode uint810 type ColorMode uint8
@@ -10,9 +13,27 @@ const (
10 // TrueColor is the zero value so manually constructed attachments retain the13 // TrueColor is the zero value so manually constructed attachments retain the
11 // historical renderer behavior; live Hello paths always use the detector.14 // historical renderer behavior; live Hello paths always use the detector.
12 TrueColor ColorMode = iota15 TrueColor ColorMode = iota
13- Indexed25616+ ANSI256
17+ ANSI16
18+ Monochrome
14 )19 )
15 20
21+// Valid reports whether m is one of the defined color modes.
22+func (m ColorMode) Valid() bool { return m <= Monochrome }
23+
24+// ColorCapabilities is the color output capability selected for one attachment
25+// and how confidently it was selected.
26+type ColorCapabilities struct {
27+ Mode ColorMode
28+ Source Source
29+}
30+
31+// Valid reports whether both the mode and the source are defined values.
32+func (c ColorCapabilities) Valid() bool { return c.Mode.Valid() && c.Source.Valid() }
33+
34+// RGB reports whether the attachment can receive RGB ANSI output.
35+func (c ColorCapabilities) RGB() bool { return c.Mode == TrueColor }
36+
16 // Source records how confidently a terminal capability was selected.37 // Source records how confidently a terminal capability was selected.
17 type Source uint838 type Source uint8
18 39
@@ -20,8 +41,14 @@ const (
20 SourceUnknown Source = iota41 SourceUnknown Source = iota
21 SourceHeuristic42 SourceHeuristic
22 SourceDeclared43 SourceDeclared
44+ // SourceForced is an explicit user override that detection must not
45+ // second-guess and that never warrants a downgrade notice.
46+ SourceForced
23 )47 )
24 48
49+// Valid reports whether s is one of the defined sources.
50+func (s Source) Valid() bool { return s <= SourceForced }
51+
25 // Application identifies a known terminal application when its environment52 // Application identifies a known terminal application when its environment
26 // provides a trustworthy origin signal.53 // provides a trustworthy origin signal.
27 type Application uint854 type Application uint8
@@ -33,8 +60,7 @@ const (
33 60
34 // Capabilities describes the output features selected for one client attachment.61 // Capabilities describes the output features selected for one client attachment.
35 type Capabilities struct {62 type Capabilities struct {
36- ColorMode ColorMode63+ Color ColorCapabilities
37- ColorSource Source
38 Application Application64 Application Application
39 KittyGraphics bool65 KittyGraphics bool
40 }66 }
@@ -43,15 +69,12 @@ type Capabilities struct {
43 // accepted the Kitty graphics protocol. Environment detection never sets it.69 // accepted the Kitty graphics protocol. Environment detection never sets it.
44 func (c Capabilities) SupportsKittyGraphics() bool { return c.KittyGraphics }70 func (c Capabilities) SupportsKittyGraphics() bool { return c.KittyGraphics }
45 71
46-// TrueColor reports whether this attachment can receive RGB ANSI output.
47-func (c Capabilities) TrueColor() bool { return c.ColorMode == TrueColor }
48-
49 // Detect derives conservative attachment capabilities from a client environment.72 // Detect derives conservative attachment capabilities from a client environment.
50 func Detect(env []string) Capabilities {73 func Detect(env []string) Capabilities {
51 values := environmentValues(env)74 values := environmentValues(env)
52 term := strings.ToLower(strings.TrimSpace(values["TERM"]))75 term := strings.ToLower(strings.TrimSpace(values["TERM"]))
53 colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"]))76 colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"]))
54- caps := Capabilities{ColorMode: Indexed256}77+ caps := Capabilities{Color: ColorCapabilities{Mode: ANSI256}}
55 78
56 if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" {79 if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" {
57 caps.Application = ApplicationKitty80 caps.Application = ApplicationKitty
@@ -60,29 +83,76 @@ func Detect(env []string) Capabilities {
60 kittyIdentity := term == "xterm-kitty" && caps.Application == ApplicationKitty83 kittyIdentity := term == "xterm-kitty" && caps.Application == ApplicationKitty
61 switch colorTerm {84 switch colorTerm {
62 case "truecolor", "24bit":85 case "truecolor", "24bit":
63- caps.ColorMode = TrueColor86+ caps.Color.Mode = TrueColor
64- caps.ColorSource = SourceDeclared87+ caps.Color.Source = SourceDeclared
65 }88 }
66 if term == "xterm-direct" || strings.HasSuffix(term, "-direct") {89 if term == "xterm-direct" || strings.HasSuffix(term, "-direct") {
67- caps.ColorMode = TrueColor90+ caps.Color.Mode = TrueColor
68- caps.ColorSource = SourceDeclared91+ caps.Color.Source = SourceDeclared
69 }92 }
70 if kittyIdentity {93 if kittyIdentity {
71- if caps.ColorSource == SourceUnknown {94+ if caps.Color.Source == SourceUnknown {
72- caps.ColorSource = SourceHeuristic95+ caps.Color.Source = SourceHeuristic
73 }96 }
74- caps.ColorMode = TrueColor97+ caps.Color.Mode = TrueColor
75 return caps98 return caps
76 }99 }
77- if caps.ColorSource == SourceDeclared {100+ if caps.Color.Source == SourceDeclared {
78 return caps101 return caps
79 }102 }
80- if strings.Contains(term, "256color") || term == "dumb" {103+ if mode, ok := termColorMode(term); ok {
81- caps.ColorSource = SourceDeclared104+ caps.Color.Mode = mode
105+ caps.Color.Source = SourceDeclared
82 }106 }
83 return caps107 return caps
84 }108 }
85 109
110+// termColorMode classifies a lowercase TERM value that carries no truecolor
111+// signal. It is deliberately conservative: only well-known terminfo names map
112+// to a constrained mode, and anything else reports ok=false so the caller keeps
113+// its unverified 256-color default. The rules apply in this order:
114+//
115+// - monochrome: dumb, vt52, vt100, vt102, vt220, or a name ending in -m or -mono;
116+// - 256 colors: any name containing "256color";
117+// - 16 colors: linux, ansi, cons25, or a name ending in -16color or -color.
118+func termColorMode(term string) (ColorMode, bool) {
119+ switch term {
120+ case "dumb", "vt52", "vt100", "vt102", "vt220":
121+ return Monochrome, true
122+ case "linux", "ansi", "cons25":
123+ return ANSI16, true
124+ }
125+ switch {
126+ case strings.HasSuffix(term, "-m"), strings.HasSuffix(term, "-mono"):
127+ return Monochrome, true
128+ case strings.Contains(term, "256color"):
129+ return ANSI256, true
130+ case strings.HasSuffix(term, "-16color"), strings.HasSuffix(term, "-color"):
131+ return ANSI16, true
132+ }
133+ return 0, false
134+}
135+
136+// ParseColorMode parses a user color override: auto, truecolor, 256, 16, or
137+// mono, case-insensitively and ignoring surrounding space. An empty value is
138+// auto. When auto is true the caller must use detection and mode is
139+// meaningless; an unknown value returns an error.
140+func ParseColorMode(value string) (mode ColorMode, auto bool, err error) {
141+ switch strings.ToLower(strings.TrimSpace(value)) {
142+ case "", "auto":
143+ return TrueColor, true, nil
144+ case "truecolor":
145+ return TrueColor, false, nil
146+ case "256":
147+ return ANSI256, false, nil
148+ case "16":
149+ return ANSI16, false, nil
150+ case "mono":
151+ return Monochrome, false, nil
152+ }
153+ return TrueColor, false, fmt.Errorf("invalid color mode %q (want auto, truecolor, 256, 16, or mono)", value)
154+}
155+
86 func environmentValues(env []string) map[string]string {156 func environmentValues(env []string) map[string]string {
87 values := make(map[string]string, len(env))157 values := make(map[string]string, len(env))
88 for _, entry := range env {158 for _, entry := range env {
@@ -94,11 +164,18 @@ func environmentValues(env []string) map[string]string {
94 return values164 return values
95 }165 }
96 166
97-// DetectTrueColor reports whether the supplied terminal environment advertises167+// Resolve selects attachment capabilities from the client environment and the
98-// direct color support. Explicit TERM/COLORTERM values override env entries.168+// client's color claim. A declared or forced client color is used verbatim. A
99-func DetectTrueColor(termEnv, colorTerm string, env []string) bool {169+// heuristic truecolor claim upgrades a weaker daemon-side detection, because
100- detectionEnv := make([]string, 0, len(env)+2)170+// the daemon may not see the client environment that produced the inference.
101- detectionEnv = append(detectionEnv, env...)171+// Any other claim falls back to environment detection.
102- detectionEnv = append(detectionEnv, "TERM="+termEnv, "COLORTERM="+colorTerm)172+func Resolve(env []string, declared ColorCapabilities) Capabilities {
103- return Detect(detectionEnv).TrueColor()173+ caps := Detect(env)
174+ switch {
175+ case declared.Source == SourceDeclared || declared.Source == SourceForced:
176+ caps.Color = declared
177+ case declared.Source == SourceHeuristic && declared.RGB() && !caps.Color.RGB():
178+ caps.Color = declared
179+ }
180+ return caps
104 }181 }
M internal/domain/terminalcap/capabilities_test.go +138 -7
@@ -6,8 +6,72 @@ import (
66 "github.com/stretchr/testify/require"
77 )
88
9-func TestDetectTrueColorPreservesKittyEnvironment(t *testing.T) {
10- require.True(t, DetectTrueColor("xterm-kitty", "", []string{"KITTY_WINDOW_ID=1"}))
9+func TestColorCapabilities(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ in ColorCapabilities
13+ wantRGB bool
14+ }{
15+ {name: "zero value is truecolor", in: ColorCapabilities{}, wantRGB: true},
16+ {name: "truecolor", in: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantRGB: true},
17+ {name: "ansi256", in: ColorCapabilities{Mode: ANSI256}, wantRGB: false},
18+ {name: "ansi16", in: ColorCapabilities{Mode: ANSI16, Source: SourceForced}, wantRGB: false},
19+ {name: "monochrome", in: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, wantRGB: false},
20+ }
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ require.Equal(t, tt.wantRGB, tt.in.RGB())
24+ })
25+ }
26+}
27+
28+func TestColorCapabilitiesValid(t *testing.T) {
29+ tests := []struct {
30+ name string
31+ in ColorCapabilities
32+ want bool
33+ }{
34+ {name: "zero value", in: ColorCapabilities{}, want: true},
35+ {name: "every mode and source", in: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, want: true},
36+ {name: "unknown mode", in: ColorCapabilities{Mode: Monochrome + 1}},
37+ {name: "unknown source", in: ColorCapabilities{Source: SourceForced + 1}},
38+ }
39+ for _, tt := range tests {
40+ t.Run(tt.name, func(t *testing.T) {
41+ require.Equal(t, tt.want, tt.in.Valid())
42+ })
43+ }
44+}
45+
46+func TestResolve(t *testing.T) {
47+ tests := []struct {
48+ name string
49+ env []string
50+ declared ColorCapabilities
51+ want ColorCapabilities
52+ wantApp Application
53+ }{
54+ {name: "unknown claim falls back to detection", env: []string{"TERM=xterm-256color"}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}},
55+ {name: "unknown truecolor claim falls back to detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}},
56+ {name: "unknown claim with detected truecolor", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
57+ {name: "declared truecolor overrides indexed detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
58+ {name: "declared truecolor on unknown terminal", env: []string{"TERM=unknown"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
59+ {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}},
60+ {name: "forced 16 colors", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, declared: ColorCapabilities{Mode: ANSI16, Source: SourceForced}, want: ColorCapabilities{Mode: ANSI16, Source: SourceForced}},
61+ {name: "forced monochrome", env: nil, declared: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, want: ColorCapabilities{Mode: Monochrome, Source: SourceForced}},
62+ {name: "heuristic truecolor upgrades weaker detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, want: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}},
63+ {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},
64+ {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}},
65+ {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},
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ got := Resolve(tt.env, tt.declared)
70+ require.Equal(t, tt.want, got.Color)
71+ require.Equal(t, tt.wantApp, got.Application)
72+ require.False(t, got.KittyGraphics)
73+ })
74+ }
1175 }
1276
1377 func TestDetect(t *testing.T) {
@@ -24,18 +88,85 @@ func TestDetect(t *testing.T) {
2488 {name: "direct terminfo entry", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared},
2589 {name: "kitty signals infer truecolor", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty},
2690 {name: "kitty environment with declared truecolor remains color-only", env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceDeclared, wantApp: ApplicationKitty},
27- {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: Indexed256, wantSource: SourceDeclared},
28- {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: Indexed256, wantSource: SourceUnknown},
29- {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: Indexed256, wantSource: SourceDeclared, wantApp: ApplicationKitty},
91+ {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
92+ {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: ANSI256, wantSource: SourceUnknown},
93+ {name: "empty TERM is unknown", env: nil, wantMode: ANSI256, wantSource: SourceUnknown},
94+ {name: "TERM=screen is unknown", env: []string{"TERM=screen"}, wantMode: ANSI256, wantSource: SourceUnknown},
95+ {name: "TERM=xterm is unknown", env: []string{"TERM=xterm"}, wantMode: ANSI256, wantSource: SourceUnknown},
96+ {name: "screen-256color", env: []string{"TERM=screen-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
97+ {name: "tmux-256color", env: []string{"TERM=tmux-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
98+ {name: "TERM is case-insensitive and trimmed", env: []string{"TERM= LINUX "}, wantMode: ANSI16, wantSource: SourceDeclared},
99+ {name: "dumb is monochrome", env: []string{"TERM=dumb"}, wantMode: Monochrome, wantSource: SourceDeclared},
100+ {name: "vt52 is monochrome", env: []string{"TERM=vt52"}, wantMode: Monochrome, wantSource: SourceDeclared},
101+ {name: "vt100 is monochrome", env: []string{"TERM=vt100"}, wantMode: Monochrome, wantSource: SourceDeclared},
102+ {name: "vt102 is monochrome", env: []string{"TERM=vt102"}, wantMode: Monochrome, wantSource: SourceDeclared},
103+ {name: "vt220 is monochrome", env: []string{"TERM=vt220"}, wantMode: Monochrome, wantSource: SourceDeclared},
104+ {name: "-mono suffix is monochrome", env: []string{"TERM=xterm-mono"}, wantMode: Monochrome, wantSource: SourceDeclared},
105+ {name: "-m suffix is monochrome", env: []string{"TERM=rxvt-m"}, wantMode: Monochrome, wantSource: SourceDeclared},
106+ {name: "vt320 is not assumed monochrome", env: []string{"TERM=vt320"}, wantMode: ANSI256, wantSource: SourceUnknown},
107+ {name: "linux console is 16 colors", env: []string{"TERM=linux"}, wantMode: ANSI16, wantSource: SourceDeclared},
108+ {name: "ansi is 16 colors", env: []string{"TERM=ansi"}, wantMode: ANSI16, wantSource: SourceDeclared},
109+ {name: "cons25 is 16 colors", env: []string{"TERM=cons25"}, wantMode: ANSI16, wantSource: SourceDeclared},
110+ {name: "-16color suffix", env: []string{"TERM=xterm-16color"}, wantMode: ANSI16, wantSource: SourceDeclared},
111+ {name: "-color suffix", env: []string{"TERM=xterm-color"}, wantMode: ANSI16, wantSource: SourceDeclared},
112+ {name: "screen-color suffix", env: []string{"TERM=screen-color"}, wantMode: ANSI16, wantSource: SourceDeclared},
113+ {name: "256color is not the -color suffix", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
114+ {name: "COLORTERM truecolor beats linux", env: []string{"TERM=linux", "COLORTERM=truecolor"}, wantMode: TrueColor, wantSource: SourceDeclared},
115+ {name: "COLORTERM 24bit beats dumb", env: []string{"TERM=dumb", "COLORTERM=24bit"}, wantMode: TrueColor, wantSource: SourceDeclared},
116+ {name: "direct terminfo beats mono suffix", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared},
117+ {name: "unrelated COLORTERM does not change TERM rules", env: []string{"TERM=linux", "COLORTERM=yes"}, wantMode: ANSI16, wantSource: SourceDeclared},
118+ {name: "kitty identity beats TERM rules", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty},
119+ {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: ANSI256, wantSource: SourceDeclared, wantApp: ApplicationKitty},
30120 }
31121
32122 for _, tt := range tests {
33123 t.Run(tt.name, func(t *testing.T) {
34124 got := Detect(tt.env)
35- require.Equal(t, tt.wantMode, got.ColorMode)
36- require.Equal(t, tt.wantSource, got.ColorSource)
125+ require.Equal(t, tt.wantMode, got.Color.Mode)
126+ require.Equal(t, tt.wantSource, got.Color.Source)
37127 require.Equal(t, tt.wantApp, got.Application)
38128 require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics())
39129 })
40130 }
41131 }
132+
133+func TestParseColorMode(t *testing.T) {
134+ tests := []struct {
135+ name string
136+ in string
137+ wantMode ColorMode
138+ wantAuto bool
139+ wantErr bool
140+ }{
141+ {name: "empty is auto", in: "", wantAuto: true},
142+ {name: "blank is auto", in: " \t", wantAuto: true},
143+ {name: "auto", in: "auto", wantAuto: true},
144+ {name: "auto is case-insensitive", in: " AUTO ", wantAuto: true},
145+ {name: "truecolor", in: "truecolor", wantMode: TrueColor},
146+ {name: "truecolor mixed case", in: "TrueColor", wantMode: TrueColor},
147+ {name: "256", in: "256", wantMode: ANSI256},
148+ {name: "16", in: " 16 ", wantMode: ANSI16},
149+ {name: "mono", in: "mono", wantMode: Monochrome},
150+ {name: "mono upper case", in: "MONO", wantMode: Monochrome},
151+ {name: "24bit is not accepted", in: "24bit", wantErr: true},
152+ {name: "256color is not accepted", in: "256color", wantErr: true},
153+ {name: "number out of set", in: "8", wantErr: true},
154+ {name: "monochrome spelled out is not accepted", in: "monochrome", wantErr: true},
155+ {name: "garbage", in: "rainbow", wantErr: true},
156+ }
157+ for _, tt := range tests {
158+ t.Run(tt.name, func(t *testing.T) {
159+ mode, auto, err := ParseColorMode(tt.in)
160+ if tt.wantErr {
161+ require.Error(t, err)
162+ require.False(t, auto)
163+ return
164+ }
165+ require.NoError(t, err)
166+ require.Equal(t, tt.wantAuto, auto)
167+ if !auto {
168+ require.Equal(t, tt.wantMode, mode)
169+ }
170+ })
171+ }
172+}
@@ -6,8 +6,72 @@ import (
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 )7 )
8 8
9-func TestDetectTrueColorPreservesKittyEnvironment(t *testing.T) {9+func TestColorCapabilities(t *testing.T) {
10- require.True(t, DetectTrueColor("xterm-kitty", "", []string{"KITTY_WINDOW_ID=1"}))10+ tests := []struct {
11+ name string
12+ in ColorCapabilities
13+ wantRGB bool
14+ }{
15+ {name: "zero value is truecolor", in: ColorCapabilities{}, wantRGB: true},
16+ {name: "truecolor", in: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, wantRGB: true},
17+ {name: "ansi256", in: ColorCapabilities{Mode: ANSI256}, wantRGB: false},
18+ {name: "ansi16", in: ColorCapabilities{Mode: ANSI16, Source: SourceForced}, wantRGB: false},
19+ {name: "monochrome", in: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, wantRGB: false},
20+ }
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ require.Equal(t, tt.wantRGB, tt.in.RGB())
24+ })
25+ }
26+}
27+
28+func TestColorCapabilitiesValid(t *testing.T) {
29+ tests := []struct {
30+ name string
31+ in ColorCapabilities
32+ want bool
33+ }{
34+ {name: "zero value", in: ColorCapabilities{}, want: true},
35+ {name: "every mode and source", in: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, want: true},
36+ {name: "unknown mode", in: ColorCapabilities{Mode: Monochrome + 1}},
37+ {name: "unknown source", in: ColorCapabilities{Source: SourceForced + 1}},
38+ }
39+ for _, tt := range tests {
40+ t.Run(tt.name, func(t *testing.T) {
41+ require.Equal(t, tt.want, tt.in.Valid())
42+ })
43+ }
44+}
45+
46+func TestResolve(t *testing.T) {
47+ tests := []struct {
48+ name string
49+ env []string
50+ declared ColorCapabilities
51+ want ColorCapabilities
52+ wantApp Application
53+ }{
54+ {name: "unknown claim falls back to detection", env: []string{"TERM=xterm-256color"}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}},
55+ {name: "unknown truecolor claim falls back to detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor}, want: ColorCapabilities{Mode: ANSI256, Source: SourceDeclared}},
56+ {name: "unknown claim with detected truecolor", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
57+ {name: "declared truecolor overrides indexed detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
58+ {name: "declared truecolor on unknown terminal", env: []string{"TERM=unknown"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}, want: ColorCapabilities{Mode: TrueColor, Source: SourceDeclared}},
59+ {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}},
60+ {name: "forced 16 colors", env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"}, declared: ColorCapabilities{Mode: ANSI16, Source: SourceForced}, want: ColorCapabilities{Mode: ANSI16, Source: SourceForced}},
61+ {name: "forced monochrome", env: nil, declared: ColorCapabilities{Mode: Monochrome, Source: SourceForced}, want: ColorCapabilities{Mode: Monochrome, Source: SourceForced}},
62+ {name: "heuristic truecolor upgrades weaker detection", env: []string{"TERM=xterm-256color"}, declared: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}, want: ColorCapabilities{Mode: TrueColor, Source: SourceHeuristic}},
63+ {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},
64+ {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}},
65+ {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},
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ got := Resolve(tt.env, tt.declared)
70+ require.Equal(t, tt.want, got.Color)
71+ require.Equal(t, tt.wantApp, got.Application)
72+ require.False(t, got.KittyGraphics)
73+ })
74+ }
11 }75 }
12 76
13 func TestDetect(t *testing.T) {77 func TestDetect(t *testing.T) {
@@ -24,18 +88,85 @@ func TestDetect(t *testing.T) {
24 {name: "direct terminfo entry", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared},88 {name: "direct terminfo entry", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared},
25 {name: "kitty signals infer truecolor", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty},89 {name: "kitty signals infer truecolor", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty},
26 {name: "kitty environment with declared truecolor remains color-only", env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceDeclared, wantApp: ApplicationKitty},90 {name: "kitty environment with declared truecolor remains color-only", env: []string{"TERM=xterm-kitty", "COLORTERM=truecolor", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceDeclared, wantApp: ApplicationKitty},
27- {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: Indexed256, wantSource: SourceDeclared},91+ {name: "256 color terminal remains a constrained attachment", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
28- {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: Indexed256, wantSource: SourceUnknown},92+ {name: "unknown terminal is conservatively indexed", env: []string{"TERM=unknown"}, wantMode: ANSI256, wantSource: SourceUnknown},
29- {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: Indexed256, wantSource: SourceDeclared, wantApp: ApplicationKitty},93+ {name: "empty TERM is unknown", env: nil, wantMode: ANSI256, wantSource: SourceUnknown},
94+ {name: "TERM=screen is unknown", env: []string{"TERM=screen"}, wantMode: ANSI256, wantSource: SourceUnknown},
95+ {name: "TERM=xterm is unknown", env: []string{"TERM=xterm"}, wantMode: ANSI256, wantSource: SourceUnknown},
96+ {name: "screen-256color", env: []string{"TERM=screen-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
97+ {name: "tmux-256color", env: []string{"TERM=tmux-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
98+ {name: "TERM is case-insensitive and trimmed", env: []string{"TERM= LINUX "}, wantMode: ANSI16, wantSource: SourceDeclared},
99+ {name: "dumb is monochrome", env: []string{"TERM=dumb"}, wantMode: Monochrome, wantSource: SourceDeclared},
100+ {name: "vt52 is monochrome", env: []string{"TERM=vt52"}, wantMode: Monochrome, wantSource: SourceDeclared},
101+ {name: "vt100 is monochrome", env: []string{"TERM=vt100"}, wantMode: Monochrome, wantSource: SourceDeclared},
102+ {name: "vt102 is monochrome", env: []string{"TERM=vt102"}, wantMode: Monochrome, wantSource: SourceDeclared},
103+ {name: "vt220 is monochrome", env: []string{"TERM=vt220"}, wantMode: Monochrome, wantSource: SourceDeclared},
104+ {name: "-mono suffix is monochrome", env: []string{"TERM=xterm-mono"}, wantMode: Monochrome, wantSource: SourceDeclared},
105+ {name: "-m suffix is monochrome", env: []string{"TERM=rxvt-m"}, wantMode: Monochrome, wantSource: SourceDeclared},
106+ {name: "vt320 is not assumed monochrome", env: []string{"TERM=vt320"}, wantMode: ANSI256, wantSource: SourceUnknown},
107+ {name: "linux console is 16 colors", env: []string{"TERM=linux"}, wantMode: ANSI16, wantSource: SourceDeclared},
108+ {name: "ansi is 16 colors", env: []string{"TERM=ansi"}, wantMode: ANSI16, wantSource: SourceDeclared},
109+ {name: "cons25 is 16 colors", env: []string{"TERM=cons25"}, wantMode: ANSI16, wantSource: SourceDeclared},
110+ {name: "-16color suffix", env: []string{"TERM=xterm-16color"}, wantMode: ANSI16, wantSource: SourceDeclared},
111+ {name: "-color suffix", env: []string{"TERM=xterm-color"}, wantMode: ANSI16, wantSource: SourceDeclared},
112+ {name: "screen-color suffix", env: []string{"TERM=screen-color"}, wantMode: ANSI16, wantSource: SourceDeclared},
113+ {name: "256color is not the -color suffix", env: []string{"TERM=xterm-256color"}, wantMode: ANSI256, wantSource: SourceDeclared},
114+ {name: "COLORTERM truecolor beats linux", env: []string{"TERM=linux", "COLORTERM=truecolor"}, wantMode: TrueColor, wantSource: SourceDeclared},
115+ {name: "COLORTERM 24bit beats dumb", env: []string{"TERM=dumb", "COLORTERM=24bit"}, wantMode: TrueColor, wantSource: SourceDeclared},
116+ {name: "direct terminfo beats mono suffix", env: []string{"TERM=foot-direct"}, wantMode: TrueColor, wantSource: SourceDeclared},
117+ {name: "unrelated COLORTERM does not change TERM rules", env: []string{"TERM=linux", "COLORTERM=yes"}, wantMode: ANSI16, wantSource: SourceDeclared},
118+ {name: "kitty identity beats TERM rules", env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"}, wantMode: TrueColor, wantSource: SourceHeuristic, wantApp: ApplicationKitty},
119+ {name: "kitty environment behind a multiplexer is not graphics evidence", env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"}, wantMode: ANSI256, wantSource: SourceDeclared, wantApp: ApplicationKitty},
30 }120 }
31 121
32 for _, tt := range tests {122 for _, tt := range tests {
33 t.Run(tt.name, func(t *testing.T) {123 t.Run(tt.name, func(t *testing.T) {
34 got := Detect(tt.env)124 got := Detect(tt.env)
35- require.Equal(t, tt.wantMode, got.ColorMode)125+ require.Equal(t, tt.wantMode, got.Color.Mode)
36- require.Equal(t, tt.wantSource, got.ColorSource)126+ require.Equal(t, tt.wantSource, got.Color.Source)
37 require.Equal(t, tt.wantApp, got.Application)127 require.Equal(t, tt.wantApp, got.Application)
38 require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics())128 require.Equal(t, tt.wantGraphics, got.SupportsKittyGraphics())
39 })129 })
40 }130 }
41 }131 }
132+
133+func TestParseColorMode(t *testing.T) {
134+ tests := []struct {
135+ name string
136+ in string
137+ wantMode ColorMode
138+ wantAuto bool
139+ wantErr bool
140+ }{
141+ {name: "empty is auto", in: "", wantAuto: true},
142+ {name: "blank is auto", in: " \t", wantAuto: true},
143+ {name: "auto", in: "auto", wantAuto: true},
144+ {name: "auto is case-insensitive", in: " AUTO ", wantAuto: true},
145+ {name: "truecolor", in: "truecolor", wantMode: TrueColor},
146+ {name: "truecolor mixed case", in: "TrueColor", wantMode: TrueColor},
147+ {name: "256", in: "256", wantMode: ANSI256},
148+ {name: "16", in: " 16 ", wantMode: ANSI16},
149+ {name: "mono", in: "mono", wantMode: Monochrome},
150+ {name: "mono upper case", in: "MONO", wantMode: Monochrome},
151+ {name: "24bit is not accepted", in: "24bit", wantErr: true},
152+ {name: "256color is not accepted", in: "256color", wantErr: true},
153+ {name: "number out of set", in: "8", wantErr: true},
154+ {name: "monochrome spelled out is not accepted", in: "monochrome", wantErr: true},
155+ {name: "garbage", in: "rainbow", wantErr: true},
156+ }
157+ for _, tt := range tests {
158+ t.Run(tt.name, func(t *testing.T) {
159+ mode, auto, err := ParseColorMode(tt.in)
160+ if tt.wantErr {
161+ require.Error(t, err)
162+ require.False(t, auto)
163+ return
164+ }
165+ require.NoError(t, err)
166+ require.Equal(t, tt.wantAuto, auto)
167+ if !auto {
168+ require.Equal(t, tt.wantMode, mode)
169+ }
170+ })
171+ }
172+}
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.TrueColor })
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.TrueColor })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 +2 -2
@@ -10,6 +10,7 @@ import (
1010 "github.com/stretchr/testify/require"
1111
1212 renderer "github.com/bnema/vev-vt"
13+ "github.com/bnema/vev/internal/domain/terminalcap"
1314 "github.com/bnema/vev/internal/ports"
1415 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1516 "github.com/bnema/vev/internal/protocol"
@@ -67,7 +68,7 @@ func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, reque
6768 cfg.Clock = h.clock
6869 cfg.Theme = themes
6970 cfg.Clipboard = clipboard
70- cfg.TrueColor = true
71+ cfg.Color = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}
7172 worker, err := newSessionAttachmentWorker(cfg)
7273 require.NoError(t, err)
7374 go func() {
@@ -188,7 +189,6 @@ func TestAttachmentInputPublishesClearedThenDefinitivePalette(t *testing.T) {
188189 first.awaitQueries(t, paletteColorBatch, 1)
189190 require.True(t, strings.HasPrefix(first.term.written(), "\x1b[Hready"), "the query follows the committed frame")
190191 cleared := first.awaitThemes(t, 1)[0]
191- require.True(t, cleared.TrueColor)
192192 require.Zero(t, cleared.PaletteKnown)
193193 require.False(t, cleared.HasForeground)
194194
@@ -10,6 +10,7 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12 renderer "github.com/bnema/vev-vt"12 renderer "github.com/bnema/vev-vt"
13+ "github.com/bnema/vev/internal/domain/terminalcap"
13 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
14 portsmocks "github.com/bnema/vev/internal/ports/mocks"15 portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 "github.com/bnema/vev/internal/protocol"16 "github.com/bnema/vev/internal/protocol"
@@ -67,7 +68,7 @@ func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, reque
67 cfg.Clock = h.clock68 cfg.Clock = h.clock
68 cfg.Theme = themes69 cfg.Theme = themes
69 cfg.Clipboard = clipboard70 cfg.Clipboard = clipboard
70- cfg.TrueColor = true71+ cfg.Color = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}
71 worker, err := newSessionAttachmentWorker(cfg)72 worker, err := newSessionAttachmentWorker(cfg)
72 require.NoError(t, err)73 require.NoError(t, err)
73 go func() {74 go func() {
@@ -188,7 +189,6 @@ func TestAttachmentInputPublishesClearedThenDefinitivePalette(t *testing.T) {
188 first.awaitQueries(t, paletteColorBatch, 1)189 first.awaitQueries(t, paletteColorBatch, 1)
189 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")
190 cleared := first.awaitThemes(t, 1)[0]191 cleared := first.awaitThemes(t, 1)[0]
191- require.True(t, cleared.TrueColor)
192 require.Zero(t, cleared.PaletteKnown)192 require.Zero(t, cleared.PaletteKnown)
193 require.False(t, cleared.HasForeground)193 require.False(t, cleared.HasForeground)
194 194
M internal/usecase/client/attachment_session.go +9 -8
@@ -9,6 +9,7 @@ import (
99 "time"
1010
1111 "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/domain/terminalcap"
1213 "github.com/bnema/vev/internal/ports"
1314 "github.com/bnema/vev/internal/protocol"
1415 )
@@ -68,12 +69,12 @@ func (e *AttachmentIdentityError) Error() string {
6869 // The request is the exact request the supervisor opened the stream with; the
6970 // rest is client-local presentation data the worker mirrors into Hello.
7071 type sessionAttachmentConfig struct {
71- Request ports.BrokerOpenStreamRequest
72- ClientID [16]byte
73- Geometry domain.Geometry
74- TermEnv string
75- Cwd string
76- TrueColor bool
72+ Request ports.BrokerOpenStreamRequest
73+ ClientID [16]byte
74+ Geometry domain.Geometry
75+ TermEnv string
76+ Cwd string
77+ Color terminalcap.ColorCapabilities
7778 // PID is this client's process ID; Hello carries it on local attaches.
7879 PID uint32
7980 // Capabilities are the outer terminal's probed capabilities, declared in
@@ -322,7 +323,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
322323 var samePeerRequests uint64
323324 var samePeerSwitch *samePeerSwitchPending
324325 samePeerUI := attachmentSamePeerUI(fg)
325- picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}
326+ picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.Color)}
326327 defer picker.stopEscape()
327328 input := newAttachmentInput(ctx, w, fg, stream, picker)
328329 defer input.close()
@@ -751,7 +752,7 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
751752 PixelHeight: geometry.PixelHeight,
752753 TermEnv: w.cfg.TermEnv,
753754 Cwd: cwd,
754- TrueColor: w.cfg.TrueColor,
755+ Color: w.cfg.Color,
755756 // A remote attach reaches the terminal through this client, so both
756757 // declarations describe this client's outer terminal.
757758 KittyDirectGraphics: w.cfg.Capabilities.KittyGraphics,
@@ -9,6 +9,7 @@ import (
9 "time"9 "time"
10 10
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/domain/terminalcap"
12 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
13 "github.com/bnema/vev/internal/protocol"14 "github.com/bnema/vev/internal/protocol"
14 )15 )
@@ -68,12 +69,12 @@ func (e *AttachmentIdentityError) Error() string {
68 // The request is the exact request the supervisor opened the stream with; the69 // The request is the exact request the supervisor opened the stream with; the
69 // rest is client-local presentation data the worker mirrors into Hello.70 // rest is client-local presentation data the worker mirrors into Hello.
70 type sessionAttachmentConfig struct {71 type sessionAttachmentConfig struct {
71- Request ports.BrokerOpenStreamRequest72+ Request ports.BrokerOpenStreamRequest
72- ClientID [16]byte73+ ClientID [16]byte
73- Geometry domain.Geometry74+ Geometry domain.Geometry
74- TermEnv string75+ TermEnv string
75- Cwd string76+ Cwd string
76- TrueColor bool77+ Color terminalcap.ColorCapabilities
77 // PID is this client's process ID; Hello carries it on local attaches.78 // PID is this client's process ID; Hello carries it on local attaches.
78 PID uint3279 PID uint32
79 // Capabilities are the outer terminal's probed capabilities, declared in80 // Capabilities are the outer terminal's probed capabilities, declared in
@@ -322,7 +323,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
322 var samePeerRequests uint64323 var samePeerRequests uint64
323 var samePeerSwitch *samePeerSwitchPending324 var samePeerSwitch *samePeerSwitchPending
324 samePeerUI := attachmentSamePeerUI(fg)325 samePeerUI := attachmentSamePeerUI(fg)
325- picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}326+ picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.Color)}
326 defer picker.stopEscape()327 defer picker.stopEscape()
327 input := newAttachmentInput(ctx, w, fg, stream, picker)328 input := newAttachmentInput(ctx, w, fg, stream, picker)
328 defer input.close()329 defer input.close()
@@ -751,7 +752,7 @@ func (w *sessionAttachmentWorker) hello(stream ports.BrokerLogicalConnection) pr
751 PixelHeight: geometry.PixelHeight,752 PixelHeight: geometry.PixelHeight,
752 TermEnv: w.cfg.TermEnv,753 TermEnv: w.cfg.TermEnv,
753 Cwd: cwd,754 Cwd: cwd,
754- TrueColor: w.cfg.TrueColor,755+ Color: w.cfg.Color,
755 // A remote attach reaches the terminal through this client, so both756 // A remote attach reaches the terminal through this client, so both
756 // declarations describe this client's outer terminal.757 // declarations describe this client's outer terminal.
757 KittyDirectGraphics: w.cfg.Capabilities.KittyGraphics,758 KittyDirectGraphics: w.cfg.Capabilities.KittyGraphics,
M internal/usecase/client/attachment_session_test.go +7 -3
@@ -12,6 +12,7 @@ import (
1212 "github.com/stretchr/testify/require"
1313
1414 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/terminalcap"
1516 "github.com/bnema/vev/internal/ports"
1617 "github.com/bnema/vev/internal/protocol"
1718 )
@@ -528,9 +529,12 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
528529 tests := []struct {
529530 name string
530531 env AttachmentEnvironment
532+ want terminalcap.ColorCapabilities
531533 }{
532- {name: "unset"},
533- {name: "supplied", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", TrueColor: 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, 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}},
534538 }
535539 for _, tt := range tests {
536540 t.Run(tt.name, func(t *testing.T) {
@@ -551,7 +555,7 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
551555 hello := awaitHello(t, stream)
552556 require.Equal(t, tt.env.TermEnv, hello.TermEnv)
553557 require.Equal(t, tt.env.Cwd, hello.Cwd)
554- require.Equal(t, tt.env.TrueColor, hello.TrueColor)
558+ require.Equal(t, tt.want, hello.Color)
555559 require.NoError(t, stream.Close())
556560 <-events
557561 })
@@ -12,6 +12,7 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/terminalcap"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/protocol"17 "github.com/bnema/vev/internal/protocol"
17 )18 )
@@ -528,9 +529,12 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
528 tests := []struct {529 tests := []struct {
529 name string530 name string
530 env AttachmentEnvironment531 env AttachmentEnvironment
532+ want terminalcap.ColorCapabilities
531 }{533 }{
532- {name: "unset"},534+ {name: "unset defaults to truecolor", want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
533- {name: "supplied", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", TrueColor: true}},535+ {name: "supplied truecolor", env: AttachmentEnvironment{TermEnv: "xterm-256color", Cwd: "/workspace", Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, 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}},
534 }538 }
535 for _, tt := range tests {539 for _, tt := range tests {
536 t.Run(tt.name, func(t *testing.T) {540 t.Run(tt.name, func(t *testing.T) {
@@ -551,7 +555,7 @@ func TestSessionAttachmentWorkerHelloEnvironment(t *testing.T) {
551 hello := awaitHello(t, stream)555 hello := awaitHello(t, stream)
552 require.Equal(t, tt.env.TermEnv, hello.TermEnv)556 require.Equal(t, tt.env.TermEnv, hello.TermEnv)
553 require.Equal(t, tt.env.Cwd, hello.Cwd)557 require.Equal(t, tt.env.Cwd, hello.Cwd)
554- require.Equal(t, tt.env.TrueColor, hello.TrueColor)558+ require.Equal(t, tt.want, hello.Color)
555 require.NoError(t, stream.Close())559 require.NoError(t, stream.Close())
556 <-events560 <-events
557 })561 })
M internal/usecase/client/client_toast.go +42 -2
@@ -6,6 +6,7 @@ import (
66 "strings"
77
88 renderer "github.com/bnema/vev-vt"
9+ ansirenderer "github.com/bnema/vev-vt/ansi"
910 "github.com/bnema/vev/internal/domain"
1011 "github.com/bnema/vev/internal/usecase/picker"
1112 "github.com/bnema/vev/internal/usecase/ui"
@@ -16,6 +17,12 @@ import (
1617 // reconciliation; input pumps must only publish a request for it.
1718 // The anchor places it: transitions stay centered, notices sit top-right so
1819 // they never cover the picker list. The border is colored by severity.
20+//
21+// The border uses fixed xterm-256 colors and does not follow the attachment
22+// color profile, so production callers (the transition notice) pass only
23+// domain.NoticeInfo, which emits no color. Toasts with a severity that must
24+// honor 16-color or monochrome terminals go through toastBorderSGRFor, as the
25+// picker does.
1926 func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor, severity domain.NoticeSeverity) (domain.Rect, error) {
2027 bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor})
2128 if bounds.Width <= 0 || bounds.Height <= 0 {
@@ -38,6 +45,39 @@ func toastBorderSGR(severity domain.NoticeSeverity) string {
3845 }
3946 }
4047
48+// toastBorderSGRFor is toastBorderSGR for one renderer color profile. The
49+// toast bypasses the renderer, so it must follow the profile itself: 16-color
50+// terminals get basic red/yellow, monochrome terminals get bold for problems.
51+// TrueColor and 256-color output is unchanged.
52+func toastBorderSGRFor(severity domain.NoticeSeverity, profile ansirenderer.ColorProfile) string {
53+ switch profile {
54+ case ansirenderer.ColorProfileANSI16:
55+ switch severity {
56+ case domain.NoticeError:
57+ return "\x1b[31m"
58+ case domain.NoticeWarn:
59+ return "\x1b[33m"
60+ }
61+ return ""
62+ case ansirenderer.ColorProfileMonochrome:
63+ if severity == domain.NoticeError || severity == domain.NoticeWarn {
64+ return "\x1b[1m"
65+ }
66+ return ""
67+ default:
68+ return toastBorderSGR(severity)
69+ }
70+}
71+
72+// toastBorderReset undoes toastBorderSGR's attribute: default foreground for
73+// colors, normal intensity for the monochrome bold border.
74+func toastBorderReset(borderSGR string) string {
75+ if borderSGR == "\x1b[1m" {
76+ return "\x1b[22m"
77+ }
78+ return "\x1b[39m"
79+}
80+
4181 // clientToastLines renders the toast box as one string per row, exactly
4282 // bounds.Width columns wide.
4383 func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
@@ -60,7 +100,7 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
60100 if border {
61101 b.WriteString(borderSGR)
62102 } else {
63- b.WriteString("\x1b[39m")
103+ b.WriteString(toastBorderReset(borderSGR))
64104 }
65105 colored = border
66106 }
@@ -76,7 +116,7 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
76116 b.WriteRune(cell.Rune)
77117 }
78118 if colored {
79- b.WriteString("\x1b[39m")
119+ b.WriteString(toastBorderReset(borderSGR))
80120 }
81121 lines[y] = b.String()
82122 }
@@ -6,6 +6,7 @@ import (
6 "strings"6 "strings"
7 7
8 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
9+ ansirenderer "github.com/bnema/vev-vt/ansi"
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/usecase/picker"11 "github.com/bnema/vev/internal/usecase/picker"
11 "github.com/bnema/vev/internal/usecase/ui"12 "github.com/bnema/vev/internal/usecase/ui"
@@ -16,6 +17,12 @@ import (
16 // reconciliation; input pumps must only publish a request for it.17 // reconciliation; input pumps must only publish a request for it.
17 // The anchor places it: transitions stay centered, notices sit top-right so18 // The anchor places it: transitions stay centered, notices sit top-right so
18 // they never cover the picker list. The border is colored by severity.19 // they never cover the picker list. The border is colored by severity.
20+//
21+// The border uses fixed xterm-256 colors and does not follow the attachment
22+// color profile, so production callers (the transition notice) pass only
23+// domain.NoticeInfo, which emits no color. Toasts with a severity that must
24+// honor 16-color or monochrome terminals go through toastBorderSGRFor, as the
25+// picker does.
19 func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor, severity domain.NoticeSeverity) (domain.Rect, error) {26 func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor, severity domain.NoticeSeverity) (domain.Rect, error) {
20 bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor})27 bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor})
21 if bounds.Width <= 0 || bounds.Height <= 0 {28 if bounds.Width <= 0 || bounds.Height <= 0 {
@@ -38,6 +45,39 @@ func toastBorderSGR(severity domain.NoticeSeverity) string {
38 }45 }
39 }46 }
40 47
48+// toastBorderSGRFor is toastBorderSGR for one renderer color profile. The
49+// toast bypasses the renderer, so it must follow the profile itself: 16-color
50+// terminals get basic red/yellow, monochrome terminals get bold for problems.
51+// TrueColor and 256-color output is unchanged.
52+func toastBorderSGRFor(severity domain.NoticeSeverity, profile ansirenderer.ColorProfile) string {
53+ switch profile {
54+ case ansirenderer.ColorProfileANSI16:
55+ switch severity {
56+ case domain.NoticeError:
57+ return "\x1b[31m"
58+ case domain.NoticeWarn:
59+ return "\x1b[33m"
60+ }
61+ return ""
62+ case ansirenderer.ColorProfileMonochrome:
63+ if severity == domain.NoticeError || severity == domain.NoticeWarn {
64+ return "\x1b[1m"
65+ }
66+ return ""
67+ default:
68+ return toastBorderSGR(severity)
69+ }
70+}
71+
72+// toastBorderReset undoes toastBorderSGR's attribute: default foreground for
73+// colors, normal intensity for the monochrome bold border.
74+func toastBorderReset(borderSGR string) string {
75+ if borderSGR == "\x1b[1m" {
76+ return "\x1b[22m"
77+ }
78+ return "\x1b[39m"
79+}
80+
41 // clientToastLines renders the toast box as one string per row, exactly81 // clientToastLines renders the toast box as one string per row, exactly
42 // bounds.Width columns wide.82 // bounds.Width columns wide.
43 func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {83 func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
@@ -60,7 +100,7 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
60 if border {100 if border {
61 b.WriteString(borderSGR)101 b.WriteString(borderSGR)
62 } else {102 } else {
63- b.WriteString("\x1b[39m")103+ b.WriteString(toastBorderReset(borderSGR))
64 }104 }
65 colored = border105 colored = border
66 }106 }
@@ -76,7 +116,7 @@ func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
76 b.WriteRune(cell.Rune)116 b.WriteRune(cell.Rune)
77 }117 }
78 if colored {118 if colored {
79- b.WriteString("\x1b[39m")119+ b.WriteString(toastBorderReset(borderSGR))
80 }120 }
81 lines[y] = b.String()121 lines[y] = b.String()
82 }122 }
M internal/usecase/client/move_picker_overlay.go +4 -2
@@ -6,8 +6,10 @@ import (
66 "time"
77
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/terminalcap"
910 "github.com/bnema/vev/internal/ports"
1011 "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/usecase/colorprofile"
1113 "github.com/bnema/vev/internal/usecase/keys/kittykey"
1214 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
1315 )
@@ -40,8 +42,8 @@ type movePickerOverlay struct {
4042 presenting bool
4143 }
4244
43-func newMovePickerOverlay(trueColor bool) *movePickerOverlay {
44- return &movePickerOverlay{renderer: newPickerRenderer(pickerColorProfile(trueColor))}
45+func newMovePickerOverlay(color terminalcap.ColorCapabilities) *movePickerOverlay {
46+ return &movePickerOverlay{renderer: newPickerRenderer(colorprofile.Profile(color))}
4547 }
4648
4749 // offer opens the namespace a move offer names. A superseding offer retires
@@ -6,8 +6,10 @@ import (
6 "time"6 "time"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/terminalcap"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/protocol"11 "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/usecase/colorprofile"
11 "github.com/bnema/vev/internal/usecase/keys/kittykey"13 "github.com/bnema/vev/internal/usecase/keys/kittykey"
12 pickerusecase "github.com/bnema/vev/internal/usecase/picker"14 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
13 )15 )
@@ -40,8 +42,8 @@ type movePickerOverlay struct {
40 presenting bool42 presenting bool
41 }43 }
42 44
43-func newMovePickerOverlay(trueColor bool) *movePickerOverlay {45+func newMovePickerOverlay(color terminalcap.ColorCapabilities) *movePickerOverlay {
44- return &movePickerOverlay{renderer: newPickerRenderer(pickerColorProfile(trueColor))}46+ return &movePickerOverlay{renderer: newPickerRenderer(colorprofile.Profile(color))}
45 }47 }
46 48
47 // offer opens the namespace a move offer names. A superseding offer retires49 // offer opens the namespace a move offer names. A superseding offer retires
M internal/usecase/client/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/client/picker_composition.go +5 -4
@@ -4,6 +4,7 @@ import (
44 "time"
55
66 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/domain/terminalcap"
78 "github.com/bnema/vev/internal/ports"
89 "github.com/bnema/vev/internal/protocol"
910 "github.com/bnema/vev/internal/usecase/keys/kittykey"
@@ -49,11 +50,11 @@ func PickerPresentation(state State) bool {
4950 }
5051
5152 // NewPicker composes one client-owned picker. A nil clock uses the system
52-// clock, and a zero freshness uses the catalogue default. trueColor is the
53-// terminal's detected color capability (AttachmentEnvironment.TrueColor); the
53+// clock, and a zero freshness uses the catalogue default. color is the
54+// terminal's detected color capability (AttachmentEnvironment.Color); the
5455 // picker renders indexed colors without it.
55-func NewPicker(clock ports.Clock, freshness time.Duration, trueColor bool) *Picker {
56- return &Picker{controller: newPickerController(clock, freshness, trueColor)}
56+func NewPicker(clock ports.Clock, freshness time.Duration, color terminalcap.ColorCapabilities) *Picker {
57+ return &Picker{controller: newPickerController(clock, freshness, color)}
5758 }
5859
5960 // ApplySnapshot forwards a broker publication to the supervisor-owned picker.
@@ -4,6 +4,7 @@ import (
4 "time"4 "time"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/domain/terminalcap"
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/protocol"9 "github.com/bnema/vev/internal/protocol"
9 "github.com/bnema/vev/internal/usecase/keys/kittykey"10 "github.com/bnema/vev/internal/usecase/keys/kittykey"
@@ -49,11 +50,11 @@ func PickerPresentation(state State) bool {
49 }50 }
50 51
51 // NewPicker composes one client-owned picker. A nil clock uses the system52 // NewPicker composes one client-owned picker. A nil clock uses the system
52-// clock, and a zero freshness uses the catalogue default. trueColor is the53+// clock, and a zero freshness uses the catalogue default. color is the
53-// terminal's detected color capability (AttachmentEnvironment.TrueColor); the54+// terminal's detected color capability (AttachmentEnvironment.Color); the
54 // picker renders indexed colors without it.55 // picker renders indexed colors without it.
55-func NewPicker(clock ports.Clock, freshness time.Duration, trueColor bool) *Picker {56+func NewPicker(clock ports.Clock, freshness time.Duration, color terminalcap.ColorCapabilities) *Picker {
56- return &Picker{controller: newPickerController(clock, freshness, trueColor)}57+ return &Picker{controller: newPickerController(clock, freshness, color)}
57 }58 }
58 59
59 // ApplySnapshot forwards a broker publication to the supervisor-owned picker.60 // ApplySnapshot forwards a broker publication to the supervisor-owned picker.
M internal/usecase/client/picker_composition_external_test.go +2 -1
@@ -7,6 +7,7 @@ import (
77 "github.com/stretchr/testify/require"
88
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/terminalcap"
1011 "github.com/bnema/vev/internal/ports"
1112 "github.com/bnema/vev/internal/protocol"
1213 "github.com/bnema/vev/internal/protocol/catalogue"
@@ -19,7 +20,7 @@ import (
1920 // so it could never resolve a selection into an attachment.
2021 func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) {
2122 geometry := domain.Size{Cols: 80, Rows: 24}
22- picker := client.NewPicker(nil, 0, true)
23+ picker := client.NewPicker(nil, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
2324 require.NotNil(t, picker)
2425
2526 // The exact assignment an app composition performs.
@@ -7,6 +7,7 @@ import (
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/terminalcap"
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"12 "github.com/bnema/vev/internal/protocol"
12 "github.com/bnema/vev/internal/protocol/catalogue"13 "github.com/bnema/vev/internal/protocol/catalogue"
@@ -19,7 +20,7 @@ import (
19 // so it could never resolve a selection into an attachment.20 // so it could never resolve a selection into an attachment.
20 func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) {21 func TestCompositionOutsideThePackageCanBuildThePicker(t *testing.T) {
21 geometry := domain.Size{Cols: 80, Rows: 24}22 geometry := domain.Size{Cols: 80, Rows: 24}
22- picker := client.NewPicker(nil, 0, true)23+ picker := client.NewPicker(nil, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
23 require.NotNil(t, picker)24 require.NotNil(t, picker)
24 25
25 // The exact assignment an app composition performs.26 // The exact assignment an app composition performs.
M internal/usecase/client/picker_controller.go +5 -3
@@ -8,9 +8,11 @@ import (
88 "time"
99
1010 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/domain/terminalcap"
1112 "github.com/bnema/vev/internal/ports"
1213 "github.com/bnema/vev/internal/protocol"
1314 "github.com/bnema/vev/internal/protocol/catalogue"
15+ "github.com/bnema/vev/internal/usecase/colorprofile"
1416 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
1517 "github.com/bnema/vev/internal/usecase/ui"
1618 )
@@ -123,14 +125,14 @@ type pickerController struct {
123125 // newPickerController builds a picker over an empty catalogue. The picker owns
124126 // input from the start: the controller presents it for the whole picker presentation and
125127 // has no session pipeline to defer to.
126-func newPickerController(clock ports.Clock, freshness time.Duration, trueColor bool) *pickerController {
128+func newPickerController(clock ports.Clock, freshness time.Duration, color terminalcap.ColorCapabilities) *pickerController {
127129 if supervisorNil(clock) {
128130 clock = systemClock{}
129131 }
130132 controller := &pickerController{
131133 clock: clock,
132134 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
133- renderer: newPickerRenderer(pickerColorProfile(trueColor)),
135+ renderer: newPickerRenderer(colorprofile.Profile(color)),
134136 sort: defaultPickerSort(),
135137 notices: ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}),
136138 ownsInput: true,
@@ -496,7 +498,7 @@ func (p *pickerController) RenderNotice(size domain.Size) []byte {
496498 }
497499 buffer := bytes.NewBuffer(blanks)
498500 for i, rect := range p.noticeBounds {
499- _ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGR(toasts[i].Severity)))
501+ _ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGRFor(toasts[i].Severity, p.renderer.profile)))
500502 }
501503 if buffer.Len() == 0 {
502504 return nil
@@ -8,9 +8,11 @@ import (
8 "time"8 "time"
9 9
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/domain/terminalcap"
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
12 "github.com/bnema/vev/internal/protocol"13 "github.com/bnema/vev/internal/protocol"
13 "github.com/bnema/vev/internal/protocol/catalogue"14 "github.com/bnema/vev/internal/protocol/catalogue"
15+ "github.com/bnema/vev/internal/usecase/colorprofile"
14 pickerusecase "github.com/bnema/vev/internal/usecase/picker"16 pickerusecase "github.com/bnema/vev/internal/usecase/picker"
15 "github.com/bnema/vev/internal/usecase/ui"17 "github.com/bnema/vev/internal/usecase/ui"
16 )18 )
@@ -123,14 +125,14 @@ type pickerController struct {
123 // newPickerController builds a picker over an empty catalogue. The picker owns125 // newPickerController builds a picker over an empty catalogue. The picker owns
124 // input from the start: the controller presents it for the whole picker presentation and126 // input from the start: the controller presents it for the whole picker presentation and
125 // has no session pipeline to defer to.127 // has no session pipeline to defer to.
126-func newPickerController(clock ports.Clock, freshness time.Duration, trueColor bool) *pickerController {128+func newPickerController(clock ports.Clock, freshness time.Duration, color terminalcap.ColorCapabilities) *pickerController {
127 if supervisorNil(clock) {129 if supervisorNil(clock) {
128 clock = systemClock{}130 clock = systemClock{}
129 }131 }
130 controller := &pickerController{132 controller := &pickerController{
131 clock: clock,133 clock: clock,
132 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),134 catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}),
133- renderer: newPickerRenderer(pickerColorProfile(trueColor)),135+ renderer: newPickerRenderer(colorprofile.Profile(color)),
134 sort: defaultPickerSort(),136 sort: defaultPickerSort(),
135 notices: ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}),137 notices: ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible}),
136 ownsInput: true,138 ownsInput: true,
@@ -496,7 +498,7 @@ func (p *pickerController) RenderNotice(size domain.Size) []byte {
496 }498 }
497 buffer := bytes.NewBuffer(blanks)499 buffer := bytes.NewBuffer(blanks)
498 for i, rect := range p.noticeBounds {500 for i, rect := range p.noticeBounds {
499- _ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGR(toasts[i].Severity)))501+ _ = writeClientToast(buffer, rect, clientToastLines(rect, toasts[i].Message, toastBorderSGRFor(toasts[i].Severity, p.renderer.profile)))
500 }502 }
501 if buffer.Len() == 0 {503 if buffer.Len() == 0 {
502 return nil504 return nil
M internal/usecase/client/picker_controller_test.go +4 -3
@@ -12,6 +12,7 @@ import (
1212 "github.com/stretchr/testify/require"
1313
1414 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/terminalcap"
1516 "github.com/bnema/vev/internal/ports"
1617 "github.com/bnema/vev/internal/protocol/catalogue"
1718 "github.com/bnema/vev/internal/usecase/ui"
@@ -118,7 +119,7 @@ func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision,
118119 func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) {
119120 t.Helper()
120121 clock := newSupervisorTestClock()
121- return newPickerController(clock, pickerTestFreshness, true), clock
122+ return newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}), clock
122123 }
123124
124125 func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string {
@@ -428,7 +429,7 @@ func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) {
428429 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
429430 return service, nil
430431 })
431- controller := newPickerController(clock, pickerTestFreshness, true)
432+ controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
432433 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
433434
434435 runErr := make(chan error, 1)
@@ -473,7 +474,7 @@ func TestSupervisorPickerFoldsLaterPublication(t *testing.T) {
473474 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
474475 return service, nil
475476 })
476- controller := newPickerController(clock, pickerTestFreshness, true)
477+ controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
477478 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
478479
479480 runErr := make(chan error, 1)
@@ -12,6 +12,7 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/domain/terminalcap"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/protocol/catalogue"17 "github.com/bnema/vev/internal/protocol/catalogue"
17 "github.com/bnema/vev/internal/usecase/ui"18 "github.com/bnema/vev/internal/usecase/ui"
@@ -118,7 +119,7 @@ func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision,
118 func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) {119 func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) {
119 t.Helper()120 t.Helper()
120 clock := newSupervisorTestClock()121 clock := newSupervisorTestClock()
121- return newPickerController(clock, pickerTestFreshness, true), clock122+ return newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}), clock
122 }123 }
123 124
124 func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string {125 func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string {
@@ -428,7 +429,7 @@ func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) {
428 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))429 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta"))
429 return service, nil430 return service, nil
430 })431 })
431- controller := newPickerController(clock, pickerTestFreshness, true)432+ controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
432 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})433 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
433 434
434 runErr := make(chan error, 1)435 runErr := make(chan error, 1)
@@ -473,7 +474,7 @@ func TestSupervisorPickerFoldsLaterPublication(t *testing.T) {
473 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))474 service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha"))
474 return service, nil475 return service, nil
475 })476 })
476- controller := newPickerController(clock, pickerTestFreshness, true)477+ controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
477 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})478 sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller})
478 479
479 runErr := make(chan error, 1)480 runErr := make(chan error, 1)
M internal/usecase/client/picker_interaction.go +5 -11
@@ -340,16 +340,6 @@ func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer {
340340 return &pickerRenderer{profile: profile}
341341 }
342342
343-// pickerColorProfile maps the terminal's detected color capability to the
344-// picker renderer profile. A terminal without truecolor gets indexed colors,
345-// so RGB surfaces are quantized instead of dropped (#280).
346-func pickerColorProfile(trueColor bool) ansirenderer.ColorProfile {
347- if trueColor {
348- return ansirenderer.ColorProfileTrueColor
349- }
350- return ansirenderer.ColorProfileANSI256
351-}
352-
353343 // render composes the loop model into terminal bytes for one display
354344 // refresh. Previews arrive as source data (picker_preview.go) and are attached
355345 // to the model by the caller; a full redraw every refresh keeps the shadow
@@ -389,7 +379,11 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick
389379 border := renderer.DefaultStyle()
390380 border.Attrs |= renderer.AttrDim
391381 picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle())
392- inner := loop.model.Render(picker.Size(presentation.Inner), preview, r.renderStyles...)
382+ styles := r.renderStyles
383+ if len(styles) == 0 && r.profile == ansirenderer.ColorProfileMonochrome {
384+ styles = []picker.RenderStyles{picker.DefaultRenderStyles(true)}
385+ }
386+ inner := loop.model.Render(picker.Size(presentation.Inner), preview, styles...)
393387 copyFrameRect(base, presentation.Inner, inner)
394388
395389 // Only the box is written: the session stays on screen around it. A box
@@ -340,16 +340,6 @@ func newPickerRenderer(profile ansirenderer.ColorProfile) *pickerRenderer {
340 return &pickerRenderer{profile: profile}340 return &pickerRenderer{profile: profile}
341 }341 }
342 342
343-// pickerColorProfile maps the terminal's detected color capability to the
344-// picker renderer profile. A terminal without truecolor gets indexed colors,
345-// so RGB surfaces are quantized instead of dropped (#280).
346-func pickerColorProfile(trueColor bool) ansirenderer.ColorProfile {
347- if trueColor {
348- return ansirenderer.ColorProfileTrueColor
349- }
350- return ansirenderer.ColorProfileANSI256
351-}
352-
353 // render composes the loop model into terminal bytes for one display343 // render composes the loop model into terminal bytes for one display
354 // refresh. Previews arrive as source data (picker_preview.go) and are attached344 // refresh. Previews arrive as source data (picker_preview.go) and are attached
355 // to the model by the caller; a full redraw every refresh keeps the shadow345 // to the model by the caller; a full redraw every refresh keeps the shadow
@@ -389,7 +379,11 @@ func (r *pickerRenderer) render(loop *pickerLoop, size domain.Size, preview pick
389 border := renderer.DefaultStyle()379 border := renderer.DefaultStyle()
390 border.Attrs |= renderer.AttrDim380 border.Attrs |= renderer.AttrDim
391 picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle())381 picker.Modal.CompositePresentation(base, presentation, border, renderer.DefaultStyle())
392- inner := loop.model.Render(picker.Size(presentation.Inner), preview, r.renderStyles...)382+ styles := r.renderStyles
383+ if len(styles) == 0 && r.profile == ansirenderer.ColorProfileMonochrome {
384+ styles = []picker.RenderStyles{picker.DefaultRenderStyles(true)}
385+ }
386+ inner := loop.model.Render(picker.Size(presentation.Inner), preview, styles...)
393 copyFrameRect(base, presentation.Inner, inner)387 copyFrameRect(base, presentation.Inner, inner)
394 388
395 // Only the box is written: the session stays on screen around it. A box389 // Only the box is written: the session stays on screen around it. A box
M internal/usecase/client/picker_interaction_test.go +11 -10
@@ -8,6 +8,7 @@ import (
88 renderer "github.com/bnema/vev-vt"
99 ansirenderer "github.com/bnema/vev-vt/ansi"
1010 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/domain/terminalcap"
1112 "github.com/bnema/vev/internal/protocol"
1213 "github.com/bnema/vev/internal/usecase/picker"
1314 "github.com/stretchr/testify/require"
@@ -73,26 +74,26 @@ func TestPickerRenderersFollowTerminalColorProfile(t *testing.T) {
7374 Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Tree: rgbPickerStyle(), Status: rgbPickerStyle(),
7475 SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(),
7576 }
76- renderers := map[string]func(trueColor bool) *pickerRenderer{
77- "session picker": func(trueColor bool) *pickerRenderer {
78- return NewPicker(nil, 0, trueColor).controller.renderer
77+ renderers := map[string]func(color terminalcap.ColorCapabilities) *pickerRenderer{
78+ "session picker": func(color terminalcap.ColorCapabilities) *pickerRenderer {
79+ return NewPicker(nil, 0, color).controller.renderer
7980 },
80- "move picker": func(trueColor bool) *pickerRenderer {
81- return newMovePickerOverlay(trueColor).renderer
81+ "move picker": func(color terminalcap.ColorCapabilities) *pickerRenderer {
82+ return newMovePickerOverlay(color).renderer
8283 },
8384 }
8485 for name, build := range renderers {
8586 for _, tc := range []struct {
86- trueColor bool
87+ color terminalcap.ColorCapabilities
8788 profile ansirenderer.ColorProfile
8889 want string
8990 forbidden string
9091 }{
91- {trueColor: false, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`},
92- {trueColor: true, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`},
92+ {color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`},
93+ {color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`},
9394 } {
94- t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.trueColor), func(t *testing.T) {
95- r := build(tc.trueColor)
95+ t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.color.RGB()), func(t *testing.T) {
96+ r := build(tc.color)
9697 require.Equal(t, tc.profile, r.profile)
9798 r.renderStyles = []picker.RenderStyles{styles}
9899 output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
@@ -8,6 +8,7 @@ import (
8 renderer "github.com/bnema/vev-vt"8 renderer "github.com/bnema/vev-vt"
9 ansirenderer "github.com/bnema/vev-vt/ansi"9 ansirenderer "github.com/bnema/vev-vt/ansi"
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/domain/terminalcap"
11 "github.com/bnema/vev/internal/protocol"12 "github.com/bnema/vev/internal/protocol"
12 "github.com/bnema/vev/internal/usecase/picker"13 "github.com/bnema/vev/internal/usecase/picker"
13 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
@@ -73,26 +74,26 @@ func TestPickerRenderersFollowTerminalColorProfile(t *testing.T) {
73 Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Tree: rgbPickerStyle(), Status: rgbPickerStyle(),74 Stopped: rgbPickerStyle(), Separator: rgbPickerStyle(), Tree: rgbPickerStyle(), Status: rgbPickerStyle(),
74 SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(),75 SearchMatch: rgbPickerStyle(), SelectionMatch: rgbPickerStyle(),
75 }76 }
76- renderers := map[string]func(trueColor bool) *pickerRenderer{77+ renderers := map[string]func(color terminalcap.ColorCapabilities) *pickerRenderer{
77- "session picker": func(trueColor bool) *pickerRenderer {78+ "session picker": func(color terminalcap.ColorCapabilities) *pickerRenderer {
78- return NewPicker(nil, 0, trueColor).controller.renderer79+ return NewPicker(nil, 0, color).controller.renderer
79 },80 },
80- "move picker": func(trueColor bool) *pickerRenderer {81+ "move picker": func(color terminalcap.ColorCapabilities) *pickerRenderer {
81- return newMovePickerOverlay(trueColor).renderer82+ return newMovePickerOverlay(color).renderer
82 },83 },
83 }84 }
84 for name, build := range renderers {85 for name, build := range renderers {
85 for _, tc := range []struct {86 for _, tc := range []struct {
86- trueColor bool87+ color terminalcap.ColorCapabilities
87 profile ansirenderer.ColorProfile88 profile ansirenderer.ColorProfile
88 want string89 want string
89 forbidden string90 forbidden string
90 }{91 }{
91- {trueColor: false, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`},92+ {color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, profile: ansirenderer.ColorProfileANSI256, want: `(?:38|48);5;`, forbidden: `(?:38|48|58);2;`},
92- {trueColor: true, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`},93+ {color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, profile: ansirenderer.ColorProfileTrueColor, want: `(?:38|48);2;`, forbidden: `(?:38|48);5;`},
93 } {94 } {
94- t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.trueColor), func(t *testing.T) {95+ t.Run(fmt.Sprintf("%s/truecolor=%v", name, tc.color.RGB()), func(t *testing.T) {
95- r := build(tc.trueColor)96+ r := build(tc.color)
96 require.Equal(t, tc.profile, r.profile)97 require.Equal(t, tc.profile, r.profile)
97 r.renderStyles = []picker.RenderStyles{styles}98 r.renderStyles = []picker.RenderStyles{styles}
98 output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))99 output := string(r.render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
A internal/usecase/client/reduced_color_test.go +103 -0
@@ -0,0 +1,103 @@
1+package client
2+
3+import (
4+ "regexp"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ ansirenderer "github.com/bnema/vev-vt/ansi"
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/usecase/picker"
13+)
14+
15+func TestPickerStaysLegibleInReducedColor(t *testing.T) {
16+ loop := pickerLoopFixture(t)
17+ size := domain.Size{Cols: 100, Rows: 30}
18+
19+ ansi16 := string(newPickerRenderer(ansirenderer.ColorProfileANSI16).render(loop, size, emptyPickerPreview()))
20+ require.NotRegexp(t, `(?:38|48|58);[25];`, ansi16, "ansi16 picker emitted extended colors")
21+ require.Regexp(t, `\x1b\[0;[0-9;]*7`, ansi16, "the selected row must stay reverse video")
22+
23+ mono := string(newPickerRenderer(ansirenderer.ColorProfileMonochrome).render(loop, size, emptyPickerPreview()))
24+ require.NotRegexp(t, `\x1b\[[0-9;:]*(?:38|48|58|3[0-7]|4[0-7]|9[0-7]|10[0-7])[;:m]`, mono, "monochrome picker emitted a color")
25+ require.Regexp(t, `\x1b\[0;[0-9;]*7`, mono, "the selected row must stay reverse video")
26+ require.Contains(t, mono, "work")
27+}
28+
29+func TestToastBorderFollowsColorProfile(t *testing.T) {
30+ tests := []struct {
31+ name string
32+ profile ansirenderer.ColorProfile
33+ severity domain.NoticeSeverity
34+ want string
35+ }{
36+ {name: "truecolor error", profile: ansirenderer.ColorProfileTrueColor, severity: domain.NoticeError, want: "\x1b[38;5;167m"},
37+ {name: "256 warn", profile: ansirenderer.ColorProfileANSI256, severity: domain.NoticeWarn, want: "\x1b[38;5;179m"},
38+ {name: "256 info", profile: ansirenderer.ColorProfileANSI256, severity: domain.NoticeInfo},
39+ {name: "16 error", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeError, want: "\x1b[31m"},
40+ {name: "16 warn", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeWarn, want: "\x1b[33m"},
41+ {name: "16 info", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeInfo},
42+ {name: "mono error", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeError, want: "\x1b[1m"},
43+ {name: "mono warn", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeWarn, want: "\x1b[1m"},
44+ {name: "mono info", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeInfo},
45+ }
46+ for _, tt := range tests {
47+ t.Run(tt.name, func(t *testing.T) {
48+ require.Equal(t, tt.want, toastBorderSGRFor(tt.severity, tt.profile))
49+ })
50+ }
51+ // Monochrome borders are closed with normal intensity, not default color.
52+ bounds := domain.Rect{Width: 12, Height: 3}
53+ lines := clientToastLines(bounds, "hi", toastBorderSGRFor(domain.NoticeError, ansirenderer.ColorProfileMonochrome))
54+ require.Contains(t, lines[0], "\x1b[22m")
55+ require.NotContains(t, lines[0], "\x1b[39m")
56+}
57+
58+// TestPickerProblemDotsKeepMeaningPerProfile checks how each profile encodes
59+// the problem dots: truecolor and 256 keep the fixed indexed colors, ANSI-16 is
60+// quantized by the renderer, and monochrome marks error and warning dots bold
61+// since no color survives. Informational statuses never gain weight.
62+func TestPickerProblemDotsKeepMeaningPerProfile(t *testing.T) {
63+ snapshot := pickerSnapshotFixture()
64+ snapshot.Lines = []protocol.PickerLine{
65+ {Key: "aa/err", Kind: protocol.PickerLineSession, Label: "err", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusError},
66+ {Key: "bb/warn", Kind: protocol.PickerLineSession, Label: "warn", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusStale},
67+ {Key: "cc/ver", Kind: protocol.PickerLineSession, Label: "ver", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusVersion},
68+ }
69+ snapshot.Cursor = protocol.PickerCursor{Key: "aa/err"}
70+ // dot returns the SGR group in effect for the dot on the row labelled label.
71+ dot := func(out, label string) string {
72+ row := regexp.MustCompile(`(?s)` + label + `[^●]*(\x1b\[[0-9;]*m)[^●\x1b]*●`)
73+ m := row.FindStringSubmatch(out)
74+ require.NotNil(t, m, "no problem dot after %q in %q", label, out)
75+ return m[1]
76+ }
77+ tests := []struct {
78+ name string
79+ profile ansirenderer.ColorProfile
80+ want map[string]string // label -> exact SGR before the dot
81+ }{
82+ {name: "truecolor", profile: ansirenderer.ColorProfileTrueColor, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}},
83+ {name: "ansi256", profile: ansirenderer.ColorProfileANSI256, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}},
84+ {name: "ansi16", profile: ansirenderer.ColorProfileANSI16, want: map[string]string{"err": "", "warn": "", "ver": ""}},
85+ {name: "monochrome", profile: ansirenderer.ColorProfileMonochrome, want: map[string]string{"err": "\x1b[0;1m", "warn": "\x1b[0;1m", "ver": "\x1b[0m"}},
86+ }
87+ for _, tt := range tests {
88+ t.Run(tt.name, func(t *testing.T) {
89+ loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent)
90+ out := string(newPickerRenderer(tt.profile).render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
91+ for label, want := range tt.want {
92+ got := dot(out, label)
93+ if want == "" {
94+ // ANSI-16 colors are chosen by the renderer's quantizer: only
95+ // require a basic or bright palette slot, never extended color.
96+ require.Regexp(t, `^\x1b\[0;(3[0-7]|9[0-7])m$`, got, label)
97+ continue
98+ }
99+ require.Equal(t, want, got, label)
100+ }
101+ })
102+ }
103+}
@@ -0,0 +1,103 @@
1+package client
2+
3+import (
4+ "regexp"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ ansirenderer "github.com/bnema/vev-vt/ansi"
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/usecase/picker"
13+)
14+
15+func TestPickerStaysLegibleInReducedColor(t *testing.T) {
16+ loop := pickerLoopFixture(t)
17+ size := domain.Size{Cols: 100, Rows: 30}
18+
19+ ansi16 := string(newPickerRenderer(ansirenderer.ColorProfileANSI16).render(loop, size, emptyPickerPreview()))
20+ require.NotRegexp(t, `(?:38|48|58);[25];`, ansi16, "ansi16 picker emitted extended colors")
21+ require.Regexp(t, `\x1b\[0;[0-9;]*7`, ansi16, "the selected row must stay reverse video")
22+
23+ mono := string(newPickerRenderer(ansirenderer.ColorProfileMonochrome).render(loop, size, emptyPickerPreview()))
24+ require.NotRegexp(t, `\x1b\[[0-9;:]*(?:38|48|58|3[0-7]|4[0-7]|9[0-7]|10[0-7])[;:m]`, mono, "monochrome picker emitted a color")
25+ require.Regexp(t, `\x1b\[0;[0-9;]*7`, mono, "the selected row must stay reverse video")
26+ require.Contains(t, mono, "work")
27+}
28+
29+func TestToastBorderFollowsColorProfile(t *testing.T) {
30+ tests := []struct {
31+ name string
32+ profile ansirenderer.ColorProfile
33+ severity domain.NoticeSeverity
34+ want string
35+ }{
36+ {name: "truecolor error", profile: ansirenderer.ColorProfileTrueColor, severity: domain.NoticeError, want: "\x1b[38;5;167m"},
37+ {name: "256 warn", profile: ansirenderer.ColorProfileANSI256, severity: domain.NoticeWarn, want: "\x1b[38;5;179m"},
38+ {name: "256 info", profile: ansirenderer.ColorProfileANSI256, severity: domain.NoticeInfo},
39+ {name: "16 error", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeError, want: "\x1b[31m"},
40+ {name: "16 warn", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeWarn, want: "\x1b[33m"},
41+ {name: "16 info", profile: ansirenderer.ColorProfileANSI16, severity: domain.NoticeInfo},
42+ {name: "mono error", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeError, want: "\x1b[1m"},
43+ {name: "mono warn", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeWarn, want: "\x1b[1m"},
44+ {name: "mono info", profile: ansirenderer.ColorProfileMonochrome, severity: domain.NoticeInfo},
45+ }
46+ for _, tt := range tests {
47+ t.Run(tt.name, func(t *testing.T) {
48+ require.Equal(t, tt.want, toastBorderSGRFor(tt.severity, tt.profile))
49+ })
50+ }
51+ // Monochrome borders are closed with normal intensity, not default color.
52+ bounds := domain.Rect{Width: 12, Height: 3}
53+ lines := clientToastLines(bounds, "hi", toastBorderSGRFor(domain.NoticeError, ansirenderer.ColorProfileMonochrome))
54+ require.Contains(t, lines[0], "\x1b[22m")
55+ require.NotContains(t, lines[0], "\x1b[39m")
56+}
57+
58+// TestPickerProblemDotsKeepMeaningPerProfile checks how each profile encodes
59+// the problem dots: truecolor and 256 keep the fixed indexed colors, ANSI-16 is
60+// quantized by the renderer, and monochrome marks error and warning dots bold
61+// since no color survives. Informational statuses never gain weight.
62+func TestPickerProblemDotsKeepMeaningPerProfile(t *testing.T) {
63+ snapshot := pickerSnapshotFixture()
64+ snapshot.Lines = []protocol.PickerLine{
65+ {Key: "aa/err", Kind: protocol.PickerLineSession, Label: "err", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusError},
66+ {Key: "bb/warn", Kind: protocol.PickerLineSession, Label: "warn", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusStale},
67+ {Key: "cc/ver", Kind: protocol.PickerLineSession, Label: "ver", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusVersion},
68+ }
69+ snapshot.Cursor = protocol.PickerCursor{Key: "aa/err"}
70+ // dot returns the SGR group in effect for the dot on the row labelled label.
71+ dot := func(out, label string) string {
72+ row := regexp.MustCompile(`(?s)` + label + `[^●]*(\x1b\[[0-9;]*m)[^●\x1b]*●`)
73+ m := row.FindStringSubmatch(out)
74+ require.NotNil(t, m, "no problem dot after %q in %q", label, out)
75+ return m[1]
76+ }
77+ tests := []struct {
78+ name string
79+ profile ansirenderer.ColorProfile
80+ want map[string]string // label -> exact SGR before the dot
81+ }{
82+ {name: "truecolor", profile: ansirenderer.ColorProfileTrueColor, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}},
83+ {name: "ansi256", profile: ansirenderer.ColorProfileANSI256, want: map[string]string{"err": "\x1b[0;38;5;167m", "warn": "\x1b[0;38;5;179m", "ver": "\x1b[0;38;5;170m"}},
84+ {name: "ansi16", profile: ansirenderer.ColorProfileANSI16, want: map[string]string{"err": "", "warn": "", "ver": ""}},
85+ {name: "monochrome", profile: ansirenderer.ColorProfileMonochrome, want: map[string]string{"err": "\x1b[0;1m", "warn": "\x1b[0;1m", "ver": "\x1b[0m"}},
86+ }
87+ for _, tt := range tests {
88+ t.Run(tt.name, func(t *testing.T) {
89+ loop := pickerLoopFromSnapshot(snapshot, protocol.PickerIntentNavigation, picker.SortRecent)
90+ out := string(newPickerRenderer(tt.profile).render(loop, domain.Size{Cols: 100, Rows: 30}, emptyPickerPreview()))
91+ for label, want := range tt.want {
92+ got := dot(out, label)
93+ if want == "" {
94+ // ANSI-16 colors are chosen by the renderer's quantizer: only
95+ // require a basic or bright palette slot, never extended color.
96+ require.Regexp(t, `^\x1b\[0;(3[0-7]|9[0-7])m$`, got, label)
97+ continue
98+ }
99+ require.Equal(t, want, got, label)
100+ }
101+ })
102+ }
103+}
M internal/usecase/client/supervisor.go +6 -3
@@ -13,6 +13,7 @@ import (
1313 "time"
1414
1515 "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/domain/terminalcap"
1617 "github.com/bnema/vev/internal/ports"
1718 "github.com/bnema/vev/internal/usecase/keys/kittykey"
1819 )
@@ -382,9 +383,11 @@ type SupervisorConfig struct {
382383 // AttachmentEnvironment is the composition seam for client environment data
383384 // carried by attachment Hello messages.
384385 type AttachmentEnvironment struct {
385- TermEnv string
386- Cwd string
387- TrueColor bool
386+ TermEnv string
387+ Cwd string
388+ // Color is the terminal's detected color capability. The zero value is
389+ // TrueColor; composition fills it from terminal detection.
390+ Color terminalcap.ColorCapabilities
388391 // PID is this client's process ID, sent on local attaches only.
389392 PID uint32
390393 // ProbeTerminal enables the one bounded capability probe of a real outer
@@ -13,6 +13,7 @@ import (
13 "time"13 "time"
14 14
15 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/domain/terminalcap"
16 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
17 "github.com/bnema/vev/internal/usecase/keys/kittykey"18 "github.com/bnema/vev/internal/usecase/keys/kittykey"
18 )19 )
@@ -382,9 +383,11 @@ type SupervisorConfig struct {
382 // AttachmentEnvironment is the composition seam for client environment data383 // AttachmentEnvironment is the composition seam for client environment data
383 // carried by attachment Hello messages.384 // carried by attachment Hello messages.
384 type AttachmentEnvironment struct {385 type AttachmentEnvironment struct {
385- TermEnv string386+ TermEnv string
386- Cwd string387+ Cwd string
387- TrueColor bool388+ // Color is the terminal's detected color capability. The zero value is
389+ // TrueColor; composition fills it from terminal detection.
390+ Color terminalcap.ColorCapabilities
388 // PID is this client's process ID, sent on local attaches only.391 // PID is this client's process ID, sent on local attaches only.
389 PID uint32392 PID uint32
390 // ProbeTerminal enables the one bounded capability probe of a real outer393 // ProbeTerminal enables the one bounded capability probe of a real outer
M internal/usecase/client/supervisor_attach.go +1 -1
@@ -347,7 +347,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
347347 Geometry: geometry,
348348 TermEnv: environment.TermEnv,
349349 Cwd: environment.Cwd,
350- TrueColor: environment.TrueColor,
350+ Color: environment.Color,
351351 PID: environment.PID,
352352 Capabilities: s.capabilities,
353353 SessionEnvironment: sessionEnv,
@@ -347,7 +347,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
347 Geometry: geometry,347 Geometry: geometry,
348 TermEnv: environment.TermEnv,348 TermEnv: environment.TermEnv,
349 Cwd: environment.Cwd,349 Cwd: environment.Cwd,
350- TrueColor: environment.TrueColor,350+ Color: environment.Color,
351 PID: environment.PID,351 PID: environment.PID,
352 Capabilities: s.capabilities,352 Capabilities: s.capabilities,
353 SessionEnvironment: sessionEnv,353 SessionEnvironment: sessionEnv,
M internal/usecase/client/supervisor_attach_test.go +2 -1
@@ -14,6 +14,7 @@ import (
1414 "github.com/stretchr/testify/require"
1515
1616 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/terminalcap"
1718 "github.com/bnema/vev/internal/ports"
1819 "github.com/bnema/vev/internal/protocol"
1920 )
@@ -1634,7 +1635,7 @@ func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) {
16341635 return admitted, nil
16351636 })
16361637
1637- controller := newPickerController(clock, pickerTestFreshness, true)
1638+ controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
16381639 connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil })
16391640 sup := mustSupervisor(t, SupervisorConfig{
16401641 Connector: connector,
@@ -14,6 +14,7 @@ import (
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/terminalcap"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
18 "github.com/bnema/vev/internal/protocol"19 "github.com/bnema/vev/internal/protocol"
19 )20 )
@@ -1634,7 +1635,7 @@ func TestSupervisorAttachmentReachableFromRealPicker(t *testing.T) {
1634 return admitted, nil1635 return admitted, nil
1635 })1636 })
1636 1637
1637- controller := newPickerController(clock, pickerTestFreshness, true)1638+ controller := newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
1638 connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil })1639 connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerNavigator, error) { return service, nil })
1639 sup := mustSupervisor(t, SupervisorConfig{1640 sup := mustSupervisor(t, SupervisorConfig{
1640 Connector: connector,1641 Connector: connector,
M internal/usecase/client/supervisor_test.go +3 -2
@@ -14,6 +14,7 @@ import (
1414 "github.com/stretchr/testify/require"
1515
1616 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/terminalcap"
1718 "github.com/bnema/vev/internal/ports"
1819 )
1920
@@ -715,7 +716,7 @@ func TestSupervisorPickerPublicationRequestsRepaint(t *testing.T) {
715716 }),
716717 Terminal: newSupervisorTestTerminal(reader),
717718 Clock: clock,
718- Picker: newPickerController(clock, pickerTestFreshness, true),
719+ Picker: newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}),
719720 Render: func(state State) {
720721 renders <- state
721722 },
@@ -1494,7 +1495,7 @@ func TestSupervisorPickerCloseWhileOffline(t *testing.T) {
14941495 })
14951496 s := mustSupervisor(t, SupervisorConfig{
14961497 Connector: connector, Terminal: terminal, Clock: clock,
1497- Picker: NewPicker(clock, 0, true),
1498+ Picker: NewPicker(clock, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}),
14981499 Notify: func(State, error) { reached <- struct{}{} },
14991500 })
15001501 ctx, cancel := context.WithCancel(context.Background())
@@ -14,6 +14,7 @@ import (
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/domain/terminalcap"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
18 )19 )
19 20
@@ -715,7 +716,7 @@ func TestSupervisorPickerPublicationRequestsRepaint(t *testing.T) {
715 }),716 }),
716 Terminal: newSupervisorTestTerminal(reader),717 Terminal: newSupervisorTestTerminal(reader),
717 Clock: clock,718 Clock: clock,
718- Picker: newPickerController(clock, pickerTestFreshness, true),719+ Picker: newPickerController(clock, pickerTestFreshness, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}),
719 Render: func(state State) {720 Render: func(state State) {
720 renders <- state721 renders <- state
721 },722 },
@@ -1494,7 +1495,7 @@ func TestSupervisorPickerCloseWhileOffline(t *testing.T) {
1494 })1495 })
1495 s := mustSupervisor(t, SupervisorConfig{1496 s := mustSupervisor(t, SupervisorConfig{
1496 Connector: connector, Terminal: terminal, Clock: clock,1497 Connector: connector, Terminal: terminal, Clock: clock,
1497- Picker: NewPicker(clock, 0, true),1498+ Picker: NewPicker(clock, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}),
1498 Notify: func(State, error) { reached <- struct{}{} },1499 Notify: func(State, error) { reached <- struct{}{} },
1499 })1500 })
1500 ctx, cancel := context.WithCancel(context.Background())1501 ctx, cancel := context.WithCancel(context.Background())
A internal/usecase/colorprofile/profile.go +25 -0
@@ -0,0 +1,25 @@
1+// Package colorprofile maps terminal color capabilities to the vev-vt renderer
2+// color profile. It is the single mode-to-profile policy shared by the client
3+// and the daemon.
4+package colorprofile
5+
6+import (
7+ ansi "github.com/bnema/vev-vt/ansi"
8+ "github.com/bnema/vev/internal/domain/terminalcap"
9+)
10+
11+// Profile returns the renderer profile for c. A terminal without RGB support
12+// gets a reduced palette, so RGB surfaces are quantized instead of dropped.
13+// Unknown modes fall back to the conservative 256-color profile.
14+func Profile(c terminalcap.ColorCapabilities) ansi.ColorProfile {
15+ switch c.Mode {
16+ case terminalcap.TrueColor:
17+ 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
24+ }
25+}
@@ -0,0 +1,25 @@
1+// Package colorprofile maps terminal color capabilities to the vev-vt renderer
2+// color profile. It is the single mode-to-profile policy shared by the client
3+// and the daemon.
4+package colorprofile
5+
6+import (
7+ ansi "github.com/bnema/vev-vt/ansi"
8+ "github.com/bnema/vev/internal/domain/terminalcap"
9+)
10+
11+// Profile returns the renderer profile for c. A terminal without RGB support
12+// gets a reduced palette, so RGB surfaces are quantized instead of dropped.
13+// Unknown modes fall back to the conservative 256-color profile.
14+func Profile(c terminalcap.ColorCapabilities) ansi.ColorProfile {
15+ switch c.Mode {
16+ case terminalcap.TrueColor:
17+ 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
24+ }
25+}
A internal/usecase/colorprofile/profile_test.go +30 -0
@@ -0,0 +1,30 @@
1+package colorprofile
2+
3+import (
4+ "testing"
5+
6+ ansi "github.com/bnema/vev-vt/ansi"
7+ "github.com/bnema/vev/internal/domain/terminalcap"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestProfile(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ in terminalcap.ColorCapabilities
15+ want ansi.ColorProfile
16+ }{
17+ {name: "zero value is truecolor", in: terminalcap.ColorCapabilities{}, want: ansi.ColorProfileTrueColor},
18+ {name: "truecolor", in: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, want: ansi.ColorProfileTrueColor},
19+ {name: "ansi256", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, want: ansi.ColorProfileANSI256},
20+ {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},
24+ }
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ require.Equal(t, tt.want, Profile(tt.in))
28+ })
29+ }
30+}
@@ -0,0 +1,30 @@
1+package colorprofile
2+
3+import (
4+ "testing"
5+
6+ ansi "github.com/bnema/vev-vt/ansi"
7+ "github.com/bnema/vev/internal/domain/terminalcap"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestProfile(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ in terminalcap.ColorCapabilities
15+ want ansi.ColorProfile
16+ }{
17+ {name: "zero value is truecolor", in: terminalcap.ColorCapabilities{}, want: ansi.ColorProfileTrueColor},
18+ {name: "truecolor", in: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}, want: ansi.ColorProfileTrueColor},
19+ {name: "ansi256", in: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}, want: ansi.ColorProfileANSI256},
20+ {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},
24+ }
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ require.Equal(t, tt.want, Profile(tt.in))
28+ })
29+ }
30+}
A internal/usecase/daemon/attachment_color_profile_test.go +280 -0
@@ -0,0 +1,280 @@
1+package daemon
2+
3+import (
4+ "regexp"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain/terminalcap"
10+ "github.com/bnema/vev/internal/protocol"
11+)
12+
13+var (
14+ colorTrue = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}
15+ color16 = terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}
16+ colorMono = terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}
17+
18+ // paneColorWrites exercise every color channel a pane can carry: RGB
19+ // foreground/background, indexed 16-255 foreground/background, and RGB and
20+ // indexed underline color.
21+ paneColorWrites = []string{
22+ "\x1b[38;2;10;200;30mR\x1b[0m",
23+ "\x1b[48;2;200;10;30mB\x1b[0m",
24+ "\x1b[38;5;196mF\x1b[0m",
25+ "\x1b[48;5;21mG\x1b[0m",
26+ "\x1b[4;58;2;0;255;0mU\x1b[0m",
27+ "\x1b[4;58;5;200mV\x1b[0m",
28+ }
29+ extendedColor = regexp.MustCompile(`^(38|48|58)`)
30+)
31+
32+// colorClient is one attachment plus the output frames its transport received.
33+type colorClient struct {
34+ ac *attachedClient
35+ tr *closeTrackingTransport
36+ seen int
37+}
38+
39+// newOutput returns the Output messages sent since the previous call and their
40+// concatenated terminal bytes.
41+func (c *colorClient) newOutput(t *testing.T) ([]protocol.Output, []byte) {
42+ t.Helper()
43+ frames := testFramesOfType(c.tr.Sends(), "Output")
44+ var (
45+ outs []protocol.Output
46+ data []byte
47+ )
48+ for _, frame := range frames[c.seen:] {
49+ out := unmarshalTestOutput(t, frame.Payload)
50+ outs = append(outs, out)
51+ data = append(data, out.Data...)
52+ }
53+ c.seen = len(frames)
54+ return outs, data
55+}
56+
57+func colorHello(intent uint8, name string, client byte, token uint64, color terminalcap.ColorCapabilities) protocol.Hello {
58+ h := helloResumeCapable(intent, name, token)
59+ h.ClientID = [16]byte{client}
60+ h.Color = color
61+ return h
62+}
63+
64+// requireProfile asserts data is encoded for color: pane RGB/indexed channels
65+// survive only where the mode can express them.
66+func requireProfile(t *testing.T, mode terminalcap.ColorMode, data []byte) {
67+ t.Helper()
68+ params := colorParams(data)
69+ switch mode {
70+ case terminalcap.TrueColor:
71+ require.Contains(t, params, "38;2;10;200;30", "%q", data)
72+ require.Contains(t, params, "48;2;200;10;30", "%q", data)
73+ require.Contains(t, params, "58;2;0;255;0", "%q", data)
74+ require.Contains(t, params, "38;5;196", "%q", data)
75+ require.Contains(t, params, "48;5;21", "%q", data)
76+ require.Contains(t, params, "58;5;200", "%q", data)
77+ case terminalcap.ANSI16:
78+ require.NotEmpty(t, params, "ansi16 keeps basic colors: %q", data)
79+ for _, p := range params {
80+ require.NotRegexp(t, extendedColor, p, "ansi16 output carries extended color: %q", data)
81+ }
82+ case terminalcap.Monochrome:
83+ require.Empty(t, params, "monochrome output carries color: %q", data)
84+ require.NotRegexp(t, `\x1b\[[0-9;:]*(38|48|58)[;:]`, string(data))
85+ default:
86+ t.Fatalf("unhandled mode %v", mode)
87+ }
88+}
89+
90+func attachColorClients(t *testing.T, modes []terminalcap.ColorCapabilities) (*Daemon, *session, []*colorClient) {
91+ t.Helper()
92+ pty, release := newBlockingPTY(t)
93+ t.Cleanup(release)
94+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
95+ var (
96+ sess *session
97+ clients []*colorClient
98+ )
99+ for i, color := range modes {
100+ tr := &closeTrackingTransport{}
101+ intent := protocol.IntentAttach
102+ if i == 0 {
103+ intent = protocol.IntentNew
104+ }
105+ s, ac, err := d.route(colorHello(intent, "work", byte(i+1), 0, color), tr)
106+ require.NoError(t, err)
107+ if sess == nil {
108+ sess = s
109+ }
110+ require.Same(t, sess, s)
111+ require.Equal(t, color.Mode, ac.terminalCapabilities.Color.Mode)
112+ clients = append(clients, &colorClient{ac: ac, tr: tr})
113+ }
114+ return d, sess, clients
115+}
116+
117+// TestAttachmentsEncodePaneOutputWithOwnColorProfile covers pane output for
118+// attachments of one session that negotiated different color modes: full
119+// first frame, incremental update, and explicit renderer reset.
120+func TestAttachmentsEncodePaneOutputWithOwnColorProfile(t *testing.T) {
121+ modes := []terminalcap.ColorCapabilities{colorTrue, color16, colorMono}
122+ d, sess, clients := attachColorClients(t, modes)
123+ pane := sess.tabs[0].focusedPane()
124+ for _, w := range paneColorWrites {
125+ pane.screen.Write([]byte(w))
126+ }
127+
128+ paintAll := func(reset bool) {
129+ for _, c := range clients {
130+ d.paint(sess, c.ac, reset, nil)
131+ }
132+ }
133+ // Each step repaints every attachment and then checks that attachment's
134+ // bytes against its own mode.
135+ steps := []struct {
136+ name string
137+ setup func()
138+ reset bool
139+ full bool
140+ }{
141+ {name: "initial full frame", reset: true, full: true},
142+ {name: "incremental draw", setup: func() {
143+ for _, w := range paneColorWrites {
144+ pane.screen.Write([]byte("\r\n" + w))
145+ }
146+ }},
147+ {name: "renderer reset", reset: true, full: true},
148+ {name: "incremental after reset", setup: func() {
149+ for _, w := range paneColorWrites {
150+ pane.screen.Write([]byte("\r\n" + w))
151+ }
152+ }},
153+ }
154+ var prevNew [3]uint64
155+ for _, step := range steps {
156+ if step.setup != nil {
157+ step.setup()
158+ }
159+ paintAll(step.reset)
160+ for i, c := range clients {
161+ t.Run(step.name+"/"+modeName(modes[i].Mode), func(t *testing.T) {
162+ outs, data := c.newOutput(t)
163+ require.NotEmpty(t, outs)
164+ first := outs[0]
165+ if step.full {
166+ require.Zero(t, first.Base, "full frames start a new chain")
167+ } else {
168+ require.Equal(t, prevNew[i], first.Base, "incremental frames extend the chain")
169+ }
170+ prevNew[i] = outs[len(outs)-1].New
171+ c.ac.ackOutputState(outs[len(outs)-1].Epoch, outs[len(outs)-1].New)
172+ requireProfile(t, modes[i].Mode, data)
173+ })
174+ }
175+ }
176+}
177+
178+func modeName(m terminalcap.ColorMode) string {
179+ switch m {
180+ case terminalcap.TrueColor:
181+ return "truecolor"
182+ case terminalcap.ANSI16:
183+ return "ansi16"
184+ case terminalcap.Monochrome:
185+ return "monochrome"
186+ default:
187+ return "other"
188+ }
189+}
190+
191+// TestResumeAdoptsColorOfLatestHello proves a resumed attachment encodes for
192+// the replacement terminal's Hello.Color, not the one it parked with.
193+func TestResumeAdoptsColorOfLatestHello(t *testing.T) {
194+ tests := []struct {
195+ name string
196+ from, to terminalcap.ColorCapabilities
197+ liveResume bool // resume while the old link is still registered
198+ // wantToasts is checked only when checkToasts is set; it lists the
199+ // visible toast messages after resume.
200+ checkToasts bool
201+ wantToasts []string
202+ }{
203+ {name: "truecolor to monochrome", from: colorTrue, to: colorMono},
204+ {name: "truecolor to ansi16", from: colorTrue, to: color16},
205+ {
206+ name: "declared ansi16 resume shows downgrade notice", from: colorTrue, to: color16,
207+ checkToasts: true, wantToasts: []string{"Terminal supports 16 colors; vev UI colors are reduced."},
208+ },
209+ {
210+ name: "forced monochrome resume shows no notice", from: colorTrue,
211+ to: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceForced},
212+ checkToasts: true,
213+ },
214+ {name: "monochrome to truecolor", from: colorMono, to: colorTrue},
215+ {name: "ansi16 to truecolor", from: color16, to: colorTrue},
216+ {name: "ansi16 to monochrome", from: color16, to: colorMono},
217+ {name: "same mode", from: color16, to: color16},
218+ {name: "live resume truecolor to monochrome", from: colorTrue, to: colorMono, liveResume: true},
219+ {name: "live resume monochrome to ansi16", from: colorMono, to: color16, liveResume: true},
220+ }
221+ for _, tt := range tests {
222+ t.Run(tt.name, func(t *testing.T) {
223+ pty, release := newBlockingPTY(t)
224+ defer release()
225+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
226+ oldTr := &closeTrackingTransport{}
227+ sess, ac, err := d.route(colorHello(protocol.IntentNew, "work", 1, 0, tt.from), oldTr)
228+ require.NoError(t, err)
229+ for _, w := range paneColorWrites {
230+ sess.tabs[0].focusedPane().screen.Write([]byte(w))
231+ }
232+ before := &colorClient{ac: ac, tr: oldTr}
233+ d.paint(sess, ac, true, nil)
234+ _, data := before.newOutput(t)
235+ requireProfile(t, tt.from.Mode, data)
236+
237+ token := ac.resumeToken
238+ if !tt.liveResume {
239+ d.clientGone(sess, ac, oldTr, false)
240+ }
241+ newTr := &closeTrackingTransport{}
242+ resumedSess, resumed, err := d.route(colorHello(protocol.IntentResume, "work", 1, token, tt.to), newTr)
243+ require.NoError(t, err)
244+ require.Same(t, sess, resumedSess)
245+ require.Same(t, ac, resumed)
246+ require.Equal(t, tt.to, resumed.terminalCapabilities.Color)
247+
248+ after := &colorClient{ac: resumed, tr: newTr}
249+ d.paint(resumedSess, resumed, true, nil)
250+ outs, data := after.newOutput(t)
251+ require.NotEmpty(t, outs)
252+ require.Zero(t, outs[0].Base, "resume starts with a full frame")
253+ requireProfile(t, tt.to.Mode, data)
254+ resumed.ackOutputState(outs[len(outs)-1].Epoch, outs[len(outs)-1].New)
255+
256+ // The profile also holds for incremental updates after resume.
257+ for _, w := range paneColorWrites {
258+ sess.tabs[0].focusedPane().screen.Write([]byte("\r\n" + w))
259+ }
260+ d.paint(resumedSess, resumed, false, nil)
261+ outs, data = after.newOutput(t)
262+ require.NotEmpty(t, outs)
263+ require.NotZero(t, outs[0].Base)
264+ requireProfile(t, tt.to.Mode, data)
265+
266+ if tt.checkToasts {
267+ toasts, _ := visibleToasts(resumed)
268+ var got []string
269+ for _, toast := range toasts {
270+ got = append(got, toast.Message)
271+ }
272+ require.Equal(t, tt.wantToasts, got)
273+ }
274+
275+ // Chrome styling follows the new mode as well.
276+ reduced := tt.to.Mode != terminalcap.TrueColor
277+ require.Equal(t, reduced, resumed.getAppliedTheme().Raw.DimByAttribute)
278+ })
279+ }
280+}
@@ -0,0 +1,280 @@
1+package daemon
2+
3+import (
4+ "regexp"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain/terminalcap"
10+ "github.com/bnema/vev/internal/protocol"
11+)
12+
13+var (
14+ colorTrue = terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}
15+ color16 = terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}
16+ colorMono = terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}
17+
18+ // paneColorWrites exercise every color channel a pane can carry: RGB
19+ // foreground/background, indexed 16-255 foreground/background, and RGB and
20+ // indexed underline color.
21+ paneColorWrites = []string{
22+ "\x1b[38;2;10;200;30mR\x1b[0m",
23+ "\x1b[48;2;200;10;30mB\x1b[0m",
24+ "\x1b[38;5;196mF\x1b[0m",
25+ "\x1b[48;5;21mG\x1b[0m",
26+ "\x1b[4;58;2;0;255;0mU\x1b[0m",
27+ "\x1b[4;58;5;200mV\x1b[0m",
28+ }
29+ extendedColor = regexp.MustCompile(`^(38|48|58)`)
30+)
31+
32+// colorClient is one attachment plus the output frames its transport received.
33+type colorClient struct {
34+ ac *attachedClient
35+ tr *closeTrackingTransport
36+ seen int
37+}
38+
39+// newOutput returns the Output messages sent since the previous call and their
40+// concatenated terminal bytes.
41+func (c *colorClient) newOutput(t *testing.T) ([]protocol.Output, []byte) {
42+ t.Helper()
43+ frames := testFramesOfType(c.tr.Sends(), "Output")
44+ var (
45+ outs []protocol.Output
46+ data []byte
47+ )
48+ for _, frame := range frames[c.seen:] {
49+ out := unmarshalTestOutput(t, frame.Payload)
50+ outs = append(outs, out)
51+ data = append(data, out.Data...)
52+ }
53+ c.seen = len(frames)
54+ return outs, data
55+}
56+
57+func colorHello(intent uint8, name string, client byte, token uint64, color terminalcap.ColorCapabilities) protocol.Hello {
58+ h := helloResumeCapable(intent, name, token)
59+ h.ClientID = [16]byte{client}
60+ h.Color = color
61+ return h
62+}
63+
64+// requireProfile asserts data is encoded for color: pane RGB/indexed channels
65+// survive only where the mode can express them.
66+func requireProfile(t *testing.T, mode terminalcap.ColorMode, data []byte) {
67+ t.Helper()
68+ params := colorParams(data)
69+ switch mode {
70+ case terminalcap.TrueColor:
71+ require.Contains(t, params, "38;2;10;200;30", "%q", data)
72+ require.Contains(t, params, "48;2;200;10;30", "%q", data)
73+ require.Contains(t, params, "58;2;0;255;0", "%q", data)
74+ require.Contains(t, params, "38;5;196", "%q", data)
75+ require.Contains(t, params, "48;5;21", "%q", data)
76+ require.Contains(t, params, "58;5;200", "%q", data)
77+ case terminalcap.ANSI16:
78+ require.NotEmpty(t, params, "ansi16 keeps basic colors: %q", data)
79+ for _, p := range params {
80+ require.NotRegexp(t, extendedColor, p, "ansi16 output carries extended color: %q", data)
81+ }
82+ case terminalcap.Monochrome:
83+ require.Empty(t, params, "monochrome output carries color: %q", data)
84+ require.NotRegexp(t, `\x1b\[[0-9;:]*(38|48|58)[;:]`, string(data))
85+ default:
86+ t.Fatalf("unhandled mode %v", mode)
87+ }
88+}
89+
90+func attachColorClients(t *testing.T, modes []terminalcap.ColorCapabilities) (*Daemon, *session, []*colorClient) {
91+ t.Helper()
92+ pty, release := newBlockingPTY(t)
93+ t.Cleanup(release)
94+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
95+ var (
96+ sess *session
97+ clients []*colorClient
98+ )
99+ for i, color := range modes {
100+ tr := &closeTrackingTransport{}
101+ intent := protocol.IntentAttach
102+ if i == 0 {
103+ intent = protocol.IntentNew
104+ }
105+ s, ac, err := d.route(colorHello(intent, "work", byte(i+1), 0, color), tr)
106+ require.NoError(t, err)
107+ if sess == nil {
108+ sess = s
109+ }
110+ require.Same(t, sess, s)
111+ require.Equal(t, color.Mode, ac.terminalCapabilities.Color.Mode)
112+ clients = append(clients, &colorClient{ac: ac, tr: tr})
113+ }
114+ return d, sess, clients
115+}
116+
117+// TestAttachmentsEncodePaneOutputWithOwnColorProfile covers pane output for
118+// attachments of one session that negotiated different color modes: full
119+// first frame, incremental update, and explicit renderer reset.
120+func TestAttachmentsEncodePaneOutputWithOwnColorProfile(t *testing.T) {
121+ modes := []terminalcap.ColorCapabilities{colorTrue, color16, colorMono}
122+ d, sess, clients := attachColorClients(t, modes)
123+ pane := sess.tabs[0].focusedPane()
124+ for _, w := range paneColorWrites {
125+ pane.screen.Write([]byte(w))
126+ }
127+
128+ paintAll := func(reset bool) {
129+ for _, c := range clients {
130+ d.paint(sess, c.ac, reset, nil)
131+ }
132+ }
133+ // Each step repaints every attachment and then checks that attachment's
134+ // bytes against its own mode.
135+ steps := []struct {
136+ name string
137+ setup func()
138+ reset bool
139+ full bool
140+ }{
141+ {name: "initial full frame", reset: true, full: true},
142+ {name: "incremental draw", setup: func() {
143+ for _, w := range paneColorWrites {
144+ pane.screen.Write([]byte("\r\n" + w))
145+ }
146+ }},
147+ {name: "renderer reset", reset: true, full: true},
148+ {name: "incremental after reset", setup: func() {
149+ for _, w := range paneColorWrites {
150+ pane.screen.Write([]byte("\r\n" + w))
151+ }
152+ }},
153+ }
154+ var prevNew [3]uint64
155+ for _, step := range steps {
156+ if step.setup != nil {
157+ step.setup()
158+ }
159+ paintAll(step.reset)
160+ for i, c := range clients {
161+ t.Run(step.name+"/"+modeName(modes[i].Mode), func(t *testing.T) {
162+ outs, data := c.newOutput(t)
163+ require.NotEmpty(t, outs)
164+ first := outs[0]
165+ if step.full {
166+ require.Zero(t, first.Base, "full frames start a new chain")
167+ } else {
168+ require.Equal(t, prevNew[i], first.Base, "incremental frames extend the chain")
169+ }
170+ prevNew[i] = outs[len(outs)-1].New
171+ c.ac.ackOutputState(outs[len(outs)-1].Epoch, outs[len(outs)-1].New)
172+ requireProfile(t, modes[i].Mode, data)
173+ })
174+ }
175+ }
176+}
177+
178+func modeName(m terminalcap.ColorMode) string {
179+ switch m {
180+ case terminalcap.TrueColor:
181+ return "truecolor"
182+ case terminalcap.ANSI16:
183+ return "ansi16"
184+ case terminalcap.Monochrome:
185+ return "monochrome"
186+ default:
187+ return "other"
188+ }
189+}
190+
191+// TestResumeAdoptsColorOfLatestHello proves a resumed attachment encodes for
192+// the replacement terminal's Hello.Color, not the one it parked with.
193+func TestResumeAdoptsColorOfLatestHello(t *testing.T) {
194+ tests := []struct {
195+ name string
196+ from, to terminalcap.ColorCapabilities
197+ liveResume bool // resume while the old link is still registered
198+ // wantToasts is checked only when checkToasts is set; it lists the
199+ // visible toast messages after resume.
200+ checkToasts bool
201+ wantToasts []string
202+ }{
203+ {name: "truecolor to monochrome", from: colorTrue, to: colorMono},
204+ {name: "truecolor to ansi16", from: colorTrue, to: color16},
205+ {
206+ name: "declared ansi16 resume shows downgrade notice", from: colorTrue, to: color16,
207+ checkToasts: true, wantToasts: []string{"Terminal supports 16 colors; vev UI colors are reduced."},
208+ },
209+ {
210+ name: "forced monochrome resume shows no notice", from: colorTrue,
211+ to: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceForced},
212+ checkToasts: true,
213+ },
214+ {name: "monochrome to truecolor", from: colorMono, to: colorTrue},
215+ {name: "ansi16 to truecolor", from: color16, to: colorTrue},
216+ {name: "ansi16 to monochrome", from: color16, to: colorMono},
217+ {name: "same mode", from: color16, to: color16},
218+ {name: "live resume truecolor to monochrome", from: colorTrue, to: colorMono, liveResume: true},
219+ {name: "live resume monochrome to ansi16", from: colorMono, to: color16, liveResume: true},
220+ }
221+ for _, tt := range tests {
222+ t.Run(tt.name, func(t *testing.T) {
223+ pty, release := newBlockingPTY(t)
224+ defer release()
225+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
226+ oldTr := &closeTrackingTransport{}
227+ sess, ac, err := d.route(colorHello(protocol.IntentNew, "work", 1, 0, tt.from), oldTr)
228+ require.NoError(t, err)
229+ for _, w := range paneColorWrites {
230+ sess.tabs[0].focusedPane().screen.Write([]byte(w))
231+ }
232+ before := &colorClient{ac: ac, tr: oldTr}
233+ d.paint(sess, ac, true, nil)
234+ _, data := before.newOutput(t)
235+ requireProfile(t, tt.from.Mode, data)
236+
237+ token := ac.resumeToken
238+ if !tt.liveResume {
239+ d.clientGone(sess, ac, oldTr, false)
240+ }
241+ newTr := &closeTrackingTransport{}
242+ resumedSess, resumed, err := d.route(colorHello(protocol.IntentResume, "work", 1, token, tt.to), newTr)
243+ require.NoError(t, err)
244+ require.Same(t, sess, resumedSess)
245+ require.Same(t, ac, resumed)
246+ require.Equal(t, tt.to, resumed.terminalCapabilities.Color)
247+
248+ after := &colorClient{ac: resumed, tr: newTr}
249+ d.paint(resumedSess, resumed, true, nil)
250+ outs, data := after.newOutput(t)
251+ require.NotEmpty(t, outs)
252+ require.Zero(t, outs[0].Base, "resume starts with a full frame")
253+ requireProfile(t, tt.to.Mode, data)
254+ resumed.ackOutputState(outs[len(outs)-1].Epoch, outs[len(outs)-1].New)
255+
256+ // The profile also holds for incremental updates after resume.
257+ for _, w := range paneColorWrites {
258+ sess.tabs[0].focusedPane().screen.Write([]byte("\r\n" + w))
259+ }
260+ d.paint(resumedSess, resumed, false, nil)
261+ outs, data = after.newOutput(t)
262+ require.NotEmpty(t, outs)
263+ require.NotZero(t, outs[0].Base)
264+ requireProfile(t, tt.to.Mode, data)
265+
266+ if tt.checkToasts {
267+ toasts, _ := visibleToasts(resumed)
268+ var got []string
269+ for _, toast := range toasts {
270+ got = append(got, toast.Message)
271+ }
272+ require.Equal(t, tt.wantToasts, got)
273+ }
274+
275+ // Chrome styling follows the new mode as well.
276+ reduced := tt.to.Mode != terminalcap.TrueColor
277+ require.Equal(t, reduced, resumed.getAppliedTheme().Raw.DimByAttribute)
278+ })
279+ }
280+}
M internal/usecase/daemon/attachment_output.go +25 -5
@@ -10,6 +10,7 @@ import (
1010 "github.com/bnema/vev/internal/domain"
1111 "github.com/bnema/vev/internal/domain/terminalcap"
1212 "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/usecase/colorprofile"
1314 )
1415
1516 // attachmentOutput owns the terminal-output dependency chain and emitted
@@ -49,6 +50,16 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient,
4950 return
5051 }
5152 ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
53+ // The replacement terminal's color claim wins over whatever the previous
54+ // link negotiated: its renderer must encode for the terminal now attached.
55+ // Only the mode changes the encoding; the source is stored but never
56+ // forces a renderer swap.
57+ color := terminalcap.Resolve(h.Env, h.Color).Color
58+ swap := color.Mode != ac.terminalCapabilities.Color.Mode
59+ ac.terminalCapabilities.Color = color
60+ if swap {
61+ ac.output.setColorProfile(colorprofile.Profile(color))
62+ }
5263 // A replacement terminal declares its own keyboard protocol.
5364 if ac.keys != nil {
5465 ac.keys.SetKittyKeyboard(h.KittyKeyboard)
@@ -71,6 +82,19 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient,
7182 ac.terminalCapabilities.KittyGraphics = false
7283 }
7384
85+// setColorProfile swaps in a renderer for profile. The next frame is a full
86+// repaint (rebaseLocked forces a snapshot), so no emitted-state diff crosses
87+// the profile change.
88+func (s *attachmentOutput) setColorProfile(profile renderer.ColorProfile) {
89+ if s == nil {
90+ return
91+ }
92+ s.lockView()
93+ defer s.unlockView()
94+ s.renderer = renderer.NewWithColorProfile(renderer.Capabilities{}, profile)
95+ s.rebaseLocked()
96+}
97+
7498 // lockView serializes an attachment's view publication with every output
7599 // state transition. It deliberately does not take sendMu: callers that hold
76100 // architecture locks can publish a view without reversing the paint path's
@@ -102,11 +126,7 @@ func newOutputStateStream(windowSize ...uint8) *attachmentOutput {
102126 }
103127
104128 func newOutputStateStreamForCapabilities(capabilities terminalcap.Capabilities, windowSize ...uint8) *attachmentOutput {
105- profile := renderer.ColorProfileANSI256
106- if capabilities.TrueColor() {
107- profile = renderer.ColorProfileTrueColor
108- }
109- return newOutputStateStreamForProfile(profile, windowSize...)
129+ return newOutputStateStreamForProfile(colorprofile.Profile(capabilities.Color), windowSize...)
110130 }
111131
112132 func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *attachmentOutput {
@@ -10,6 +10,7 @@ import (
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/domain/terminalcap"11 "github.com/bnema/vev/internal/domain/terminalcap"
12 "github.com/bnema/vev/internal/protocol"12 "github.com/bnema/vev/internal/protocol"
13+ "github.com/bnema/vev/internal/usecase/colorprofile"
13 )14 )
14 15
15 // attachmentOutput owns the terminal-output dependency chain and emitted16 // attachmentOutput owns the terminal-output dependency chain and emitted
@@ -49,6 +50,16 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient,
49 return50 return
50 }51 }
51 ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics52 ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
53+ // The replacement terminal's color claim wins over whatever the previous
54+ // link negotiated: its renderer must encode for the terminal now attached.
55+ // Only the mode changes the encoding; the source is stored but never
56+ // forces a renderer swap.
57+ color := terminalcap.Resolve(h.Env, h.Color).Color
58+ swap := color.Mode != ac.terminalCapabilities.Color.Mode
59+ ac.terminalCapabilities.Color = color
60+ if swap {
61+ ac.output.setColorProfile(colorprofile.Profile(color))
62+ }
52 // A replacement terminal declares its own keyboard protocol.63 // A replacement terminal declares its own keyboard protocol.
53 if ac.keys != nil {64 if ac.keys != nil {
54 ac.keys.SetKittyKeyboard(h.KittyKeyboard)65 ac.keys.SetKittyKeyboard(h.KittyKeyboard)
@@ -71,6 +82,19 @@ func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient,
71 ac.terminalCapabilities.KittyGraphics = false82 ac.terminalCapabilities.KittyGraphics = false
72 }83 }
73 84
85+// setColorProfile swaps in a renderer for profile. The next frame is a full
86+// repaint (rebaseLocked forces a snapshot), so no emitted-state diff crosses
87+// the profile change.
88+func (s *attachmentOutput) setColorProfile(profile renderer.ColorProfile) {
89+ if s == nil {
90+ return
91+ }
92+ s.lockView()
93+ defer s.unlockView()
94+ s.renderer = renderer.NewWithColorProfile(renderer.Capabilities{}, profile)
95+ s.rebaseLocked()
96+}
97+
74 // lockView serializes an attachment's view publication with every output98 // lockView serializes an attachment's view publication with every output
75 // state transition. It deliberately does not take sendMu: callers that hold99 // state transition. It deliberately does not take sendMu: callers that hold
76 // architecture locks can publish a view without reversing the paint path's100 // architecture locks can publish a view without reversing the paint path's
@@ -102,11 +126,7 @@ func newOutputStateStream(windowSize ...uint8) *attachmentOutput {
102 }126 }
103 127
104 func newOutputStateStreamForCapabilities(capabilities terminalcap.Capabilities, windowSize ...uint8) *attachmentOutput {128 func newOutputStateStreamForCapabilities(capabilities terminalcap.Capabilities, windowSize ...uint8) *attachmentOutput {
105- profile := renderer.ColorProfileANSI256129+ return newOutputStateStreamForProfile(colorprofile.Profile(capabilities.Color), windowSize...)
106- if capabilities.TrueColor() {
107- profile = renderer.ColorProfileTrueColor
108- }
109- return newOutputStateStreamForProfile(profile, windowSize...)
110 }130 }
111 131
112 func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *attachmentOutput {132 func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *attachmentOutput {
M internal/usecase/daemon/attachment_output_test.go +2 -2
@@ -59,12 +59,12 @@ func TestAttachmentOutputUsesTerminalColorProfile(t *testing.T) {
5959 }{
6060 {
6161 name: "truecolor",
62- capabilities: terminalcap.Capabilities{ColorMode: terminalcap.TrueColor},
62+ capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
6363 want: "\x1b[1;1H\x1b[0;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0mX\x1b[0m",
6464 },
6565 {
6666 name: "indexed 256",
67- capabilities: terminalcap.Capabilities{ColorMode: terminalcap.Indexed256},
67+ capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
6868 want: "\x1b[1;1H\x1b[0;38;5;196;48;5;21;58;5;46mX\x1b[0m",
6969 },
7070 }
@@ -59,12 +59,12 @@ func TestAttachmentOutputUsesTerminalColorProfile(t *testing.T) {
59 }{59 }{
60 {60 {
61 name: "truecolor",61 name: "truecolor",
62- capabilities: terminalcap.Capabilities{ColorMode: terminalcap.TrueColor},62+ capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
63 want: "\x1b[1;1H\x1b[0;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0mX\x1b[0m",63 want: "\x1b[1;1H\x1b[0;38;2;255;0;0;48;2;0;0;255;58;2;0;255;0mX\x1b[0m",
64 },64 },
65 {65 {
66 name: "indexed 256",66 name: "indexed 256",
67- capabilities: terminalcap.Capabilities{ColorMode: terminalcap.Indexed256},67+ capabilities: terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
68 want: "\x1b[1;1H\x1b[0;38;5;196;48;5;21;58;5;46mX\x1b[0m",68 want: "\x1b[1;1H\x1b[0;38;5;196;48;5;21;58;5;46mX\x1b[0m",
69 },69 },
70 }70 }
M internal/usecase/daemon/client.go +41 -13
@@ -260,7 +260,7 @@ func (ac *attachedClient) getAppliedTheme() appliedTheme {
260260 if applied.Generation == 0 {
261261 // An unattached client has no terminal report yet. Reuse the static
262262 // neutral cache rather than resolving from a render path.
263- applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackChromeStyles}
263+ applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackStylesFor(ac.terminalCapabilities.Color)}
264264 }
265265 return applied
266266 }
@@ -693,19 +693,46 @@ 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 "Terminal supports 256 colors; vev UI colors are reduced.", true
706+ case terminalcap.ANSI16:
707+ return "Terminal supports 16 colors; vev UI colors are reduced.", true
708+ case terminalcap.Monochrome:
709+ return "Terminal has no color support; vev UI uses bold and reverse.", true
710+ default:
711+ return "", false
712+ }
713+}
714+
715+// publishColorDowngradeNotice toasts ac when its terminal declared a reduced
716+// color mode (see colorDowngradeNotice).
717+func (d *Daemon) publishColorDowngradeNotice(sess *session, ac *attachedClient) {
718+ message, ok := colorDowngradeNotice(ac.terminalCapabilities.Color)
719+ if !ok {
720+ return
721+ }
722+ d.publishToast(ac, domain.Notification{
723+ Code: domain.NoticeUser,
724+ Severity: domain.NoticeWarn,
725+ Message: message,
726+ Time: d.clock.Now(),
727+ Count: 1,
728+ SessionID: sess.id,
729+ })
730+}
731+
696732 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
697733 d.touchMRU(sess)
698734 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
699- if ac.terminalCapabilities.ColorSource == terminalcap.SourceDeclared && !ac.terminalCapabilities.TrueColor() {
700- d.publishToast(ac, domain.Notification{
701- Code: domain.NoticeUser,
702- Severity: domain.NoticeWarn,
703- Message: "TrueColor was not detected; rendering with 256 colors.",
704- Time: d.clock.Now(),
705- Count: 1,
706- SessionID: sess.id,
707- })
708- }
735+ d.publishColorDowngradeNotice(sess, ac)
709736 d.applyHostTheme(sess, ac, themeui.Theme{}, true)
710737 if !ac.terminalCapabilities.SupportsKittyGraphics() && sessionHasKittyGraphics(sess) {
711738 d.warnUnsupportedGraphics(ac)
@@ -721,7 +748,9 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
721748 resumeToken = d.nextResumeTokenLocked()
722749 }
723750 if !opts.capabilitiesSet {
724- opts.terminalCapabilities = terminalcap.Capabilities{ColorMode: terminalcap.TrueColor}
751+ // The zero value is TrueColor: manually constructed attachments keep
752+ // the historical renderer behavior.
753+ opts.terminalCapabilities = terminalcap.Capabilities{}
725754 }
726755 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)
727756 geometry = geometry.NormalizePixels()
@@ -945,7 +974,6 @@ func themeFromMessage(msg protocol.Theme) themeui.Theme {
945974 PaletteKnown: msg.PaletteKnown,
946975 HasFG: msg.HasForeground,
947976 HasBG: msg.HasBackground,
948- TrueColor: msg.TrueColor,
949977 Known: msg.HasForeground && msg.HasBackground,
950978 SchemeKnown: msg.SchemeKnown,
951979 Light: msg.Light,
@@ -260,7 +260,7 @@ func (ac *attachedClient) getAppliedTheme() appliedTheme {
260 if applied.Generation == 0 {260 if applied.Generation == 0 {
261 // An unattached client has no terminal report yet. Reuse the static261 // An unattached client has no terminal report yet. Reuse the static
262 // neutral cache rather than resolving from a render path.262 // neutral cache rather than resolving from a render path.
263- applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackChromeStyles}263+ applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackStylesFor(ac.terminalCapabilities.Color)}
264 }264 }
265 return applied265 return applied
266 }266 }
@@ -693,19 +693,46 @@ 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 "Terminal supports 256 colors; vev UI colors are reduced.", true
706+ case terminalcap.ANSI16:
707+ return "Terminal supports 16 colors; vev UI colors are reduced.", true
708+ case terminalcap.Monochrome:
709+ return "Terminal has no color support; vev UI uses bold and reverse.", true
710+ default:
711+ return "", false
712+ }
713+}
714+
715+// publishColorDowngradeNotice toasts ac when its terminal declared a reduced
716+// color mode (see colorDowngradeNotice).
717+func (d *Daemon) publishColorDowngradeNotice(sess *session, ac *attachedClient) {
718+ message, ok := colorDowngradeNotice(ac.terminalCapabilities.Color)
719+ if !ok {
720+ return
721+ }
722+ d.publishToast(ac, domain.Notification{
723+ Code: domain.NoticeUser,
724+ Severity: domain.NoticeWarn,
725+ Message: message,
726+ Time: d.clock.Now(),
727+ Count: 1,
728+ SessionID: sess.id,
729+ })
730+}
731+
696 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {732 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
697 d.touchMRU(sess)733 d.touchMRU(sess)
698 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)734 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
699- if ac.terminalCapabilities.ColorSource == terminalcap.SourceDeclared && !ac.terminalCapabilities.TrueColor() {735+ d.publishColorDowngradeNotice(sess, ac)
700- d.publishToast(ac, domain.Notification{
701- Code: domain.NoticeUser,
702- Severity: domain.NoticeWarn,
703- Message: "TrueColor was not detected; rendering with 256 colors.",
704- Time: d.clock.Now(),
705- Count: 1,
706- SessionID: sess.id,
707- })
708- }
709 d.applyHostTheme(sess, ac, themeui.Theme{}, true)736 d.applyHostTheme(sess, ac, themeui.Theme{}, true)
710 if !ac.terminalCapabilities.SupportsKittyGraphics() && sessionHasKittyGraphics(sess) {737 if !ac.terminalCapabilities.SupportsKittyGraphics() && sessionHasKittyGraphics(sess) {
711 d.warnUnsupportedGraphics(ac)738 d.warnUnsupportedGraphics(ac)
@@ -721,7 +748,9 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
721 resumeToken = d.nextResumeTokenLocked()748 resumeToken = d.nextResumeTokenLocked()
722 }749 }
723 if !opts.capabilitiesSet {750 if !opts.capabilitiesSet {
724- opts.terminalCapabilities = terminalcap.Capabilities{ColorMode: terminalcap.TrueColor}751+ // The zero value is TrueColor: manually constructed attachments keep
752+ // the historical renderer behavior.
753+ opts.terminalCapabilities = terminalcap.Capabilities{}
725 }754 }
726 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)755 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)
727 geometry = geometry.NormalizePixels()756 geometry = geometry.NormalizePixels()
@@ -945,7 +974,6 @@ func themeFromMessage(msg protocol.Theme) themeui.Theme {
945 PaletteKnown: msg.PaletteKnown,974 PaletteKnown: msg.PaletteKnown,
946 HasFG: msg.HasForeground,975 HasFG: msg.HasForeground,
947 HasBG: msg.HasBackground,976 HasBG: msg.HasBackground,
948- TrueColor: msg.TrueColor,
949 Known: msg.HasForeground && msg.HasBackground,977 Known: msg.HasForeground && msg.HasBackground,
950 SchemeKnown: msg.SchemeKnown,978 SchemeKnown: msg.SchemeKnown,
951 Light: msg.Light,979 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: "Terminal supports 256 colors; vev UI colors are reduced."},
18+ {name: "declared 16 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}, want: "Terminal supports 16 colors; vev UI colors are reduced."},
19+ {name: "declared monochrome", color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}, want: "Terminal has no color support; vev UI uses bold and reverse."},
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: "Terminal supports 256 colors; vev UI colors are reduced."},
46+ {name: "declared 16 colors toasts", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}, wantToast: "Terminal supports 16 colors; vev UI colors are reduced."},
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: "Terminal supports 256 colors; vev UI colors are reduced."},
18+ {name: "declared 16 colors", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}, want: "Terminal supports 16 colors; vev UI colors are reduced."},
19+ {name: "declared monochrome", color: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}, want: "Terminal has no color support; vev UI uses bold and reverse."},
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: "Terminal supports 256 colors; vev UI colors are reduced."},
46+ {name: "declared 16 colors toasts", color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}, wantToast: "Terminal supports 16 colors; vev UI colors are reduced."},
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/config.go +7 -2
@@ -10,6 +10,7 @@ import (
1010 "time"
1111
1212 "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/domain/terminalcap"
1314 "github.com/bnema/vev/internal/usecase/command"
1415 "github.com/bnema/vev/internal/usecase/keys"
1516 "github.com/bnema/vev/internal/usecase/theme"
@@ -344,10 +345,14 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
344345 return effectiveThemeForConfig(clientTheme, d.currentThemeConfig())
345346 }
346347
347-func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme {
348+// resolveAppliedTheme resolves chrome for one attachment's color capability;
349+// ANSI16 and monochrome attachments get attribute-based chrome, so the result
350+// is never shared between attachments of different modes.
351+func (d *Daemon) resolveAppliedTheme(raw theme.Theme, color terminalcap.ColorCapabilities) appliedTheme {
348352 config := d.currentThemeConfig()
349353 effective := effectiveThemeForConfig(raw, config)
350- return appliedTheme{Raw: effective, Resolved: theme.Resolve(effective, config.accent)}
354+ resolved := theme.ResolveForColor(effective, config.accent, color)
355+ return appliedTheme{Raw: resolved.Theme, Resolved: resolved}
351356 }
352357
353358 func (d *Daemon) reapplyThemeAllSessions() {
@@ -10,6 +10,7 @@ import (
10 "time"10 "time"
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/usecase/command"14 "github.com/bnema/vev/internal/usecase/command"
14 "github.com/bnema/vev/internal/usecase/keys"15 "github.com/bnema/vev/internal/usecase/keys"
15 "github.com/bnema/vev/internal/usecase/theme"16 "github.com/bnema/vev/internal/usecase/theme"
@@ -344,10 +345,14 @@ func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
344 return effectiveThemeForConfig(clientTheme, d.currentThemeConfig())345 return effectiveThemeForConfig(clientTheme, d.currentThemeConfig())
345 }346 }
346 347
347-func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme {348+// resolveAppliedTheme resolves chrome for one attachment's color capability;
349+// ANSI16 and monochrome attachments get attribute-based chrome, so the result
350+// is never shared between attachments of different modes.
351+func (d *Daemon) resolveAppliedTheme(raw theme.Theme, color terminalcap.ColorCapabilities) appliedTheme {
348 config := d.currentThemeConfig()352 config := d.currentThemeConfig()
349 effective := effectiveThemeForConfig(raw, config)353 effective := effectiveThemeForConfig(raw, config)
350- return appliedTheme{Raw: effective, Resolved: theme.Resolve(effective, config.accent)}354+ resolved := theme.ResolveForColor(effective, config.accent, color)
355+ return appliedTheme{Raw: resolved.Theme, Resolved: resolved}
351 }356 }
352 357
353 func (d *Daemon) reapplyThemeAllSessions() {358 func (d *Daemon) reapplyThemeAllSessions() {
M internal/usecase/daemon/daemon.go +1 -5
@@ -1477,15 +1477,11 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
14771477 // otherwise falls back to the session's normal first-tab repair.
14781478 initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)
14791479 }
1480- terminalCapabilities := terminalcap.Detect(h.Env)
1480+ terminalCapabilities := terminalcap.Resolve(h.Env, h.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.
14841484 terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
1485- if h.TrueColor && !terminalCapabilities.TrueColor() {
1486- terminalCapabilities.ColorMode = terminalcap.TrueColor
1487- terminalCapabilities.ColorSource = terminalcap.SourceDeclared
1488- }
14891485 opts := attachClientOptions{
14901486 clientID: h.ClientID,
14911487 kittyKeyboard: h.KittyKeyboard,
@@ -1477,15 +1477,11 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
1477 // otherwise falls back to the session's normal first-tab repair.1477 // otherwise falls back to the session's normal first-tab repair.
1478 initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)1478 initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)
1479 }1479 }
1480- terminalCapabilities := terminalcap.Detect(h.Env)1480+ terminalCapabilities := terminalcap.Resolve(h.Env, h.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.
1484 terminalCapabilities.KittyGraphics = h.KittyDirectGraphics1484 terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
1485- if h.TrueColor && !terminalCapabilities.TrueColor() {
1486- terminalCapabilities.ColorMode = terminalcap.TrueColor
1487- terminalCapabilities.ColorSource = terminalcap.SourceDeclared
1488- }
1489 opts := attachClientOptions{1485 opts := attachClientOptions{
1490 clientID: h.ClientID,1486 clientID: h.ClientID,
1491 kittyKeyboard: h.KittyKeyboard,1487 kittyKeyboard: h.KittyKeyboard,
M internal/usecase/daemon/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/graphics_output_test.go +1 -1
@@ -510,7 +510,7 @@ func TestGraphicsNamespacePoolExhaustionFallsBackToText(t *testing.T) {
510510 domain.Geometry{Size: defaultSize},
511511 attachClientOptions{
512512 capabilitiesSet: true,
513- terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, ColorMode: terminalcap.TrueColor},
513+ terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
514514 },
515515 )
516516 d.mu.Unlock()
@@ -510,7 +510,7 @@ func TestGraphicsNamespacePoolExhaustionFallsBackToText(t *testing.T) {
510 domain.Geometry{Size: defaultSize},510 domain.Geometry{Size: defaultSize},
511 attachClientOptions{511 attachClientOptions{
512 capabilitiesSet: true,512 capabilitiesSet: true,
513- terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, ColorMode: terminalcap.TrueColor},513+ terminalCapabilities: terminalcap.Capabilities{KittyGraphics: true, Color: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor}},
514 },514 },
515 )515 )
516 d.mu.Unlock()516 d.mu.Unlock()
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 +3 -3
@@ -10,6 +10,7 @@ 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 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1415 "github.com/bnema/vev/internal/protocol"
1516 "github.com/bnema/vev/internal/usecase/command"
@@ -131,11 +132,10 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
131132 paletteColors[10] = paletteColors[2]
132133 accentTheme := themeui.Theme{
133134 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,
135+ HasFG: true, HasBG: true, Known: true, UsePalette: true,
135136 Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
136137 }
137138 indexedTheme := accentTheme
138- indexedTheme.TrueColor = false
139139 neutralTheme := accentTheme
140140 neutralTheme.PaletteKnown = 0
141141 neutralTheme.SchemeKnown = false
@@ -164,7 +164,7 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
164164 t.Run(tt.name, func(t *testing.T) {
165165 d := newTestDaemon(t, nil, stubClock{})
166166 d.ApplyConfig(tt.config)
167- applied := d.resolveAppliedTheme(tt.raw)
167+ applied := d.resolveAppliedTheme(tt.raw, terminalcap.ColorCapabilities{})
168168 model := palette.New(palette.CommandResults([]command.Command{
169169 {Code: "ONE", Name: "One", Desc: "first description"},
170170 {Code: "TWO", Name: "Two", Desc: "second description"},
@@ -10,6 +10,7 @@ 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 portsmocks "github.com/bnema/vev/internal/ports/mocks"14 portsmocks "github.com/bnema/vev/internal/ports/mocks"
14 "github.com/bnema/vev/internal/protocol"15 "github.com/bnema/vev/internal/protocol"
15 "github.com/bnema/vev/internal/usecase/command"16 "github.com/bnema/vev/internal/usecase/command"
@@ -131,11 +132,10 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
131 paletteColors[10] = paletteColors[2]132 paletteColors[10] = paletteColors[2]
132 accentTheme := themeui.Theme{133 accentTheme := themeui.Theme{
133 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},134 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,135+ HasFG: true, HasBG: true, Known: true, UsePalette: true,
135 Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,136 Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
136 }137 }
137 indexedTheme := accentTheme138 indexedTheme := accentTheme
138- indexedTheme.TrueColor = false
139 neutralTheme := accentTheme139 neutralTheme := accentTheme
140 neutralTheme.PaletteKnown = 0140 neutralTheme.PaletteKnown = 0
141 neutralTheme.SchemeKnown = false141 neutralTheme.SchemeKnown = false
@@ -164,7 +164,7 @@ func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t
164 t.Run(tt.name, func(t *testing.T) {164 t.Run(tt.name, func(t *testing.T) {
165 d := newTestDaemon(t, nil, stubClock{})165 d := newTestDaemon(t, nil, stubClock{})
166 d.ApplyConfig(tt.config)166 d.ApplyConfig(tt.config)
167- applied := d.resolveAppliedTheme(tt.raw)167+ applied := d.resolveAppliedTheme(tt.raw, terminalcap.ColorCapabilities{})
168 model := palette.New(palette.CommandResults([]command.Command{168 model := palette.New(palette.CommandResults([]command.Command{
169 {Code: "ONE", Name: "One", Desc: "first description"},169 {Code: "ONE", Name: "One", Desc: "first description"},
170 {Code: "TWO", Name: "Two", Desc: "second description"},170 {Code: "TWO", Name: "Two", Desc: "second description"},
A internal/usecase/daemon/reduced_color_test.go +242 -0
@@ -0,0 +1,242 @@
1+package daemon
2+
3+import (
4+ "regexp"
5+ "strconv"
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ renderer "github.com/bnema/vev-vt"
12+ ansirenderer "github.com/bnema/vev-vt/ansi"
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/terminalcap"
15+ "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/usecase/colorprofile"
17+ "github.com/bnema/vev/internal/usecase/layout"
18+ themeui "github.com/bnema/vev/internal/usecase/theme"
19+ "github.com/bnema/vev/internal/usecase/ui"
20+)
21+
22+var sgrSequence = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
23+
24+// colorParams returns every color-bearing SGR parameter group in out, e.g.
25+// "31", "48;2;1;2;3", "58;5;9". Attribute parameters (0, 1, 2, 3, 4, 7...) are
26+// not reported.
27+func colorParams(out []byte) []string {
28+ var found []string
29+ for _, m := range sgrSequence.FindAllStringSubmatch(string(out), -1) {
30+ params := strings.Split(m[1], ";")
31+ for i := 0; i < len(params); i++ {
32+ n, err := strconv.Atoi(params[i])
33+ if err != nil {
34+ continue
35+ }
36+ switch {
37+ case n == 38 || n == 48 || n == 58:
38+ end := len(params)
39+ if i+1 < len(params) && params[i+1] == "2" {
40+ end = min(i+5, len(params))
41+ } else if i+1 < len(params) {
42+ end = min(i+3, len(params))
43+ }
44+ found = append(found, strings.Join(params[i:end], ";"))
45+ i = end - 1
46+ case n >= 30 && n <= 37, n >= 40 && n <= 47, n >= 90 && n <= 97, n >= 100 && n <= 107:
47+ found = append(found, params[i])
48+ }
49+ }
50+ }
51+ return found
52+}
53+
54+func reducedTestTheme() themeui.Theme {
55+ palette := [16]renderer.RGB{}
56+ palette[4] = renderer.RGB{R: 60, G: 120, B: 220}
57+ palette[12] = palette[4]
58+ return themeui.Theme{
59+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
60+ HasFG: true, HasBG: true, Known: true, UsePalette: true, SchemeKnown: true,
61+ Palette: palette, PaletteKnown: 1<<4 | 1<<12,
62+ }
63+}
64+
65+// reducedChromeFrame composes a representative frame: two tabs (second
66+// active) with an attention bell, a split with the left pane focused, a
67+// bottom bar with MRU entries, a toast, and a focused palette modal with a
68+// selected row. rawTheme is what the attachment reported.
69+func reducedChromeFrame(t *testing.T, color terminalcap.ColorCapabilities) (composedRenderFrame, themeui.ResolvedTheme) {
70+ t.Helper()
71+ resolved := themeui.ResolveForColor(reducedTestTheme(), domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, color)
72+ state := cachedSplitState("reduced", "left", layout.Horizontal, resolved.Theme)
73+ state.styles = resolved.Styles
74+ state.bars = barState{
75+ status: statusSnapshot{session: "work", tabs: []statusTab{{name: "one", paneTitle: "sh"}, {name: "two", paneTitle: "vim", active: true, attention: true}}},
76+ attentionFrame: pulseFrameCount / 2,
77+ mru: []recentRouteDisplay{{name: "alp"}, {name: "beta"}},
78+ theme: resolved.Theme,
79+ }
80+ state.panes[0].title, state.panes[1].title = "left", "right"
81+ state.overlays.notices = []domain.Notification{{Code: domain.NoticeUser, Severity: domain.NoticeWarn, Message: "careful", Count: 1}}
82+ inner := renderer.NewFrame(18, 3)
83+ for x, r := range "> sel" {
84+ inner.Set(x, 0, renderer.Cell{Rune: r, Style: resolved.Styles.PickerSelection})
85+ }
86+ state.overlays.paletteActive = true
87+ state.overlays.palette = capturedModal{
88+ active: true, title: "Palette", focused: true, inner: inner,
89+ presentation: ui.Presentation{Bounds: domain.Rect{X: 10, Y: 2, Width: 20, Height: 5}, Inner: domain.Rect{X: 11, Y: 3, Width: 18, Height: 3}, Borders: ui.BorderAll},
90+ }
91+ return composeFrame(state, composeCacheInput{}), resolved
92+}
93+
94+// rowIndex returns the x of the first cell of text on row y.
95+func rowIndex(frame renderer.Frame, y int, text string) int {
96+ var row strings.Builder
97+ for x := range frame.Width {
98+ r := frame.At(x, y).Rune
99+ if r == 0 {
100+ r = ' '
101+ }
102+ row.WriteRune(r)
103+ }
104+ return strings.Index(row.String(), text)
105+}
106+
107+func drawProfile(t *testing.T, frame renderer.Frame, color terminalcap.ColorCapabilities) []byte {
108+ t.Helper()
109+ out, err := ansirenderer.NewWithColorProfile(ansirenderer.Capabilities{}, colorprofile.Profile(color)).Draw(frame, []renderer.Damage{renderer.FullRedraw()})
110+ require.NoError(t, err)
111+ return out
112+}
113+
114+func TestReducedColorChromeIsLegible(t *testing.T) {
115+ ansi16 := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}
116+ mono := terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}
117+
118+ t.Run("ansi16 has no RGB, indexed or underline colors outside slots 0-15", func(t *testing.T) {
119+ composed, resolved := reducedChromeFrame(t, ansi16)
120+ out := drawProfile(t, composed.frame, ansi16)
121+ for _, p := range colorParams(out) {
122+ require.NotRegexp(t, `^(38|48|58)`, p, "ansi16 chrome emitted extended color %q", p)
123+ }
124+ // The accent is a palette slot, never RGB: the border and bell use it.
125+ require.True(t, resolved.Accent.Known)
126+ require.Contains(t, colorParams(out), "34", "accent slot 4 renders as the terminal's own blue")
127+ // Chrome surfaces are the terminal background: no RGB backgrounds.
128+ for x := range composed.frame.Width {
129+ for _, y := range []int{0, composed.frame.Height - 1} {
130+ require.False(t, composed.frame.At(x, y).Style.HasBackgroundRGB)
131+ }
132+ }
133+ })
134+
135+ t.Run("monochrome has no color sequences at all", func(t *testing.T) {
136+ composed, _ := reducedChromeFrame(t, mono)
137+ out := drawProfile(t, composed.frame, mono)
138+ require.Empty(t, colorParams(out), "%q", out)
139+ require.NotRegexp(t, `\x1b\[[0-9;:]*(38|48|58)`, string(out))
140+ })
141+
142+ for name, color := range map[string]terminalcap.ColorCapabilities{"ansi16": ansi16, "monochrome": mono} {
143+ t.Run(name+" distinguishes active, selected and focused elements", func(t *testing.T) {
144+ composed, resolved := reducedChromeFrame(t, color)
145+ frame := composed.frame
146+ // Top bar: " one" (inactive) vs " two" (active).
147+ inactive, active := frame.At(rowIndex(frame, 0, "one"), 0).Style, frame.At(rowIndex(frame, 0, "two"), 0).Style
148+ require.True(t, active.Inverse && active.Bold, "active tab must be bold reverse")
149+ require.False(t, inactive.Inverse)
150+ require.NotEqual(t, inactive.Canonical(), active.Canonical())
151+ // The attention bell is bold on its visible beat.
152+ bell := frame.At(rowIndex(frame, 0, string(ui.AttentionGlyph)), 0).Style
153+ require.True(t, bell.Bold && bell.Inverse)
154+ // Bottom bar: the session name is the active surface.
155+ require.True(t, frame.At(rowIndex(frame, frame.Height-1, "work"), frame.Height-1).Style.Inverse)
156+ require.False(t, frame.At(rowIndex(frame, frame.Height-1, "alp"), frame.Height-1).Style.Inverse)
157+ // Focused pane title bar is plain reverse (no stack title bars in
158+ // this fixture, so assert the role the title bar draws with).
159+ require.True(t, resolved.Styles.StatusBar.Inverse)
160+ // Palette selection row and focused modal border.
161+ require.True(t, frame.At(11, 3).Style.Inverse && frame.At(11, 3).Style.Bold, "selected row must be bold reverse")
162+ border := frame.At(10, 2).Style
163+ require.True(t, border.Bold, "focused modal border must be bold")
164+ require.Zero(t, border.Attrs&ansirenderer.AttrDim)
165+ // The same border, unfocused, is faint: focus is attribute-visible.
166+ require.NotZero(t, resolved.Styles.BorderMuted.Attrs&ansirenderer.AttrDim)
167+ // Copy selection is plain reverse.
168+ require.True(t, resolved.Styles.Selection.Inverse)
169+ require.True(t, resolved.Styles.CopyStatus.Inverse)
170+ // Toast box: warn border is visible without color.
171+ require.True(t, resolved.Styles.BorderWarn.Bold)
172+ })
173+ }
174+}
175+
176+// TestReducedColorLeavesPaneContentAlone guards that panes are never recolored
177+// or faded: vev-vt quantizes them, vev chrome must not touch them.
178+func TestReducedColorLeavesPaneContentAlone(t *testing.T) {
179+ for _, mode := range []terminalcap.ColorMode{terminalcap.ANSI16, terminalcap.Monochrome} {
180+ color := terminalcap.ColorCapabilities{Mode: mode}
181+ resolved := themeui.ResolveForColor(reducedTestTheme(), domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, color)
182+ state := cachedSplitState("pane", "left", layout.Horizontal, resolved.Theme)
183+ state.styles = resolved.Styles
184+ pane := state.panes[1].frame // unfocused
185+ cell := pane.At(0, 0)
186+ cell.Style.Foreground = 196
187+ pane.Set(0, 0, cell)
188+ composed := composeFrame(state, composeCacheInput{})
189+ x := state.panes[1].placement.Content.X
190+ y := state.panes[1].placement.Content.Y + 1
191+ require.Equal(t, cell.Style.Canonical(), composed.frame.At(x, y).Style.Canonical())
192+ }
193+}
194+
195+// TestResolveForColorKeepsRGBModesIdentical proves TrueColor and ANSI256
196+// attachments resolve exactly as before this policy existed, so their output
197+// bytes cannot change.
198+func TestResolveForColorKeepsRGBModesIdentical(t *testing.T) {
199+ raw := reducedTestTheme()
200+ policy := domain.ThemeAccent{Mode: domain.ThemeAccentAuto}
201+ want := themeui.Resolve(raw, policy)
202+ for _, color := range []terminalcap.ColorCapabilities{{}, {Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced}, {Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}} {
203+ require.Equal(t, want, themeui.ResolveForColor(raw, policy, color))
204+ }
205+ for _, color := range []terminalcap.ColorCapabilities{{Mode: terminalcap.TrueColor}, {Mode: terminalcap.ANSI256}} {
206+ composed, _ := reducedChromeFrame(t, color)
207+ state := cachedSplitState("reference", "left", layout.Horizontal, raw)
208+ _ = state
209+ out := drawProfile(t, composed.frame, color)
210+ require.Regexp(t, `(38|48);[25];`, string(out), "RGB-capable modes keep tinted surfaces")
211+ }
212+}
213+
214+// TestAppliedThemeIsPerAttachment confirms two attachments of one session
215+// resolve independently.
216+func TestAppliedThemeIsPerAttachment(t *testing.T) {
217+ p, release := newBlockingPTY(t)
218+ defer release()
219+ d, sess, ac16, _ := newManualSessionWithPTYs(t, p)
220+ ac16.terminalCapabilities = terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}}
221+ tr, _ := newCapturingTransport(t)
222+ acTrue := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
223+ acTrue.output.attachment = acTrue
224+ acTrue.initOverlays()
225+ sess.mu.Lock()
226+ sess.attachments[acTrue] = struct{}{}
227+ sess.mu.Unlock()
228+ acTrue.setSession(sess)
229+
230+ raw := reducedTestTheme()
231+ msg := protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown}
232+ d.applyTheme(sess, ac16, msg)
233+ d.applyTheme(sess, acTrue, msg)
234+ d.reapplyThemeSession(sess) // a config reload must keep each mode
235+
236+ reduced, rgb := ac16.getAppliedTheme(), acTrue.getAppliedTheme()
237+ require.True(t, reduced.Raw.DimByAttribute)
238+ require.False(t, rgb.Raw.DimByAttribute)
239+ require.False(t, reduced.Resolved.Styles.SurfaceActive.HasBackgroundRGB)
240+ require.True(t, rgb.Resolved.Styles.SurfaceActive.HasBackgroundRGB)
241+ require.Equal(t, themeui.Resolve(rgb.Raw, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, rgb.Resolved.Styles)
242+}
@@ -0,0 +1,242 @@
1+package daemon
2+
3+import (
4+ "regexp"
5+ "strconv"
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ renderer "github.com/bnema/vev-vt"
12+ ansirenderer "github.com/bnema/vev-vt/ansi"
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/domain/terminalcap"
15+ "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/usecase/colorprofile"
17+ "github.com/bnema/vev/internal/usecase/layout"
18+ themeui "github.com/bnema/vev/internal/usecase/theme"
19+ "github.com/bnema/vev/internal/usecase/ui"
20+)
21+
22+var sgrSequence = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
23+
24+// colorParams returns every color-bearing SGR parameter group in out, e.g.
25+// "31", "48;2;1;2;3", "58;5;9". Attribute parameters (0, 1, 2, 3, 4, 7...) are
26+// not reported.
27+func colorParams(out []byte) []string {
28+ var found []string
29+ for _, m := range sgrSequence.FindAllStringSubmatch(string(out), -1) {
30+ params := strings.Split(m[1], ";")
31+ for i := 0; i < len(params); i++ {
32+ n, err := strconv.Atoi(params[i])
33+ if err != nil {
34+ continue
35+ }
36+ switch {
37+ case n == 38 || n == 48 || n == 58:
38+ end := len(params)
39+ if i+1 < len(params) && params[i+1] == "2" {
40+ end = min(i+5, len(params))
41+ } else if i+1 < len(params) {
42+ end = min(i+3, len(params))
43+ }
44+ found = append(found, strings.Join(params[i:end], ";"))
45+ i = end - 1
46+ case n >= 30 && n <= 37, n >= 40 && n <= 47, n >= 90 && n <= 97, n >= 100 && n <= 107:
47+ found = append(found, params[i])
48+ }
49+ }
50+ }
51+ return found
52+}
53+
54+func reducedTestTheme() themeui.Theme {
55+ palette := [16]renderer.RGB{}
56+ palette[4] = renderer.RGB{R: 60, G: 120, B: 220}
57+ palette[12] = palette[4]
58+ return themeui.Theme{
59+ Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
60+ HasFG: true, HasBG: true, Known: true, UsePalette: true, SchemeKnown: true,
61+ Palette: palette, PaletteKnown: 1<<4 | 1<<12,
62+ }
63+}
64+
65+// reducedChromeFrame composes a representative frame: two tabs (second
66+// active) with an attention bell, a split with the left pane focused, a
67+// bottom bar with MRU entries, a toast, and a focused palette modal with a
68+// selected row. rawTheme is what the attachment reported.
69+func reducedChromeFrame(t *testing.T, color terminalcap.ColorCapabilities) (composedRenderFrame, themeui.ResolvedTheme) {
70+ t.Helper()
71+ resolved := themeui.ResolveForColor(reducedTestTheme(), domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, color)
72+ state := cachedSplitState("reduced", "left", layout.Horizontal, resolved.Theme)
73+ state.styles = resolved.Styles
74+ state.bars = barState{
75+ status: statusSnapshot{session: "work", tabs: []statusTab{{name: "one", paneTitle: "sh"}, {name: "two", paneTitle: "vim", active: true, attention: true}}},
76+ attentionFrame: pulseFrameCount / 2,
77+ mru: []recentRouteDisplay{{name: "alp"}, {name: "beta"}},
78+ theme: resolved.Theme,
79+ }
80+ state.panes[0].title, state.panes[1].title = "left", "right"
81+ state.overlays.notices = []domain.Notification{{Code: domain.NoticeUser, Severity: domain.NoticeWarn, Message: "careful", Count: 1}}
82+ inner := renderer.NewFrame(18, 3)
83+ for x, r := range "> sel" {
84+ inner.Set(x, 0, renderer.Cell{Rune: r, Style: resolved.Styles.PickerSelection})
85+ }
86+ state.overlays.paletteActive = true
87+ state.overlays.palette = capturedModal{
88+ active: true, title: "Palette", focused: true, inner: inner,
89+ presentation: ui.Presentation{Bounds: domain.Rect{X: 10, Y: 2, Width: 20, Height: 5}, Inner: domain.Rect{X: 11, Y: 3, Width: 18, Height: 3}, Borders: ui.BorderAll},
90+ }
91+ return composeFrame(state, composeCacheInput{}), resolved
92+}
93+
94+// rowIndex returns the x of the first cell of text on row y.
95+func rowIndex(frame renderer.Frame, y int, text string) int {
96+ var row strings.Builder
97+ for x := range frame.Width {
98+ r := frame.At(x, y).Rune
99+ if r == 0 {
100+ r = ' '
101+ }
102+ row.WriteRune(r)
103+ }
104+ return strings.Index(row.String(), text)
105+}
106+
107+func drawProfile(t *testing.T, frame renderer.Frame, color terminalcap.ColorCapabilities) []byte {
108+ t.Helper()
109+ out, err := ansirenderer.NewWithColorProfile(ansirenderer.Capabilities{}, colorprofile.Profile(color)).Draw(frame, []renderer.Damage{renderer.FullRedraw()})
110+ require.NoError(t, err)
111+ return out
112+}
113+
114+func TestReducedColorChromeIsLegible(t *testing.T) {
115+ ansi16 := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}
116+ mono := terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}
117+
118+ t.Run("ansi16 has no RGB, indexed or underline colors outside slots 0-15", func(t *testing.T) {
119+ composed, resolved := reducedChromeFrame(t, ansi16)
120+ out := drawProfile(t, composed.frame, ansi16)
121+ for _, p := range colorParams(out) {
122+ require.NotRegexp(t, `^(38|48|58)`, p, "ansi16 chrome emitted extended color %q", p)
123+ }
124+ // The accent is a palette slot, never RGB: the border and bell use it.
125+ require.True(t, resolved.Accent.Known)
126+ require.Contains(t, colorParams(out), "34", "accent slot 4 renders as the terminal's own blue")
127+ // Chrome surfaces are the terminal background: no RGB backgrounds.
128+ for x := range composed.frame.Width {
129+ for _, y := range []int{0, composed.frame.Height - 1} {
130+ require.False(t, composed.frame.At(x, y).Style.HasBackgroundRGB)
131+ }
132+ }
133+ })
134+
135+ t.Run("monochrome has no color sequences at all", func(t *testing.T) {
136+ composed, _ := reducedChromeFrame(t, mono)
137+ out := drawProfile(t, composed.frame, mono)
138+ require.Empty(t, colorParams(out), "%q", out)
139+ require.NotRegexp(t, `\x1b\[[0-9;:]*(38|48|58)`, string(out))
140+ })
141+
142+ for name, color := range map[string]terminalcap.ColorCapabilities{"ansi16": ansi16, "monochrome": mono} {
143+ t.Run(name+" distinguishes active, selected and focused elements", func(t *testing.T) {
144+ composed, resolved := reducedChromeFrame(t, color)
145+ frame := composed.frame
146+ // Top bar: " one" (inactive) vs " two" (active).
147+ inactive, active := frame.At(rowIndex(frame, 0, "one"), 0).Style, frame.At(rowIndex(frame, 0, "two"), 0).Style
148+ require.True(t, active.Inverse && active.Bold, "active tab must be bold reverse")
149+ require.False(t, inactive.Inverse)
150+ require.NotEqual(t, inactive.Canonical(), active.Canonical())
151+ // The attention bell is bold on its visible beat.
152+ bell := frame.At(rowIndex(frame, 0, string(ui.AttentionGlyph)), 0).Style
153+ require.True(t, bell.Bold && bell.Inverse)
154+ // Bottom bar: the session name is the active surface.
155+ require.True(t, frame.At(rowIndex(frame, frame.Height-1, "work"), frame.Height-1).Style.Inverse)
156+ require.False(t, frame.At(rowIndex(frame, frame.Height-1, "alp"), frame.Height-1).Style.Inverse)
157+ // Focused pane title bar is plain reverse (no stack title bars in
158+ // this fixture, so assert the role the title bar draws with).
159+ require.True(t, resolved.Styles.StatusBar.Inverse)
160+ // Palette selection row and focused modal border.
161+ require.True(t, frame.At(11, 3).Style.Inverse && frame.At(11, 3).Style.Bold, "selected row must be bold reverse")
162+ border := frame.At(10, 2).Style
163+ require.True(t, border.Bold, "focused modal border must be bold")
164+ require.Zero(t, border.Attrs&ansirenderer.AttrDim)
165+ // The same border, unfocused, is faint: focus is attribute-visible.
166+ require.NotZero(t, resolved.Styles.BorderMuted.Attrs&ansirenderer.AttrDim)
167+ // Copy selection is plain reverse.
168+ require.True(t, resolved.Styles.Selection.Inverse)
169+ require.True(t, resolved.Styles.CopyStatus.Inverse)
170+ // Toast box: warn border is visible without color.
171+ require.True(t, resolved.Styles.BorderWarn.Bold)
172+ })
173+ }
174+}
175+
176+// TestReducedColorLeavesPaneContentAlone guards that panes are never recolored
177+// or faded: vev-vt quantizes them, vev chrome must not touch them.
178+func TestReducedColorLeavesPaneContentAlone(t *testing.T) {
179+ for _, mode := range []terminalcap.ColorMode{terminalcap.ANSI16, terminalcap.Monochrome} {
180+ color := terminalcap.ColorCapabilities{Mode: mode}
181+ resolved := themeui.ResolveForColor(reducedTestTheme(), domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, color)
182+ state := cachedSplitState("pane", "left", layout.Horizontal, resolved.Theme)
183+ state.styles = resolved.Styles
184+ pane := state.panes[1].frame // unfocused
185+ cell := pane.At(0, 0)
186+ cell.Style.Foreground = 196
187+ pane.Set(0, 0, cell)
188+ composed := composeFrame(state, composeCacheInput{})
189+ x := state.panes[1].placement.Content.X
190+ y := state.panes[1].placement.Content.Y + 1
191+ require.Equal(t, cell.Style.Canonical(), composed.frame.At(x, y).Style.Canonical())
192+ }
193+}
194+
195+// TestResolveForColorKeepsRGBModesIdentical proves TrueColor and ANSI256
196+// attachments resolve exactly as before this policy existed, so their output
197+// bytes cannot change.
198+func TestResolveForColorKeepsRGBModesIdentical(t *testing.T) {
199+ raw := reducedTestTheme()
200+ policy := domain.ThemeAccent{Mode: domain.ThemeAccentAuto}
201+ want := themeui.Resolve(raw, policy)
202+ for _, color := range []terminalcap.ColorCapabilities{{}, {Mode: terminalcap.TrueColor, Source: terminalcap.SourceForced}, {Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}} {
203+ require.Equal(t, want, themeui.ResolveForColor(raw, policy, color))
204+ }
205+ for _, color := range []terminalcap.ColorCapabilities{{Mode: terminalcap.TrueColor}, {Mode: terminalcap.ANSI256}} {
206+ composed, _ := reducedChromeFrame(t, color)
207+ state := cachedSplitState("reference", "left", layout.Horizontal, raw)
208+ _ = state
209+ out := drawProfile(t, composed.frame, color)
210+ require.Regexp(t, `(38|48);[25];`, string(out), "RGB-capable modes keep tinted surfaces")
211+ }
212+}
213+
214+// TestAppliedThemeIsPerAttachment confirms two attachments of one session
215+// resolve independently.
216+func TestAppliedThemeIsPerAttachment(t *testing.T) {
217+ p, release := newBlockingPTY(t)
218+ defer release()
219+ d, sess, ac16, _ := newManualSessionWithPTYs(t, p)
220+ ac16.terminalCapabilities = terminalcap.Capabilities{Color: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}}
221+ tr, _ := newCapturingTransport(t)
222+ acTrue := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
223+ acTrue.output.attachment = acTrue
224+ acTrue.initOverlays()
225+ sess.mu.Lock()
226+ sess.attachments[acTrue] = struct{}{}
227+ sess.mu.Unlock()
228+ acTrue.setSession(sess)
229+
230+ raw := reducedTestTheme()
231+ msg := protocol.Theme{Foreground: raw.Foreground, Background: raw.Background, HasForeground: true, HasBackground: true, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown}
232+ d.applyTheme(sess, ac16, msg)
233+ d.applyTheme(sess, acTrue, msg)
234+ d.reapplyThemeSession(sess) // a config reload must keep each mode
235+
236+ reduced, rgb := ac16.getAppliedTheme(), acTrue.getAppliedTheme()
237+ require.True(t, reduced.Raw.DimByAttribute)
238+ require.False(t, rgb.Raw.DimByAttribute)
239+ require.False(t, reduced.Resolved.Styles.SurfaceActive.HasBackgroundRGB)
240+ require.True(t, rgb.Resolved.Styles.SurfaceActive.HasBackgroundRGB)
241+ require.Equal(t, themeui.Resolve(rgb.Raw, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles, rgb.Resolved.Styles)
242+}
M internal/usecase/daemon/render.go +18 -0
@@ -6,6 +6,7 @@ import (
66
77 renderer "github.com/bnema/vev-vt/ansi"
88 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/terminalcap"
910 "github.com/bnema/vev/internal/ports"
1011 "github.com/bnema/vev/internal/usecase/layout"
1112 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -496,6 +497,23 @@ func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset
496497
497498 var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
498499
500+var (
501+ fallbackANSI16Styles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}).Styles
502+ fallbackMonoStyles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome}).Styles
503+)
504+
505+// fallbackStylesFor is the static pre-theme chrome for one attachment's mode.
506+func fallbackStylesFor(color terminalcap.ColorCapabilities) themeui.Styles {
507+ switch color.Mode {
508+ case terminalcap.ANSI16:
509+ return fallbackANSI16Styles
510+ case terminalcap.Monochrome:
511+ return fallbackMonoStyles
512+ default:
513+ return fallbackChromeStyles
514+ }
515+}
516+
499517 func resolveStyles(styles []themeui.Styles) themeui.Styles {
500518 if len(styles) > 0 {
501519 return styles[0]
@@ -6,6 +6,7 @@ import (
6 6
7 renderer "github.com/bnema/vev-vt/ansi"7 renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/domain/terminalcap"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
11 themeui "github.com/bnema/vev/internal/usecase/theme"12 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -496,6 +497,23 @@ func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset
496 497
497 var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles498 var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
498 499
500+var (
501+ fallbackANSI16Styles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}).Styles
502+ fallbackMonoStyles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome}).Styles
503+)
504+
505+// fallbackStylesFor is the static pre-theme chrome for one attachment's mode.
506+func fallbackStylesFor(color terminalcap.ColorCapabilities) themeui.Styles {
507+ switch color.Mode {
508+ case terminalcap.ANSI16:
509+ return fallbackANSI16Styles
510+ case terminalcap.Monochrome:
511+ return fallbackMonoStyles
512+ default:
513+ return fallbackChromeStyles
514+ }
515+}
516+
499 func resolveStyles(styles []themeui.Styles) themeui.Styles {517 func resolveStyles(styles []themeui.Styles) themeui.Styles {
500 if len(styles) > 0 {518 if len(styles) > 0 {
501 return styles[0]519 return styles[0]
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.go +3 -1
@@ -157,7 +157,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
157157 titles[pane.id] = pane.titleGeneration
158158 }
159159 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
160- blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, inactivePaneDimmer)
160+ // Reduced-color chrome never recolors or fades pane content; focus is
161+ // shown by the title bar and dividers.
162+ blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused && !state.theme.DimByAttribute, inactivePaneDimmer)
161163 }
162164 for _, d := range pane.damage {
163165 if d.Kind != renderer.DamageFullRedraw {
@@ -157,7 +157,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
157 titles[pane.id] = pane.titleGeneration157 titles[pane.id] = pane.titleGeneration
158 }158 }
159 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {159 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
160- blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, inactivePaneDimmer)160+ // Reduced-color chrome never recolors or fades pane content; focus is
161+ // shown by the title bar and dividers.
162+ blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused && !state.theme.DimByAttribute, inactivePaneDimmer)
161 }163 }
162 for _, d := range pane.damage {164 for _, d := range pane.damage {
163 if d.Kind != renderer.DamageFullRedraw {165 if d.Kind != renderer.DamageFullRedraw {
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.go +10 -0
@@ -564,7 +564,9 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do
564564 ac.sendMu.Unlock()
565565 return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
566566 }
567+ colorBefore := ac.terminalCapabilities.Color.Mode
567568 sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz)
569+ colorChanged := resumed != nil && resumed.terminalCapabilities.Color.Mode != colorBefore
568570 d.mu.Unlock()
569571 ac.sendMu.Unlock()
570572 if err == nil && ok && sess != nil && resumed != nil {
@@ -572,6 +574,14 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do
572574 // complete the claiming terminal's geometry before the resumed
573575 // attachment is allowed to produce its first frame.
574576 sess.geometry.reconcile(d, sess, resumed)
577+ if colorChanged {
578+ // The applied theme (chrome styles) was resolved for the previous
579+ // terminal's color mode; re-resolve it for the new one before the
580+ // first paint, and tell the user about a declared downgrade just as a
581+ // fresh attach would.
582+ d.publishColorDowngradeNotice(sess, resumed)
583+ d.applyHostTheme(sess, resumed, resumed.getClientTheme(), false)
584+ }
575585 // A resumed unsupported attachment bypasses finishAttachedClient. Check
576586 // the restored scene at this boundary so suppression is explained once.
577587 d.warnUnsupportedGraphics(resumed)
@@ -564,7 +564,9 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do
564 ac.sendMu.Unlock()564 ac.sendMu.Unlock()
565 return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}565 return nil, nil, false, &protoErr{protocol.ErrNoSuchSession, "resume token is no longer valid"}
566 }566 }
567+ colorBefore := ac.terminalCapabilities.Color.Mode
567 sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz)568 sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz)
569+ colorChanged := resumed != nil && resumed.terminalCapabilities.Color.Mode != colorBefore
568 d.mu.Unlock()570 d.mu.Unlock()
569 ac.sendMu.Unlock()571 ac.sendMu.Unlock()
570 if err == nil && ok && sess != nil && resumed != nil {572 if err == nil && ok && sess != nil && resumed != nil {
@@ -572,6 +574,14 @@ func (d *Daemon) resumeParked(h protocol.Hello, tr ports.ServerConnection, sz do
572 // complete the claiming terminal's geometry before the resumed574 // complete the claiming terminal's geometry before the resumed
573 // attachment is allowed to produce its first frame.575 // attachment is allowed to produce its first frame.
574 sess.geometry.reconcile(d, sess, resumed)576 sess.geometry.reconcile(d, sess, resumed)
577+ if colorChanged {
578+ // The applied theme (chrome styles) was resolved for the previous
579+ // terminal's color mode; re-resolve it for the new one before the
580+ // first paint, and tell the user about a declared downgrade just as a
581+ // fresh attach would.
582+ d.publishColorDowngradeNotice(sess, resumed)
583+ d.applyHostTheme(sess, resumed, resumed.getClientTheme(), false)
584+ }
575 // A resumed unsupported attachment bypasses finishAttachedClient. Check585 // A resumed unsupported attachment bypasses finishAttachedClient. Check
576 // the restored scene at this boundary so suppression is explained once.586 // the restored scene at this boundary so suppression is explained once.
577 d.warnUnsupportedGraphics(resumed)587 d.warnUnsupportedGraphics(resumed)
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.go +16 -12
@@ -27,15 +27,19 @@ func pulseVisible(frame int) bool {
2727 return frame%pulseFrameCount != 0
2828 }
2929
30-// pulseStyle returns the style for the attention bell glyph at frame, built
30+// pulseStyleFor returns the style for the attention bell glyph at frame, built
3131 // on top of base so the bell always keeps the caller's background (the tab's
3232 // accent/statusBar, or a faded MRU entry's blended colors) instead of
3333 // punching a default-background hole in a themed bar. On the invisible beat
34-// it returns (base, false) unchanged.
35-func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {
34+// it returns (base, false) unchanged. reduced selects the attribute-only pulse
35+// for chrome built by the reduced-color policy.
36+func pulseStyleFor(frame int, base renderer.Style, reduced bool) (renderer.Style, bool) {
3637 if !pulseVisible(frame) {
3738 return base, false
3839 }
40+ if reduced {
41+ return themeui.PulseAttribute(base), true
42+ }
3943 f := frame % pulseFrameCount
4044 peak := pulseFrameCount / 2
4145 distance := f - peak
@@ -63,7 +67,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
6367 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
6468 if w.attention {
6569 writeStatusText(row, &x, " ", baseStyle)
66- writeBell(row, &x, frame, baseStyle)
70+ writeBell(row, &x, frame, baseStyle, styles.Reduced)
6771 }
6872 writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
6973 writeStatusText(row, &x, " ", baseStyle)
@@ -82,13 +86,13 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl
8286 if sess.selected {
8387 style = styles.SurfaceActive
8488 }
85- drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame)
89+ drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
8690 }
8791 } else {
8892 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
8993 for i, sess := range fittedMRU {
9094 style := styles.MRUStyle(i, len(fittedMRU))
91- drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame)
95+ drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
9296 }
9397 }
9498 drawRightPlainText(row, rightText, x, styles.SurfaceBar)
@@ -103,23 +107,23 @@ type rankedRecent struct {
103107 selected bool
104108 }
105109
106-func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int) {
110+func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int, reduced bool) {
107111 prefixStyle := style
108112 prefixStyle.Bold = true
109113 writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle)
110114 writeStatusText(row, x, sess.name, style)
111115 if sess.attention {
112116 writeStatusText(row, x, " ", style)
113- writeBell(row, x, attentionFrame, style)
117+ writeBell(row, x, attentionFrame, style, reduced)
114118 }
115119 writeStatusText(row, x, " ", style)
116120 }
117121
118-func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int) {
122+func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int, reduced bool) {
119123 writeStatusText(row, x, " "+entry.name, style)
120124 if entry.attention {
121125 writeStatusText(row, x, " ", style)
122- writeBell(row, x, attentionFrame, style)
126+ writeBell(row, x, attentionFrame, style, reduced)
123127 }
124128 writeStatusText(row, x, " ", style)
125129 }
@@ -490,8 +494,8 @@ func mruFutureRightReserve(rowLen int) int {
490494 // writeBell draws the attention glyph on its visible beat, or a plain cell in
491495 // base's style on its blank beat, so the bell never leaves a bare
492496 // default-background hole in a themed bar.
493-func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) {
494- style, visible := pulseStyle(frame, base)
497+func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style, reduced bool) {
498+ style, visible := pulseStyleFor(frame, base, reduced)
495499 if !visible {
496500 writeStatusText(row, x, " ", base)
497501 return
@@ -27,15 +27,19 @@ func pulseVisible(frame int) bool {
27 return frame%pulseFrameCount != 027 return frame%pulseFrameCount != 0
28 }28 }
29 29
30-// pulseStyle returns the style for the attention bell glyph at frame, built30+// pulseStyleFor returns the style for the attention bell glyph at frame, built
31 // on top of base so the bell always keeps the caller's background (the tab's31 // on top of base so the bell always keeps the caller's background (the tab's
32 // accent/statusBar, or a faded MRU entry's blended colors) instead of32 // accent/statusBar, or a faded MRU entry's blended colors) instead of
33 // punching a default-background hole in a themed bar. On the invisible beat33 // punching a default-background hole in a themed bar. On the invisible beat
34-// it returns (base, false) unchanged.34+// it returns (base, false) unchanged. reduced selects the attribute-only pulse
35-func pulseStyle(frame int, base renderer.Style) (renderer.Style, bool) {35+// for chrome built by the reduced-color policy.
36+func pulseStyleFor(frame int, base renderer.Style, reduced bool) (renderer.Style, bool) {
36 if !pulseVisible(frame) {37 if !pulseVisible(frame) {
37 return base, false38 return base, false
38 }39 }
40+ if reduced {
41+ return themeui.PulseAttribute(base), true
42+ }
39 f := frame % pulseFrameCount43 f := frame % pulseFrameCount
40 peak := pulseFrameCount / 244 peak := pulseFrameCount / 2
41 distance := f - peak45 distance := f - peak
@@ -63,7 +67,7 @@ func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, t
63 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)67 writeStatusText(row, &x, " "+label.text[:label.nameLen], nameStyle)
64 if w.attention {68 if w.attention {
65 writeStatusText(row, &x, " ", baseStyle)69 writeStatusText(row, &x, " ", baseStyle)
66- writeBell(row, &x, frame, baseStyle)70+ writeBell(row, &x, frame, baseStyle, styles.Reduced)
67 }71 }
68 writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)72 writeStatusText(row, &x, label.text[label.nameLen:], titleStyle)
69 writeStatusText(row, &x, " ", baseStyle)73 writeStatusText(row, &x, " ", baseStyle)
@@ -82,13 +86,13 @@ func drawStatusBarState(row []renderer.Cell, state barState, styles themeui.Styl
82 if sess.selected {86 if sess.selected {
83 style = styles.SurfaceActive87 style = styles.SurfaceActive
84 }88 }
85- drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame)89+ drawRankedStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
86 }90 }
87 } else {91 } else {
88 fittedMRU := fitMRU(state.mru, len(row), x, rightText)92 fittedMRU := fitMRU(state.mru, len(row), x, rightText)
89 for i, sess := range fittedMRU {93 for i, sess := range fittedMRU {
90 style := styles.MRUStyle(i, len(fittedMRU))94 style := styles.MRUStyle(i, len(fittedMRU))
91- drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame)95+ drawStatusSessionEntry(row, &x, sess, style, state.attentionFrame, styles.Reduced)
92 }96 }
93 }97 }
94 drawRightPlainText(row, rightText, x, styles.SurfaceBar)98 drawRightPlainText(row, rightText, x, styles.SurfaceBar)
@@ -103,23 +107,23 @@ type rankedRecent struct {
103 selected bool107 selected bool
104 }108 }
105 109
106-func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int) {110+func drawRankedStatusSessionEntry(row []renderer.Cell, x *int, sess rankedRecent, style renderer.Style, attentionFrame int, reduced bool) {
107 prefixStyle := style111 prefixStyle := style
108 prefixStyle.Bold = true112 prefixStyle.Bold = true
109 writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle)113 writeStatusText(row, x, " "+strconv.Itoa(sess.rank)+":", prefixStyle)
110 writeStatusText(row, x, sess.name, style)114 writeStatusText(row, x, sess.name, style)
111 if sess.attention {115 if sess.attention {
112 writeStatusText(row, x, " ", style)116 writeStatusText(row, x, " ", style)
113- writeBell(row, x, attentionFrame, style)117+ writeBell(row, x, attentionFrame, style, reduced)
114 }118 }
115 writeStatusText(row, x, " ", style)119 writeStatusText(row, x, " ", style)
116 }120 }
117 121
118-func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int) {122+func drawStatusSessionEntry(row []renderer.Cell, x *int, entry recentRouteDisplay, style renderer.Style, attentionFrame int, reduced bool) {
119 writeStatusText(row, x, " "+entry.name, style)123 writeStatusText(row, x, " "+entry.name, style)
120 if entry.attention {124 if entry.attention {
121 writeStatusText(row, x, " ", style)125 writeStatusText(row, x, " ", style)
122- writeBell(row, x, attentionFrame, style)126+ writeBell(row, x, attentionFrame, style, reduced)
123 }127 }
124 writeStatusText(row, x, " ", style)128 writeStatusText(row, x, " ", style)
125 }129 }
@@ -490,8 +494,8 @@ func mruFutureRightReserve(rowLen int) int {
490 // writeBell draws the attention glyph on its visible beat, or a plain cell in494 // writeBell draws the attention glyph on its visible beat, or a plain cell in
491 // base's style on its blank beat, so the bell never leaves a bare495 // base's style on its blank beat, so the bell never leaves a bare
492 // default-background hole in a themed bar.496 // default-background hole in a themed bar.
493-func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style) {497+func writeBell(row []renderer.Cell, x *int, frame int, base renderer.Style, reduced bool) {
494- style, visible := pulseStyle(frame, base)498+ style, visible := pulseStyleFor(frame, base, reduced)
495 if !visible {499 if !visible {
496 writeStatusText(row, x, " ", base)500 writeStatusText(row, x, " ", base)
497 return501 return
M internal/usecase/daemon/status_test.go +12 -16
@@ -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})
@@ -888,32 +885,32 @@ func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) {
888885
889886 t.Run("invisible frame returns base unchanged", func(t *testing.T) {
890887 for _, base := range []renderer.Style{rgbBase, indexedBase} {
891- style, visible := pulseStyle(0, base)
888+ style, visible := pulseStyleFor(0, base, false)
892889 require.False(t, visible)
893890 require.True(t, style.Equal(base))
894891 }
895892 })
896893
897894 t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) {
898- low, visible := pulseStyle(1, rgbBase)
895+ low, visible := pulseStyleFor(1, rgbBase, false)
899896 require.True(t, visible)
900897 require.True(t, low.Bold)
901898 require.True(t, low.HasBackgroundRGB)
902899 require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar")
903900
904- peak, visible := pulseStyle(pulseFrameCount/2, rgbBase)
901+ peak, visible := pulseStyleFor(pulseFrameCount/2, rgbBase, false)
905902 require.True(t, visible)
906903 require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly")
907904 require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity")
908905 })
909906
910907 t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) {
911- low, visible := pulseStyle(1, indexedBase)
908+ low, visible := pulseStyleFor(1, indexedBase, false)
912909 require.True(t, visible)
913910 require.True(t, low.Bold)
914911 require.True(t, low.Inverse, "non-color base attributes like inverse must survive")
915912
916- peak, visible := pulseStyle(pulseFrameCount/2, indexedBase)
913+ peak, visible := pulseStyleFor(pulseFrameCount/2, indexedBase, false)
917914 require.True(t, visible)
918915 require.Greater(t, peak.Foreground, low.Foreground)
919916 })
@@ -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})
@@ -888,32 +885,32 @@ func TestPulseStyleFadesFromBaseAndHidesAtFrameZero(t *testing.T) {
888 885
889 t.Run("invisible frame returns base unchanged", func(t *testing.T) {886 t.Run("invisible frame returns base unchanged", func(t *testing.T) {
890 for _, base := range []renderer.Style{rgbBase, indexedBase} {887 for _, base := range []renderer.Style{rgbBase, indexedBase} {
891- style, visible := pulseStyle(0, base)888+ style, visible := pulseStyleFor(0, base, false)
892 require.False(t, visible)889 require.False(t, visible)
893 require.True(t, style.Equal(base))890 require.True(t, style.Equal(base))
894 }891 }
895 })892 })
896 893
897 t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) {894 t.Run("rgb base blends the glyph foreground from the base background to its foreground, keeping the base background", func(t *testing.T) {
898- low, visible := pulseStyle(1, rgbBase)895+ low, visible := pulseStyleFor(1, rgbBase, false)
899 require.True(t, visible)896 require.True(t, visible)
900 require.True(t, low.Bold)897 require.True(t, low.Bold)
901 require.True(t, low.HasBackgroundRGB)898 require.True(t, low.HasBackgroundRGB)
902 require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar")899 require.Equal(t, rgbBase.BackgroundRGB, low.BackgroundRGB, "bell keeps the caller's background so it never punches a hole in a themed bar")
903 900
904- peak, visible := pulseStyle(pulseFrameCount/2, rgbBase)901+ peak, visible := pulseStyleFor(pulseFrameCount/2, rgbBase, false)
905 require.True(t, visible)902 require.True(t, visible)
906 require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly")903 require.Equal(t, rgbBase.ForegroundRGB, peak.ForegroundRGB, "peak intensity reaches the base foreground exactly")
907 require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity")904 require.NotEqual(t, low.ForegroundRGB, peak.ForegroundRGB, "the glyph should ramp, not jump straight to full intensity")
908 })905 })
909 906
910 t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) {907 t.Run("non-RGB base falls back to the indexed grey ramp and preserves other base attributes", func(t *testing.T) {
911- low, visible := pulseStyle(1, indexedBase)908+ low, visible := pulseStyleFor(1, indexedBase, false)
912 require.True(t, visible)909 require.True(t, visible)
913 require.True(t, low.Bold)910 require.True(t, low.Bold)
914 require.True(t, low.Inverse, "non-color base attributes like inverse must survive")911 require.True(t, low.Inverse, "non-color base attributes like inverse must survive")
915 912
916- peak, visible := pulseStyle(pulseFrameCount/2, indexedBase)913+ peak, visible := pulseStyleFor(pulseFrameCount/2, indexedBase, false)
917 require.True(t, visible)914 require.True(t, visible)
918 require.Greater(t, peak.Foreground, low.Foreground)915 require.Greater(t, peak.Foreground, low.Foreground)
919 })916 })
@@ -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 +3 -4
@@ -7,6 +7,7 @@ import (
77
88 renderer "github.com/bnema/vev-vt/ansi"
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/domain/terminalcap"
1011 "github.com/bnema/vev/internal/protocol"
1112 themeui "github.com/bnema/vev/internal/usecase/theme"
1213 )
@@ -14,8 +15,7 @@ import (
1415 func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) protocol.Theme {
1516 out := protocol.Theme{
1617 Foreground: foreground, Background: background,
17- HasForeground: true, HasBackground: true, TrueColor: true,
18- SchemeKnown: true,
18+ HasForeground: true, HasBackground: true, SchemeKnown: true,
1919 }
2020 for slot, color := range palette {
2121 out.Palette[slot] = color
@@ -175,8 +175,7 @@ func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) {
175175 d.ApplyConfig(tt.config)
176176 got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{
177177 Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown,
178- HasFG: true, HasBG: true, TrueColor: true,
179- }).Resolved.Styles)
178+ HasFG: true, HasBG: true}, terminalcap.ColorCapabilities{}).Resolved.Styles)
180179 require.Equal(t, []byte(tt.want), got)
181180 })
182181 }
@@ -7,6 +7,7 @@ import (
7 7
8 renderer "github.com/bnema/vev-vt/ansi"8 renderer "github.com/bnema/vev-vt/ansi"
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 themeui "github.com/bnema/vev/internal/usecase/theme"12 themeui "github.com/bnema/vev/internal/usecase/theme"
12 )13 )
@@ -14,8 +15,7 @@ import (
14 func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) protocol.Theme {15 func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) protocol.Theme {
15 out := protocol.Theme{16 out := protocol.Theme{
16 Foreground: foreground, Background: background,17 Foreground: foreground, Background: background,
17- HasForeground: true, HasBackground: true, TrueColor: true,18+ HasForeground: true, HasBackground: true, SchemeKnown: true,
18- SchemeKnown: true,
19 }19 }
20 for slot, color := range palette {20 for slot, color := range palette {
21 out.Palette[slot] = color21 out.Palette[slot] = color
@@ -175,8 +175,7 @@ func TestAccentLifecycleNeutralChromeByteSnapshots(t *testing.T) {
175 d.ApplyConfig(tt.config)175 d.ApplyConfig(tt.config)
176 got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{176 got := chromeStyleSnapshot(t, d.resolveAppliedTheme(themeui.Theme{
177 Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown,177 Foreground: raw.Foreground, Background: raw.Background, Palette: raw.Palette, PaletteKnown: raw.PaletteKnown,
178- HasFG: true, HasBG: true, TrueColor: true,178+ HasFG: true, HasBG: true}, terminalcap.ColorCapabilities{}).Resolved.Styles)
179- }).Resolved.Styles)
180 require.Equal(t, []byte(tt.want), got)179 require.Equal(t, []byte(tt.want), got)
181 })180 })
182 }181 }
M internal/usecase/daemon/theme_resolve.go +9 -2
@@ -1,6 +1,9 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/usecase/theme"
3+import (
4+ "github.com/bnema/vev/internal/domain/terminalcap"
5+ "github.com/bnema/vev/internal/usecase/theme"
6+)
47
58 func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
69 if ac != nil {
@@ -16,7 +19,11 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
1619 // Resolve while applying, never while composing. The theme mutex only
1720 // publishes the completed value and is not held across session/tab/pane
1821 // locks, preserving the daemon lock order.
19- applied := d.resolveAppliedTheme(t)
22+ var color terminalcap.ColorCapabilities
23+ if ac != nil {
24+ color = ac.terminalCapabilities.Color
25+ }
26+ applied := d.resolveAppliedTheme(t, color)
2027 t = applied.Raw
2128 sess.mu.Lock()
2229 if ac != nil {
@@ -1,6 +1,9 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/internal/usecase/theme"3+import (
4+ "github.com/bnema/vev/internal/domain/terminalcap"
5+ "github.com/bnema/vev/internal/usecase/theme"
6+)
4 7
5 func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {8 func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
6 if ac != nil {9 if ac != nil {
@@ -16,7 +19,11 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
16 // Resolve while applying, never while composing. The theme mutex only19 // Resolve while applying, never while composing. The theme mutex only
17 // publishes the completed value and is not held across session/tab/pane20 // publishes the completed value and is not held across session/tab/pane
18 // locks, preserving the daemon lock order.21 // locks, preserving the daemon lock order.
19- applied := d.resolveAppliedTheme(t)22+ var color terminalcap.ColorCapabilities
23+ if ac != nil {
24+ color = ac.terminalCapabilities.Color
25+ }
26+ applied := d.resolveAppliedTheme(t, color)
20 t = applied.Raw27 t = applied.Raw
21 sess.mu.Lock()28 sess.mu.Lock()
22 if ac != nil {29 if ac != nil {
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/picker/model.go +21 -3
@@ -61,6 +61,21 @@ type RenderStyles struct {
6161 Status renderer.Style // one-row picker-local contextual help
6262 SearchMatch renderer.Style // matched runes on ordinary rows
6363 SelectionMatch renderer.Style // matched runes on the selected row
64+ // ProblemBold draws error and warning dots bold. A monochrome terminal
65+ // drops the fixed problem colors, so weight is what sets them apart.
66+ ProblemBold bool
67+}
68+
69+// DefaultRenderStyles returns the picker's built-in styles. monochrome selects
70+// the attribute-only variant: tree lines are dimmed instead of grey and
71+// problem dots are bold, since no color survives on such a terminal.
72+func DefaultRenderStyles(monochrome bool) RenderStyles {
73+ styles := defaultRenderStyles()
74+ if monochrome {
75+ styles.Tree = renderer.Style{} // zero Tree keeps the dimmed row style
76+ styles.ProblemBold = true
77+ }
78+ return styles
6479 }
6580
6681 func defaultRenderStyles() RenderStyles {
@@ -585,7 +600,7 @@ func lineStatusColor(status protocol.PickerLineStatus) int {
585600
586601 // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps
587602 // its own color even on the inverse selected row.
588-func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus) {
603+func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus, problemBold bool) {
589604 if badge == "" {
590605 return
591606 }
@@ -593,6 +608,9 @@ func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge
593608 style.Inverse = false
594609 style.Foreground = lineStatusColor(status)
595610 style.HasForegroundRGB = false
611+ if color := lineStatusColor(status); problemBold && (color == ColorProblemError || color == ColorProblemWarn) {
612+ style.Bold = true
613+ }
596614 ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style)
597615 }
598616
@@ -681,7 +699,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
681699 x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)
682700
683701 if r.section() {
684- drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status)
702+ drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold)
685703 continue
686704 }
687705 if r.rendersAsHeader() {
@@ -697,7 +715,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
697715 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)
698716 drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)
699717 }
700- drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status)
718+ drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold)
701719 continue
702720 }
703721
@@ -61,6 +61,21 @@ type RenderStyles struct {
61 Status renderer.Style // one-row picker-local contextual help61 Status renderer.Style // one-row picker-local contextual help
62 SearchMatch renderer.Style // matched runes on ordinary rows62 SearchMatch renderer.Style // matched runes on ordinary rows
63 SelectionMatch renderer.Style // matched runes on the selected row63 SelectionMatch renderer.Style // matched runes on the selected row
64+ // ProblemBold draws error and warning dots bold. A monochrome terminal
65+ // drops the fixed problem colors, so weight is what sets them apart.
66+ ProblemBold bool
67+}
68+
69+// DefaultRenderStyles returns the picker's built-in styles. monochrome selects
70+// the attribute-only variant: tree lines are dimmed instead of grey and
71+// problem dots are bold, since no color survives on such a terminal.
72+func DefaultRenderStyles(monochrome bool) RenderStyles {
73+ styles := defaultRenderStyles()
74+ if monochrome {
75+ styles.Tree = renderer.Style{} // zero Tree keeps the dimmed row style
76+ styles.ProblemBold = true
77+ }
78+ return styles
64 }79 }
65 80
66 func defaultRenderStyles() RenderStyles {81 func defaultRenderStyles() RenderStyles {
@@ -585,7 +600,7 @@ func lineStatusColor(status protocol.PickerLineStatus) int {
585 600
586 // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps601 // drawStatusBadge draws a problem dot right-aligned on row y. The dot keeps
587 // its own color even on the inverse selected row.602 // its own color even on the inverse selected row.
588-func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus) {603+func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge string, base renderer.Style, status protocol.PickerLineStatus, problemBold bool) {
589 if badge == "" {604 if badge == "" {
590 return605 return
591 }606 }
@@ -593,6 +608,9 @@ func drawStatusBadge(frame renderer.Frame, rect domain.Rect, y, clipX int, badge
593 style.Inverse = false608 style.Inverse = false
594 style.Foreground = lineStatusColor(status)609 style.Foreground = lineStatusColor(status)
595 style.HasForegroundRGB = false610 style.HasForegroundRGB = false
611+ if color := lineStatusColor(status); problemBold && (color == ColorProblemError || color == ColorProblemWarn) {
612+ style.Bold = true
613+ }
596 ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style)614 ui.DrawText(frame, max(rect.X, clipX-textCellWidth(badge)), rect.Y+y, clipX, badge, style)
597 }615 }
598 616
@@ -681,7 +699,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
681 x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)699 x := drawMatchedText(frame, treeX, rect.Y+y, contentClipX, name, nameStyle, nameMatchStyle, namePositions)
682 700
683 if r.section() {701 if r.section() {
684- drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status)702+ drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold)
685 continue703 continue
686 }704 }
687 if r.rendersAsHeader() {705 if r.rendersAsHeader() {
@@ -697,7 +715,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, styles Render
697 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)715 detailPositions := visibleMatchPositions(m.matchPositions(idx, matchDetail), detail, detail != r.line.Detail)
698 drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)716 drawMatchedText(frame, detailX, rect.Y+y, contentClipX, detail, detailStyle, nameMatchStyle, detailPositions)
699 }717 }
700- drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status)718+ drawStatusBadge(frame, rect, y, clipX, badge, base, r.line.Status, styles.ProblemBold)
701 continue719 continue
702 }720 }
703 721
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,
A internal/usecase/theme/color_policy.go +128 -0
@@ -0,0 +1,128 @@
1+package theme
2+
3+import (
4+ renderer "github.com/bnema/vev-vt"
5+ "github.com/bnema/vev/internal/domain"
6+ "github.com/bnema/vev/internal/domain/terminalcap"
7+)
8+
9+// reducedColor reports whether chrome for c must be built from attributes and
10+// terminal palette slots instead of RGB surfaces: 16-color and monochrome
11+// attachments.
12+func reducedColor(c terminalcap.ColorCapabilities) bool {
13+ return c.Mode == terminalcap.ANSI16 || c.Mode == terminalcap.Monochrome
14+}
15+
16+// ResolveForColor is Resolve for one attachment's color capability. TrueColor
17+// and ANSI256 return exactly what Resolve returns. ANSI16 and Monochrome
18+// replace the chrome styles with the reduced-color policy (see reducedStyles)
19+// and mark the returned Theme so dimming uses the faint attribute instead of
20+// RGB blends. Color is per attachment, so callers resolve once per attachment.
21+func ResolveForColor(t Theme, policy domain.ThemeAccent, color terminalcap.ColorCapabilities) ResolvedTheme {
22+ if !reducedColor(color) {
23+ return Resolve(t, policy)
24+ }
25+ t.DimByAttribute = true
26+ resolved := Resolve(t, policy) // resolved.Theme carries DimByAttribute
27+ accent := resolved.Accent
28+ slot := -1
29+ if (accent.Known || accent.IndexedOnly) && accent.Slot < 16 {
30+ slot = int(accent.Slot)
31+ }
32+ resolved.Styles = reducedStyles(slot, color.Mode == terminalcap.Monochrome)
33+ return resolved
34+}
35+
36+// reducedStyles builds the chrome for terminals that cannot show tinted RGB
37+// surfaces. Surfaces keep the terminal's default background; hierarchy comes
38+// from attributes, and the accent (when known) is only ever a palette slot
39+// 0-15 so the terminal's own palette renders it:
40+//
41+// - active tab, session name, selected rows, hint keys, search selection:
42+// bold reverse, so contrast is always the terminal's own fg/bg pair;
43+// - copy status, copy selection and the focused pane title: plain reverse
44+// (a selection keeps the text's own colors);
45+// - inactive tabs and bars: plain; secondary text, muted borders and
46+// unfocused titles: faint;
47+// - focused borders: bold, in the accent palette slot when there is one;
48+// warn borders: bold yellow (slot 3); errors share the focused border.
49+//
50+// Monochrome uses the same attributes with no color at all. The accent never
51+// fills a background: a palette slot is not guaranteed to contrast with the
52+// terminal background that reverse video would put on top of it.
53+func reducedStyles(accentSlot int, mono bool) Styles {
54+ plain := renderer.DefaultStyle()
55+ withFG := func(s renderer.Style, slot int) renderer.Style {
56+ if !mono && slot >= 0 {
57+ s.Foreground = slot
58+ }
59+ return s
60+ }
61+ with := func(base renderer.Style, bold, italic, inverse, faint bool) renderer.Style {
62+ base.Bold, base.Italic, base.Inverse = bold, italic, inverse
63+ if faint {
64+ base.Attrs |= renderer.AttrDim
65+ }
66+ return base
67+ }
68+ active := with(plain, true, false, true, false)
69+ activeTitle := with(plain, false, false, true, false)
70+ selectionMuted := with(plain, false, true, true, false)
71+ faint := with(plain, false, false, false, true)
72+ description := with(plain, false, true, false, true)
73+ focusedBorder := with(withFG(plain, accentSlot), true, false, false, false)
74+ warn := with(plain, true, true, false, false)
75+ if !mono {
76+ warn = with(withFG(plain, 3), true, false, false, false)
77+ }
78+ copySelection := with(plain, false, false, true, false)
79+
80+ s := Styles{
81+ Reduced: true,
82+
83+ SurfaceBar: plain,
84+ SurfaceInactive: plain,
85+ SurfaceRecent: plain,
86+ SurfaceActive: active,
87+ BorderMuted: faint,
88+ BorderActive: focusedBorder,
89+ BorderWarn: warn,
90+ NeutralBorder: faint,
91+
92+ TabInactive: plain,
93+ TabInactiveTitle: faint,
94+ TabActive: active,
95+ TabActiveTitle: activeTitle,
96+ MRURecent: plain,
97+
98+ PickerBase: plain,
99+ PickerSelection: active,
100+ HintKey: active,
101+ PickerDescription: description,
102+ PickerSeparator: faint,
103+ PromptBase: plain,
104+ CopyStatus: with(plain, false, false, true, false),
105+ SearchSelection: active,
106+ }
107+ // Aliases consumed by render paths that have not migrated yet. Selection
108+ // is the copy-mode selection role and StatusBar the focused pane title
109+ // bar; both are plain reverse.
110+ s.StatusBar = copySelection
111+ s.Accent = s.SurfaceActive
112+ s.Border = s.BorderMuted
113+ s.Selection = copySelection
114+ s.PaletteDesc = description
115+ s.TabName = s.TabInactive
116+ s.TabNameActive = s.TabActive
117+ s.TabTitle = s.TabInactiveTitle
118+ s.TabTitleActive = s.TabActiveTitle
119+ s.PickerName = plain
120+ s.PickerSelectionName = active
121+ s.PickerSelectionMuted = selectionMuted
122+ for count := range s.mruStyles {
123+ for index := 0; index <= count; index++ {
124+ s.mruStyles[count][index] = plain
125+ }
126+ }
127+ return s
128+}
@@ -0,0 +1,128 @@
1+package theme
2+
3+import (
4+ renderer "github.com/bnema/vev-vt"
5+ "github.com/bnema/vev/internal/domain"
6+ "github.com/bnema/vev/internal/domain/terminalcap"
7+)
8+
9+// reducedColor reports whether chrome for c must be built from attributes and
10+// terminal palette slots instead of RGB surfaces: 16-color and monochrome
11+// attachments.
12+func reducedColor(c terminalcap.ColorCapabilities) bool {
13+ return c.Mode == terminalcap.ANSI16 || c.Mode == terminalcap.Monochrome
14+}
15+
16+// ResolveForColor is Resolve for one attachment's color capability. TrueColor
17+// and ANSI256 return exactly what Resolve returns. ANSI16 and Monochrome
18+// replace the chrome styles with the reduced-color policy (see reducedStyles)
19+// and mark the returned Theme so dimming uses the faint attribute instead of
20+// RGB blends. Color is per attachment, so callers resolve once per attachment.
21+func ResolveForColor(t Theme, policy domain.ThemeAccent, color terminalcap.ColorCapabilities) ResolvedTheme {
22+ if !reducedColor(color) {
23+ return Resolve(t, policy)
24+ }
25+ t.DimByAttribute = true
26+ resolved := Resolve(t, policy) // resolved.Theme carries DimByAttribute
27+ accent := resolved.Accent
28+ slot := -1
29+ if (accent.Known || accent.IndexedOnly) && accent.Slot < 16 {
30+ slot = int(accent.Slot)
31+ }
32+ resolved.Styles = reducedStyles(slot, color.Mode == terminalcap.Monochrome)
33+ return resolved
34+}
35+
36+// reducedStyles builds the chrome for terminals that cannot show tinted RGB
37+// surfaces. Surfaces keep the terminal's default background; hierarchy comes
38+// from attributes, and the accent (when known) is only ever a palette slot
39+// 0-15 so the terminal's own palette renders it:
40+//
41+// - active tab, session name, selected rows, hint keys, search selection:
42+// bold reverse, so contrast is always the terminal's own fg/bg pair;
43+// - copy status, copy selection and the focused pane title: plain reverse
44+// (a selection keeps the text's own colors);
45+// - inactive tabs and bars: plain; secondary text, muted borders and
46+// unfocused titles: faint;
47+// - focused borders: bold, in the accent palette slot when there is one;
48+// warn borders: bold yellow (slot 3); errors share the focused border.
49+//
50+// Monochrome uses the same attributes with no color at all. The accent never
51+// fills a background: a palette slot is not guaranteed to contrast with the
52+// terminal background that reverse video would put on top of it.
53+func reducedStyles(accentSlot int, mono bool) Styles {
54+ plain := renderer.DefaultStyle()
55+ withFG := func(s renderer.Style, slot int) renderer.Style {
56+ if !mono && slot >= 0 {
57+ s.Foreground = slot
58+ }
59+ return s
60+ }
61+ with := func(base renderer.Style, bold, italic, inverse, faint bool) renderer.Style {
62+ base.Bold, base.Italic, base.Inverse = bold, italic, inverse
63+ if faint {
64+ base.Attrs |= renderer.AttrDim
65+ }
66+ return base
67+ }
68+ active := with(plain, true, false, true, false)
69+ activeTitle := with(plain, false, false, true, false)
70+ selectionMuted := with(plain, false, true, true, false)
71+ faint := with(plain, false, false, false, true)
72+ description := with(plain, false, true, false, true)
73+ focusedBorder := with(withFG(plain, accentSlot), true, false, false, false)
74+ warn := with(plain, true, true, false, false)
75+ if !mono {
76+ warn = with(withFG(plain, 3), true, false, false, false)
77+ }
78+ copySelection := with(plain, false, false, true, false)
79+
80+ s := Styles{
81+ Reduced: true,
82+
83+ SurfaceBar: plain,
84+ SurfaceInactive: plain,
85+ SurfaceRecent: plain,
86+ SurfaceActive: active,
87+ BorderMuted: faint,
88+ BorderActive: focusedBorder,
89+ BorderWarn: warn,
90+ NeutralBorder: faint,
91+
92+ TabInactive: plain,
93+ TabInactiveTitle: faint,
94+ TabActive: active,
95+ TabActiveTitle: activeTitle,
96+ MRURecent: plain,
97+
98+ PickerBase: plain,
99+ PickerSelection: active,
100+ HintKey: active,
101+ PickerDescription: description,
102+ PickerSeparator: faint,
103+ PromptBase: plain,
104+ CopyStatus: with(plain, false, false, true, false),
105+ SearchSelection: active,
106+ }
107+ // Aliases consumed by render paths that have not migrated yet. Selection
108+ // is the copy-mode selection role and StatusBar the focused pane title
109+ // bar; both are plain reverse.
110+ s.StatusBar = copySelection
111+ s.Accent = s.SurfaceActive
112+ s.Border = s.BorderMuted
113+ s.Selection = copySelection
114+ s.PaletteDesc = description
115+ s.TabName = s.TabInactive
116+ s.TabNameActive = s.TabActive
117+ s.TabTitle = s.TabInactiveTitle
118+ s.TabTitleActive = s.TabActiveTitle
119+ s.PickerName = plain
120+ s.PickerSelectionName = active
121+ s.PickerSelectionMuted = selectionMuted
122+ for count := range s.mruStyles {
123+ for index := 0; index <= count; index++ {
124+ s.mruStyles[count][index] = plain
125+ }
126+ }
127+ return s
128+}
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/styles.go +12 -0
@@ -47,6 +47,14 @@ func PulseColor(base renderer.Style, intensity float64) renderer.Style {
4747 return style
4848 }
4949
50+// PulseAttribute is PulseColor for reduced-color attachments: the glyph's
51+// on/off beat already pulses, so the visible beat is just bold. Grey indexes
52+// would quantize to arbitrary slots or vanish.
53+func PulseAttribute(base renderer.Style) renderer.Style {
54+ base.Bold = true
55+ return base
56+}
57+
5058 // MRUFade fades an MRU style by position, reaching 60 percent of the terminal
5159 // background color at the oldest entry.
5260 func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
@@ -62,6 +70,10 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
6270 // Styles is the complete semantic set of terminal chrome styles. The legacy
6371 // aliases remain until daemon renderers consume the semantic fields directly.
6472 type Styles struct {
73+ // Reduced marks the ANSI16/monochrome policy: no RGB anywhere, and the
74+ // attention pulse uses bold instead of a grey ramp (see ResolveForColor).
75+ Reduced bool
76+
6577 // mruStyles are all position/count combinations that status composition
6678 // can display. They keep OKLab interpolation out of render paths.
6779 mruStyles [9][9]renderer.Style
@@ -47,6 +47,14 @@ func PulseColor(base renderer.Style, intensity float64) renderer.Style {
47 return style47 return style
48 }48 }
49 49
50+// PulseAttribute is PulseColor for reduced-color attachments: the glyph's
51+// on/off beat already pulses, so the visible beat is just bold. Grey indexes
52+// would quantize to arbitrary slots or vanish.
53+func PulseAttribute(base renderer.Style) renderer.Style {
54+ base.Bold = true
55+ return base
56+}
57+
50 // MRUFade fades an MRU style by position, reaching 60 percent of the terminal58 // MRUFade fades an MRU style by position, reaching 60 percent of the terminal
51 // background color at the oldest entry.59 // background color at the oldest entry.
52 func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {60 func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
@@ -62,6 +70,10 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style {
62 // Styles is the complete semantic set of terminal chrome styles. The legacy70 // Styles is the complete semantic set of terminal chrome styles. The legacy
63 // aliases remain until daemon renderers consume the semantic fields directly.71 // aliases remain until daemon renderers consume the semantic fields directly.
64 type Styles struct {72 type Styles struct {
73+ // Reduced marks the ANSI16/monochrome policy: no RGB anywhere, and the
74+ // attention pulse uses bold instead of a grey ramp (see ResolveForColor).
75+ Reduced bool
76+
65 // mruStyles are all position/count combinations that status composition77 // mruStyles are all position/count combinations that status composition
66 // can display. They keep OKLab interpolation out of render paths.78 // can display. They keep OKLab interpolation out of render paths.
67 mruStyles [9][9]renderer.Style79 mruStyles [9][9]renderer.Style
M internal/usecase/theme/theme.go +17 -9
@@ -19,13 +19,14 @@ 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
26+ // DimByAttribute makes Dimmer use the faint attribute instead of blending
27+ // colors. The daemon sets it per attachment for ANSI16 and monochrome
28+ // output, where RGB blends would quantize badly or vanish.
29+ DimByAttribute bool
2930 }
3031
3132 // PaletteColor returns a palette color only when palette inheritance is
@@ -43,7 +44,6 @@ var (
4344 Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18},
4445 HasFG: true,
4546 HasBG: true,
46- TrueColor: true,
4747 Known: true,
4848 SchemeKnown: true,
4949 Light: false,
@@ -53,7 +53,6 @@ var (
5353 Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8},
5454 HasFG: true,
5555 HasBG: true,
56- TrueColor: true,
5756 Known: true,
5857 SchemeKnown: true,
5958 Light: true,
@@ -448,6 +447,11 @@ func WithForegroundDimming(percent int) DimmerOption {
448447 }
449448
450449 // NewDimmer returns a reusable dimmer with optional per-channel overrides.
450+//
451+// Keep this function inlinable: it sits on the per-cell render path, and adding
452+// fields or work pushes it over the inline budget (80). After changing it,
453+// check `go build -gcflags=-m=2 ./internal/usecase/theme 2>&1 | grep NewDimmer`
454+// and rerun BenchmarkComposeCapturedFrame in internal/usecase/daemon.
451455 func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {
452456 d := Dimmer{
453457 theme: t,
@@ -470,6 +474,10 @@ func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {
470474 // Dim fades a style's resolved background toward the terminal background,
471475 // then fades its foreground and custom underline toward that dimmed background.
472476 func (d Dimmer) Dim(style renderer.Style) renderer.Style {
477+ if d.theme.DimByAttribute {
478+ style.Attrs |= renderer.AttrDim
479+ return style
480+ }
473481 if !usable(d.theme) {
474482 return style
475483 }
@@ -19,13 +19,14 @@ 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 bool26+ // DimByAttribute makes Dimmer use the faint attribute instead of blending
27- Light bool27+ // colors. The daemon sets it per attachment for ANSI16 and monochrome
28- UsePalette bool28+ // output, where RGB blends would quantize badly or vanish.
29+ DimByAttribute bool
29 }30 }
30 31
31 // PaletteColor returns a palette color only when palette inheritance is32 // PaletteColor returns a palette color only when palette inheritance is
@@ -43,7 +44,6 @@ var (
43 Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18},44 Background: renderer.RGB{R: 0x18, G: 0x18, B: 0x18},
44 HasFG: true,45 HasFG: true,
45 HasBG: true,46 HasBG: true,
46- TrueColor: true,
47 Known: true,47 Known: true,
48 SchemeKnown: true,48 SchemeKnown: true,
49 Light: false,49 Light: false,
@@ -53,7 +53,6 @@ var (
53 Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8},53 Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8},
54 HasFG: true,54 HasFG: true,
55 HasBG: true,55 HasBG: true,
56- TrueColor: true,
57 Known: true,56 Known: true,
58 SchemeKnown: true,57 SchemeKnown: true,
59 Light: true,58 Light: true,
@@ -448,6 +447,11 @@ func WithForegroundDimming(percent int) DimmerOption {
448 }447 }
449 448
450 // NewDimmer returns a reusable dimmer with optional per-channel overrides.449 // NewDimmer returns a reusable dimmer with optional per-channel overrides.
450+//
451+// Keep this function inlinable: it sits on the per-cell render path, and adding
452+// fields or work pushes it over the inline budget (80). After changing it,
453+// check `go build -gcflags=-m=2 ./internal/usecase/theme 2>&1 | grep NewDimmer`
454+// and rerun BenchmarkComposeCapturedFrame in internal/usecase/daemon.
451 func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {455 func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {
452 d := Dimmer{456 d := Dimmer{
453 theme: t,457 theme: t,
@@ -470,6 +474,10 @@ func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {
470 // Dim fades a style's resolved background toward the terminal background,474 // Dim fades a style's resolved background toward the terminal background,
471 // then fades its foreground and custom underline toward that dimmed background.475 // then fades its foreground and custom underline toward that dimmed background.
472 func (d Dimmer) Dim(style renderer.Style) renderer.Style {476 func (d Dimmer) Dim(style renderer.Style) renderer.Style {
477+ if d.theme.DimByAttribute {
478+ style.Attrs |= renderer.AttrDim
479+ return style
480+ }
473 if !usable(d.theme) {481 if !usable(d.theme) {
474 return style482 return style
475 }483 }
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