detached 7855202

feat(ports): carry terminal palette in Theme wire message

Bump the IPC protocol to version 16 for the 57-byte palette payload.
commit
78552020e522b953176088c85bd47318bf5e2f5f
author
brice · · 2026-07-17 08:09 UTC
parent
c0c90a2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +85 -74
M internal/ports/frame.go +1 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 15
4+const ProtocolVersion uint16 = 16
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 154+const ProtocolVersion uint16 = 16
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 15 {
7- t.Fatalf("ProtocolVersion = %d, want 15", ProtocolVersion)
6+ if ProtocolVersion != 16 {
7+ t.Fatalf("ProtocolVersion = %d, want 16", ProtocolVersion)
88 }
99 }
1010
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 15 {6+ if ProtocolVersion != 16 {
7- t.Fatalf("ProtocolVersion = %d, want 15", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 16", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/wire.go +42 -11
@@ -85,8 +85,8 @@ type Resize struct {
8585 Size domain.Size
8686 }
8787
88-// Theme reports the client's terminal foreground/background colors and
89-// whether the client terminal supports truecolor.
88+// Theme reports the client's terminal foreground/background colors, ANSI
89+// palette entries, and whether the client terminal supports truecolor.
9090 type Theme struct {
9191 HasForeground bool
9292 Foreground renderer.RGB
@@ -95,6 +95,8 @@ type Theme struct {
9595 TrueColor bool
9696 SchemeKnown bool
9797 Light bool
98+ PaletteKnown uint16
99+ Palette [16]renderer.RGB
98100 }
99101
100102 // ImagePush carries a clipboard image from a remote client, to be written to
@@ -464,7 +466,7 @@ func MarshalResize(m Resize) []byte {
464466 return w.b
465467 }
466468
467-// MarshalTheme encodes m into a fixed-width Theme message payload.
469+// MarshalTheme encodes m into a 57-byte fixed-width Theme message payload.
468470 func MarshalTheme(m Theme) []byte {
469471 var flags uint8
470472 if m.HasForeground {
@@ -482,14 +484,25 @@ func MarshalTheme(m Theme) []byte {
482484 if m.Light {
483485 flags |= 0x10
484486 }
485- return []byte{
486- flags,
487- m.Foreground.R, m.Foreground.G, m.Foreground.B,
488- m.Background.R, m.Background.G, m.Background.B,
487+
488+ w := payloadWriter{b: make([]byte, 0, 57)}
489+ w.putUint8(flags)
490+ w.putUint8(m.Foreground.R)
491+ w.putUint8(m.Foreground.G)
492+ w.putUint8(m.Foreground.B)
493+ w.putUint8(m.Background.R)
494+ w.putUint8(m.Background.G)
495+ w.putUint8(m.Background.B)
496+ w.putUint16(m.PaletteKnown)
497+ for _, color := range m.Palette {
498+ w.putUint8(color.R)
499+ w.putUint8(color.G)
500+ w.putUint8(color.B)
489501 }
502+ return w.b
490503 }
491504
492-// UnmarshalTheme decodes a fixed-width Theme message payload.
505+// UnmarshalTheme decodes a 57-byte fixed-width Theme message payload.
493506 func UnmarshalTheme(b []byte) (Theme, error) {
494507 r := payloadReader{b: b}
495508 flags, err := r.getUint8()
@@ -520,10 +533,12 @@ func UnmarshalTheme(b []byte) (Theme, error) {
520533 if err != nil {
521534 return Theme{}, err
522535 }
523- if err := r.done(); err != nil {
536+ paletteKnown, err := r.getUint16()
537+ if err != nil {
524538 return Theme{}, err
525539 }
526- return Theme{
540+
541+ m := Theme{
527542 HasForeground: flags&0x01 != 0,
528543 Foreground: renderer.RGB{R: fgR, G: fgG, B: fgB},
529544 HasBackground: flags&0x02 != 0,
@@ -531,7 +546,23 @@ func UnmarshalTheme(b []byte) (Theme, error) {
531546 TrueColor: flags&0x04 != 0,
532547 SchemeKnown: flags&0x08 != 0,
533548 Light: flags&0x10 != 0,
534- }, nil
549+ PaletteKnown: paletteKnown,
550+ }
551+ for i := range m.Palette {
552+ if m.Palette[i].R, err = r.getUint8(); err != nil {
553+ return Theme{}, err
554+ }
555+ if m.Palette[i].G, err = r.getUint8(); err != nil {
556+ return Theme{}, err
557+ }
558+ if m.Palette[i].B, err = r.getUint8(); err != nil {
559+ return Theme{}, err
560+ }
561+ }
562+ if err := r.done(); err != nil {
563+ return Theme{}, err
564+ }
565+ return m, nil
535566 }
536567
537568 // UnmarshalResize decodes a Resize message payload.
@@ -85,8 +85,8 @@ type Resize struct {
85 Size domain.Size85 Size domain.Size
86 }86 }
87 87
88-// Theme reports the client's terminal foreground/background colors and88+// Theme reports the client's terminal foreground/background colors, ANSI
89-// whether the client terminal supports truecolor.89+// palette entries, and whether the client terminal supports truecolor.
90 type Theme struct {90 type Theme struct {
91 HasForeground bool91 HasForeground bool
92 Foreground renderer.RGB92 Foreground renderer.RGB
@@ -95,6 +95,8 @@ type Theme struct {
95 TrueColor bool95 TrueColor bool
96 SchemeKnown bool96 SchemeKnown bool
97 Light bool97 Light bool
98+ PaletteKnown uint16
99+ Palette [16]renderer.RGB
98 }100 }
99 101
100 // ImagePush carries a clipboard image from a remote client, to be written to102 // ImagePush carries a clipboard image from a remote client, to be written to
@@ -464,7 +466,7 @@ func MarshalResize(m Resize) []byte {
464 return w.b466 return w.b
465 }467 }
466 468
467-// MarshalTheme encodes m into a fixed-width Theme message payload.469+// MarshalTheme encodes m into a 57-byte fixed-width Theme message payload.
468 func MarshalTheme(m Theme) []byte {470 func MarshalTheme(m Theme) []byte {
469 var flags uint8471 var flags uint8
470 if m.HasForeground {472 if m.HasForeground {
@@ -482,14 +484,25 @@ func MarshalTheme(m Theme) []byte {
482 if m.Light {484 if m.Light {
483 flags |= 0x10485 flags |= 0x10
484 }486 }
485- return []byte{487+
486- flags,488+ w := payloadWriter{b: make([]byte, 0, 57)}
487- m.Foreground.R, m.Foreground.G, m.Foreground.B,489+ w.putUint8(flags)
488- m.Background.R, m.Background.G, m.Background.B,490+ w.putUint8(m.Foreground.R)
491+ w.putUint8(m.Foreground.G)
492+ w.putUint8(m.Foreground.B)
493+ w.putUint8(m.Background.R)
494+ w.putUint8(m.Background.G)
495+ w.putUint8(m.Background.B)
496+ w.putUint16(m.PaletteKnown)
497+ for _, color := range m.Palette {
498+ w.putUint8(color.R)
499+ w.putUint8(color.G)
500+ w.putUint8(color.B)
489 }501 }
502+ return w.b
490 }503 }
491 504
492-// UnmarshalTheme decodes a fixed-width Theme message payload.505+// UnmarshalTheme decodes a 57-byte fixed-width Theme message payload.
493 func UnmarshalTheme(b []byte) (Theme, error) {506 func UnmarshalTheme(b []byte) (Theme, error) {
494 r := payloadReader{b: b}507 r := payloadReader{b: b}
495 flags, err := r.getUint8()508 flags, err := r.getUint8()
@@ -520,10 +533,12 @@ func UnmarshalTheme(b []byte) (Theme, error) {
520 if err != nil {533 if err != nil {
521 return Theme{}, err534 return Theme{}, err
522 }535 }
523- if err := r.done(); err != nil {536+ paletteKnown, err := r.getUint16()
537+ if err != nil {
524 return Theme{}, err538 return Theme{}, err
525 }539 }
526- return Theme{540+
541+ m := Theme{
527 HasForeground: flags&0x01 != 0,542 HasForeground: flags&0x01 != 0,
528 Foreground: renderer.RGB{R: fgR, G: fgG, B: fgB},543 Foreground: renderer.RGB{R: fgR, G: fgG, B: fgB},
529 HasBackground: flags&0x02 != 0,544 HasBackground: flags&0x02 != 0,
@@ -531,7 +546,23 @@ func UnmarshalTheme(b []byte) (Theme, error) {
531 TrueColor: flags&0x04 != 0,546 TrueColor: flags&0x04 != 0,
532 SchemeKnown: flags&0x08 != 0,547 SchemeKnown: flags&0x08 != 0,
533 Light: flags&0x10 != 0,548 Light: flags&0x10 != 0,
534- }, nil549+ PaletteKnown: paletteKnown,
550+ }
551+ for i := range m.Palette {
552+ if m.Palette[i].R, err = r.getUint8(); err != nil {
553+ return Theme{}, err
554+ }
555+ if m.Palette[i].G, err = r.getUint8(); err != nil {
556+ return Theme{}, err
557+ }
558+ if m.Palette[i].B, err = r.getUint8(); err != nil {
559+ return Theme{}, err
560+ }
561+ }
562+ if err := r.done(); err != nil {
563+ return Theme{}, err
564+ }
565+ return m, nil
535 }566 }
536 567
537 // UnmarshalResize decodes a Resize message payload.568 // UnmarshalResize decodes a Resize message payload.
M internal/ports/wire_test.go +40 -60
@@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
121121 Env: []string{"A=B", "XY=123"},
122122 })
123123 want := []byte{
124- 0x00, 0x0f, 0x00, // version, intent
124+ 0x00, 0x10, 0x00, // version, intent
125125 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
126126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
127127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -244,65 +244,45 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) {
244244 }
245245
246246 func TestThemeGoldenAndRoundTrip(t *testing.T) {
247- tests := []struct {
248- name string
249- msg Theme
250- want []byte
251- }{
252- {
253- name: "foreground background truecolor dark scheme",
254- msg: Theme{
255- HasForeground: true,
256- Foreground: renderer.RGB{R: 1, G: 2, B: 3},
257- HasBackground: true,
258- Background: renderer.RGB{R: 4, G: 5, B: 6},
259- TrueColor: true,
260- SchemeKnown: true,
261- },
262- want: []byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06},
263- },
264- {
265- name: "light scheme bit without known is preserved",
266- msg: Theme{
267- Light: true,
268- },
269- want: []byte{0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
270- },
271- {
272- name: "foreground only no truecolor",
273- msg: Theme{
274- HasForeground: true,
275- Foreground: renderer.RGB{R: 10, G: 20, B: 30},
276- Background: renderer.RGB{R: 40, G: 50, B: 60},
277- },
278- want: []byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c},
279- },
280- {
281- name: "empty",
282- msg: Theme{},
283- want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
284- },
285- }
286-
287- for _, tt := range tests {
288- t.Run(tt.name, func(t *testing.T) {
289- got := MarshalTheme(tt.msg)
290- if !bytes.Equal(got, tt.want) {
291- t.Fatalf("MarshalTheme() = %#v, want %#v", got, tt.want)
292- }
293- back, err := UnmarshalTheme(got)
294- if err != nil {
295- t.Fatalf("UnmarshalTheme() error = %v", err)
296- }
297- if !reflect.DeepEqual(back, tt.msg) {
298- t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
299- }
300- })
301- }
302-
303- full := MarshalTheme(tests[0].msg)
304- assertAllPrefixesFail(t, full, UnmarshalTheme)
305- assertTrailingGarbageFails(t, full, UnmarshalTheme)
247+ palette := [16]renderer.RGB{
248+ {R: 0x10, G: 0x11, B: 0x12}, {R: 0x13, G: 0x14, B: 0x15},
249+ {R: 0x16, G: 0x17, B: 0x18}, {R: 0x19, G: 0x1a, B: 0x1b},
250+ {R: 0x1c, G: 0x1d, B: 0x1e}, {R: 0x1f, G: 0x20, B: 0x21},
251+ {R: 0x22, G: 0x23, B: 0x24}, {R: 0x25, G: 0x26, B: 0x27},
252+ {R: 0x28, G: 0x29, B: 0x2a}, {R: 0x2b, G: 0x2c, B: 0x2d},
253+ {R: 0x2e, G: 0x2f, B: 0x30}, {R: 0x31, G: 0x32, B: 0x33},
254+ {R: 0x34, G: 0x35, B: 0x36}, {R: 0x37, G: 0x38, B: 0x39},
255+ {R: 0x3a, G: 0x3b, B: 0x3c}, {R: 0x3d, G: 0x3e, B: 0x3f},
256+ }
257+ msg := Theme{
258+ HasForeground: true,
259+ Foreground: renderer.RGB{R: 1, G: 2, B: 3},
260+ HasBackground: true,
261+ Background: renderer.RGB{R: 4, G: 5, B: 6},
262+ TrueColor: true,
263+ SchemeKnown: true,
264+ PaletteKnown: 0x8001,
265+ Palette: palette,
266+ }
267+ want := []byte{
268+ 0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x80, 0x01,
269+ 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
270+ 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21,
271+ 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a,
272+ 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33,
273+ 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
274+ 0x3d, 0x3e, 0x3f,
275+ }
276+
277+ got := MarshalTheme(msg)
278+ require.Len(t, got, 57)
279+ require.Equal(t, want, got)
280+ back, err := UnmarshalTheme(got)
281+ require.NoError(t, err)
282+ require.Equal(t, msg, back)
283+
284+ assertAllPrefixesFail(t, got, UnmarshalTheme)
285+ assertTrailingGarbageFails(t, got, UnmarshalTheme)
306286 }
307287
308288 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -121,7 +121,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
121 Env: []string{"A=B", "XY=123"},121 Env: []string{"A=B", "XY=123"},
122 })122 })
123 want := []byte{123 want := []byte{
124- 0x00, 0x0f, 0x00, // version, intent124+ 0x00, 0x10, 0x00, // version, intent
125 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,125 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -244,65 +244,45 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) {
244 }244 }
245 245
246 func TestThemeGoldenAndRoundTrip(t *testing.T) {246 func TestThemeGoldenAndRoundTrip(t *testing.T) {
247- tests := []struct {247+ palette := [16]renderer.RGB{
248- name string248+ {R: 0x10, G: 0x11, B: 0x12}, {R: 0x13, G: 0x14, B: 0x15},
249- msg Theme249+ {R: 0x16, G: 0x17, B: 0x18}, {R: 0x19, G: 0x1a, B: 0x1b},
250- want []byte250+ {R: 0x1c, G: 0x1d, B: 0x1e}, {R: 0x1f, G: 0x20, B: 0x21},
251- }{251+ {R: 0x22, G: 0x23, B: 0x24}, {R: 0x25, G: 0x26, B: 0x27},
252- {252+ {R: 0x28, G: 0x29, B: 0x2a}, {R: 0x2b, G: 0x2c, B: 0x2d},
253- name: "foreground background truecolor dark scheme",253+ {R: 0x2e, G: 0x2f, B: 0x30}, {R: 0x31, G: 0x32, B: 0x33},
254- msg: Theme{254+ {R: 0x34, G: 0x35, B: 0x36}, {R: 0x37, G: 0x38, B: 0x39},
255- HasForeground: true,255+ {R: 0x3a, G: 0x3b, B: 0x3c}, {R: 0x3d, G: 0x3e, B: 0x3f},
256- Foreground: renderer.RGB{R: 1, G: 2, B: 3},256+ }
257- HasBackground: true,257+ msg := Theme{
258- Background: renderer.RGB{R: 4, G: 5, B: 6},258+ HasForeground: true,
259- TrueColor: true,259+ Foreground: renderer.RGB{R: 1, G: 2, B: 3},
260- SchemeKnown: true,260+ HasBackground: true,
261- },261+ Background: renderer.RGB{R: 4, G: 5, B: 6},
262- want: []byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06},262+ TrueColor: true,
263- },263+ SchemeKnown: true,
264- {264+ PaletteKnown: 0x8001,
265- name: "light scheme bit without known is preserved",265+ Palette: palette,
266- msg: Theme{266+ }
267- Light: true,267+ want := []byte{
268- },268+ 0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x80, 0x01,
269- want: []byte{0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},269+ 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
270- },270+ 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21,
271- {271+ 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a,
272- name: "foreground only no truecolor",272+ 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33,
273- msg: Theme{273+ 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c,
274- HasForeground: true,274+ 0x3d, 0x3e, 0x3f,
275- Foreground: renderer.RGB{R: 10, G: 20, B: 30},275+ }
276- Background: renderer.RGB{R: 40, G: 50, B: 60},276+
277- },277+ got := MarshalTheme(msg)
278- want: []byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c},278+ require.Len(t, got, 57)
279- },279+ require.Equal(t, want, got)
280- {280+ back, err := UnmarshalTheme(got)
281- name: "empty",281+ require.NoError(t, err)
282- msg: Theme{},282+ require.Equal(t, msg, back)
283- want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},283+
284- },284+ assertAllPrefixesFail(t, got, UnmarshalTheme)
285- }285+ assertTrailingGarbageFails(t, got, UnmarshalTheme)
286-
287- for _, tt := range tests {
288- t.Run(tt.name, func(t *testing.T) {
289- got := MarshalTheme(tt.msg)
290- if !bytes.Equal(got, tt.want) {
291- t.Fatalf("MarshalTheme() = %#v, want %#v", got, tt.want)
292- }
293- back, err := UnmarshalTheme(got)
294- if err != nil {
295- t.Fatalf("UnmarshalTheme() error = %v", err)
296- }
297- if !reflect.DeepEqual(back, tt.msg) {
298- t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
299- }
300- })
301- }
302-
303- full := MarshalTheme(tests[0].msg)
304- assertAllPrefixesFail(t, full, UnmarshalTheme)
305- assertTrailingGarbageFails(t, full, UnmarshalTheme)
306 }286 }
307 287
308 func TestResizeGoldenAndRoundTrip(t *testing.T) {288 func TestResizeGoldenAndRoundTrip(t *testing.T) {

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