detached 88e5870

merge(feature): terminal theme inheritance

commit
88e587078869960a9ecaeeeb9c672591e5d7ceae
author
brice · · 2026-07-03 14:44 UTC
merge of
95a371c + 8e0f50e · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
29 files · +1499 -64
M internal/adapters/term/pty_integration_test.go +1 -1
@@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
102102
103103 select {
104104 case got := <-captured:
105- want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
105+ want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
106106 if string(got) != want {
107107 t.Fatalf("captured escapes = %q, want %q", got, want)
108108 }
@@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
102 102
103 select {103 select {
104 case got := <-captured:104 case got := <-captured:
105- want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit105+ want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
106 if string(got) != want {106 if string(got) != want {
107 t.Fatalf("captured escapes = %q, want %q", got, want)107 t.Fatalf("captured escapes = %q, want %q", got, want)
108 }108 }
M internal/adapters/term/terminal.go +2 -1
@@ -28,6 +28,7 @@ const (
2828 cursorStyleDefault = "\x1b[0 q"
2929 mouseEnable = "\x1b[?1002h\x1b[?1006h"
3030 mouseDisable = "\x1b[?1002l\x1b[?1006l"
31+ oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
3132 )
3233
3334 // bufSize is the batched writer's buffer capacity.
@@ -104,7 +105,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
104105 }
105106 t.orig = old
106107
107- if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable); err != nil {
108+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + oscColorQuery); err != nil {
108109 _ = t.restoreRawLocked()
109110 return nil, fmt.Errorf("term: enter alt screen: %w", err)
110111 }
@@ -28,6 +28,7 @@ const (
28 cursorStyleDefault = "\x1b[0 q"28 cursorStyleDefault = "\x1b[0 q"
29 mouseEnable = "\x1b[?1002h\x1b[?1006h"29 mouseEnable = "\x1b[?1002h\x1b[?1006h"
30 mouseDisable = "\x1b[?1002l\x1b[?1006l"30 mouseDisable = "\x1b[?1002l\x1b[?1006l"
31+ oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
31 )32 )
32 33
33 // bufSize is the batched writer's buffer capacity.34 // bufSize is the batched writer's buffer capacity.
@@ -104,7 +105,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
104 }105 }
105 t.orig = old106 t.orig = old
106 107
107- if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable); err != nil {108+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + oscColorQuery); err != nil {
108 _ = t.restoreRawLocked()109 _ = t.restoreRawLocked()
109 return nil, fmt.Errorf("term: enter alt screen: %w", err)110 return nil, fmt.Errorf("term: enter alt screen: %w", err)
110 }111 }
M internal/adapters/term/terminal_test.go +5 -5
@@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
6060 _ = outW.Close()
6161 <-done
6262
63- want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
63+ want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
6464 if got := captured.String(); got != want {
6565 t.Fatalf("captured escapes = %q, want %q", got, want)
6666 }
@@ -98,10 +98,10 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
9898 _ = outW.Close()
9999 <-done
100100
101- // Alt-screen/cursor escapes must appear exactly once for enter and
102- // once for exit, regardless of how many times EnterRaw/restore were
103- // called.
104- want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
101+ // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and
102+ // cursor/alt-screen exits exactly once, regardless of how many times
103+ // EnterRaw/restore were called.
104+ want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
105105 if got := captured.String(); got != want {
106106 t.Fatalf("captured escapes = %q, want %q", got, want)
107107 }
@@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
60 _ = outW.Close()60 _ = outW.Close()
61 <-done61 <-done
62 62
63- want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit63+ want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
64 if got := captured.String(); got != want {64 if got := captured.String(); got != want {
65 t.Fatalf("captured escapes = %q, want %q", got, want)65 t.Fatalf("captured escapes = %q, want %q", got, want)
66 }66 }
@@ -98,10 +98,10 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
98 _ = outW.Close()98 _ = outW.Close()
99 <-done99 <-done
100 100
101- // Alt-screen/cursor escapes must appear exactly once for enter and101+ // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and
102- // once for exit, regardless of how many times EnterRaw/restore were102+ // cursor/alt-screen exits exactly once, regardless of how many times
103- // called.103+ // EnterRaw/restore were called.
104- want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit104+ want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
105 if got := captured.String(); got != want {105 if got := captured.String(); got != want {
106 t.Fatalf("captured escapes = %q, want %q", got, want)106 t.Fatalf("captured escapes = %q, want %q", got, want)
107 }107 }
M internal/ports/frame.go +4 -2
@@ -6,8 +6,9 @@ const ProtocolVersion uint16 = 3
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
88
9-// Frame message types. Client-originated messages are numbered from 1,
10-// server-originated messages from 16, leaving room for growth in each band.
9+// Frame message types. Client-originated messages are numbered from 1
10+// through 15, server-originated messages from 16, leaving room for growth in
11+// each band.
1112 const (
1213 MsgHello MsgType = 1
1314 MsgInput MsgType = 2
@@ -16,6 +17,7 @@ const (
1617 MsgPing MsgType = 5
1718 MsgList MsgType = 6
1819 MsgKill MsgType = 7
20+ MsgTheme MsgType = 8
1921
2022 MsgWelcome MsgType = 16
2123 MsgError MsgType = 17
@@ -6,8 +6,9 @@ const ProtocolVersion uint16 = 3
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
8 8
9-// Frame message types. Client-originated messages are numbered from 1,9+// Frame message types. Client-originated messages are numbered from 1
10-// server-originated messages from 16, leaving room for growth in each band.10+// through 15, server-originated messages from 16, leaving room for growth in
11+// each band.
11 const (12 const (
12 MsgHello MsgType = 113 MsgHello MsgType = 1
13 MsgInput MsgType = 214 MsgInput MsgType = 2
@@ -16,6 +17,7 @@ const (
16 MsgPing MsgType = 517 MsgPing MsgType = 5
17 MsgList MsgType = 618 MsgList MsgType = 6
18 MsgKill MsgType = 719 MsgKill MsgType = 7
20+ MsgTheme MsgType = 8
19 21
20 MsgWelcome MsgType = 1622 MsgWelcome MsgType = 16
21 MsgError MsgType = 1723 MsgError MsgType = 17
M internal/ports/frame_test.go +1 -0
@@ -14,6 +14,7 @@ func TestMsgTypeUnique(t *testing.T) {
1414 {"MsgPing", MsgPing},
1515 {"MsgList", MsgList},
1616 {"MsgKill", MsgKill},
17+ {"MsgTheme", MsgTheme},
1718 {"MsgWelcome", MsgWelcome},
1819 {"MsgError", MsgError},
1920 {"MsgOutput", MsgOutput},
@@ -14,6 +14,7 @@ func TestMsgTypeUnique(t *testing.T) {
14 {"MsgPing", MsgPing},14 {"MsgPing", MsgPing},
15 {"MsgList", MsgList},15 {"MsgList", MsgList},
16 {"MsgKill", MsgKill},16 {"MsgKill", MsgKill},
17+ {"MsgTheme", MsgTheme},
17 {"MsgWelcome", MsgWelcome},18 {"MsgWelcome", MsgWelcome},
18 {"MsgError", MsgError},19 {"MsgError", MsgError},
19 {"MsgOutput", MsgOutput},20 {"MsgOutput", MsgOutput},
M internal/ports/wire.go +73 -0
@@ -11,6 +11,7 @@ import (
1111 "errors"
1212
1313 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/pkg/renderer"
1415 )
1516
1617 // errShortPayload is returned when a payload ends before a required field
@@ -65,6 +66,16 @@ type Resize struct {
6566 Size domain.Size
6667 }
6768
69+// Theme reports the client's terminal foreground/background colors and
70+// whether the client terminal supports truecolor.
71+type Theme struct {
72+ HasForeground bool
73+ Foreground renderer.RGB
74+ HasBackground bool
75+ Background renderer.RGB
76+ TrueColor bool
77+}
78+
6879 // Detach asks the daemon to detach the current client without killing the
6980 // session.
7081 type Detach struct{}
@@ -272,6 +283,68 @@ func MarshalResize(m Resize) []byte {
272283 return w.b
273284 }
274285
286+// MarshalTheme encodes m into a fixed-width Theme message payload.
287+func MarshalTheme(m Theme) []byte {
288+ var flags uint8
289+ if m.HasForeground {
290+ flags |= 0x01
291+ }
292+ if m.HasBackground {
293+ flags |= 0x02
294+ }
295+ if m.TrueColor {
296+ flags |= 0x04
297+ }
298+ return []byte{
299+ flags,
300+ m.Foreground.R, m.Foreground.G, m.Foreground.B,
301+ m.Background.R, m.Background.G, m.Background.B,
302+ }
303+}
304+
305+// UnmarshalTheme decodes a fixed-width Theme message payload.
306+func UnmarshalTheme(b []byte) (Theme, error) {
307+ r := payloadReader{b: b}
308+ flags, err := r.getUint8()
309+ if err != nil {
310+ return Theme{}, err
311+ }
312+ fgR, err := r.getUint8()
313+ if err != nil {
314+ return Theme{}, err
315+ }
316+ fgG, err := r.getUint8()
317+ if err != nil {
318+ return Theme{}, err
319+ }
320+ fgB, err := r.getUint8()
321+ if err != nil {
322+ return Theme{}, err
323+ }
324+ bgR, err := r.getUint8()
325+ if err != nil {
326+ return Theme{}, err
327+ }
328+ bgG, err := r.getUint8()
329+ if err != nil {
330+ return Theme{}, err
331+ }
332+ bgB, err := r.getUint8()
333+ if err != nil {
334+ return Theme{}, err
335+ }
336+ if err := r.done(); err != nil {
337+ return Theme{}, err
338+ }
339+ return Theme{
340+ HasForeground: flags&0x01 != 0,
341+ Foreground: renderer.RGB{R: fgR, G: fgG, B: fgB},
342+ HasBackground: flags&0x02 != 0,
343+ Background: renderer.RGB{R: bgR, G: bgG, B: bgB},
344+ TrueColor: flags&0x04 != 0,
345+ }, nil
346+}
347+
275348 // UnmarshalResize decodes a Resize message payload.
276349 func UnmarshalResize(b []byte) (Resize, error) {
277350 r := payloadReader{b: b}
@@ -11,6 +11,7 @@ import (
11 "errors"11 "errors"
12 12
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/pkg/renderer"
14 )15 )
15 16
16 // errShortPayload is returned when a payload ends before a required field17 // errShortPayload is returned when a payload ends before a required field
@@ -65,6 +66,16 @@ type Resize struct {
65 Size domain.Size66 Size domain.Size
66 }67 }
67 68
69+// Theme reports the client's terminal foreground/background colors and
70+// whether the client terminal supports truecolor.
71+type Theme struct {
72+ HasForeground bool
73+ Foreground renderer.RGB
74+ HasBackground bool
75+ Background renderer.RGB
76+ TrueColor bool
77+}
78+
68 // Detach asks the daemon to detach the current client without killing the79 // Detach asks the daemon to detach the current client without killing the
69 // session.80 // session.
70 type Detach struct{}81 type Detach struct{}
@@ -272,6 +283,68 @@ func MarshalResize(m Resize) []byte {
272 return w.b283 return w.b
273 }284 }
274 285
286+// MarshalTheme encodes m into a fixed-width Theme message payload.
287+func MarshalTheme(m Theme) []byte {
288+ var flags uint8
289+ if m.HasForeground {
290+ flags |= 0x01
291+ }
292+ if m.HasBackground {
293+ flags |= 0x02
294+ }
295+ if m.TrueColor {
296+ flags |= 0x04
297+ }
298+ return []byte{
299+ flags,
300+ m.Foreground.R, m.Foreground.G, m.Foreground.B,
301+ m.Background.R, m.Background.G, m.Background.B,
302+ }
303+}
304+
305+// UnmarshalTheme decodes a fixed-width Theme message payload.
306+func UnmarshalTheme(b []byte) (Theme, error) {
307+ r := payloadReader{b: b}
308+ flags, err := r.getUint8()
309+ if err != nil {
310+ return Theme{}, err
311+ }
312+ fgR, err := r.getUint8()
313+ if err != nil {
314+ return Theme{}, err
315+ }
316+ fgG, err := r.getUint8()
317+ if err != nil {
318+ return Theme{}, err
319+ }
320+ fgB, err := r.getUint8()
321+ if err != nil {
322+ return Theme{}, err
323+ }
324+ bgR, err := r.getUint8()
325+ if err != nil {
326+ return Theme{}, err
327+ }
328+ bgG, err := r.getUint8()
329+ if err != nil {
330+ return Theme{}, err
331+ }
332+ bgB, err := r.getUint8()
333+ if err != nil {
334+ return Theme{}, err
335+ }
336+ if err := r.done(); err != nil {
337+ return Theme{}, err
338+ }
339+ return Theme{
340+ HasForeground: flags&0x01 != 0,
341+ Foreground: renderer.RGB{R: fgR, G: fgG, B: fgB},
342+ HasBackground: flags&0x02 != 0,
343+ Background: renderer.RGB{R: bgR, G: bgG, B: bgB},
344+ TrueColor: flags&0x04 != 0,
345+ }, nil
346+}
347+
275 // UnmarshalResize decodes a Resize message payload.348 // UnmarshalResize decodes a Resize message payload.
276 func UnmarshalResize(b []byte) (Resize, error) {349 func UnmarshalResize(b []byte) (Resize, error) {
277 r := payloadReader{b: b}350 r := payloadReader{b: b}
M internal/ports/wire_test.go +55 -0
@@ -7,6 +7,7 @@ import (
77 "testing"
88
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/pkg/renderer"
1011 )
1112
1213 // mustNotPanic runs f and fails the test (instead of crashing the test
@@ -139,6 +140,60 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
139140 })
140141 }
141142
143+func TestThemeGoldenAndRoundTrip(t *testing.T) {
144+ tests := []struct {
145+ name string
146+ msg Theme
147+ want []byte
148+ }{
149+ {
150+ name: "foreground background truecolor",
151+ msg: Theme{
152+ HasForeground: true,
153+ Foreground: renderer.RGB{R: 1, G: 2, B: 3},
154+ HasBackground: true,
155+ Background: renderer.RGB{R: 4, G: 5, B: 6},
156+ TrueColor: true,
157+ },
158+ want: []byte{0x07, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06},
159+ },
160+ {
161+ name: "foreground only no truecolor",
162+ msg: Theme{
163+ HasForeground: true,
164+ Foreground: renderer.RGB{R: 10, G: 20, B: 30},
165+ Background: renderer.RGB{R: 40, G: 50, B: 60},
166+ },
167+ want: []byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c},
168+ },
169+ {
170+ name: "empty",
171+ msg: Theme{},
172+ want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
173+ },
174+ }
175+
176+ for _, tt := range tests {
177+ t.Run(tt.name, func(t *testing.T) {
178+ got := MarshalTheme(tt.msg)
179+ if !bytes.Equal(got, tt.want) {
180+ t.Fatalf("MarshalTheme() = %#v, want %#v", got, tt.want)
181+ }
182+ back, err := UnmarshalTheme(got)
183+ if err != nil {
184+ t.Fatalf("UnmarshalTheme() error = %v", err)
185+ }
186+ if !reflect.DeepEqual(back, tt.msg) {
187+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
188+ }
189+ })
190+ }
191+
192+ full := MarshalTheme(tests[0].msg)
193+ assertAllPrefixesFail(t, full, UnmarshalTheme)
194+ assertTrailingGarbageFails(t, full, UnmarshalTheme)
195+}
196+
142197 func TestResizeGoldenAndRoundTrip(t *testing.T) {
143198 msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}
144199 want := []byte{0x00, 0x64, 0x00, 0x28}
@@ -7,6 +7,7 @@ import (
7 "testing"7 "testing"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/pkg/renderer"
10 )11 )
11 12
12 // mustNotPanic runs f and fails the test (instead of crashing the test13 // mustNotPanic runs f and fails the test (instead of crashing the test
@@ -139,6 +140,60 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
139 })140 })
140 }141 }
141 142
143+func TestThemeGoldenAndRoundTrip(t *testing.T) {
144+ tests := []struct {
145+ name string
146+ msg Theme
147+ want []byte
148+ }{
149+ {
150+ name: "foreground background truecolor",
151+ msg: Theme{
152+ HasForeground: true,
153+ Foreground: renderer.RGB{R: 1, G: 2, B: 3},
154+ HasBackground: true,
155+ Background: renderer.RGB{R: 4, G: 5, B: 6},
156+ TrueColor: true,
157+ },
158+ want: []byte{0x07, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06},
159+ },
160+ {
161+ name: "foreground only no truecolor",
162+ msg: Theme{
163+ HasForeground: true,
164+ Foreground: renderer.RGB{R: 10, G: 20, B: 30},
165+ Background: renderer.RGB{R: 40, G: 50, B: 60},
166+ },
167+ want: []byte{0x01, 0x0a, 0x14, 0x1e, 0x28, 0x32, 0x3c},
168+ },
169+ {
170+ name: "empty",
171+ msg: Theme{},
172+ want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
173+ },
174+ }
175+
176+ for _, tt := range tests {
177+ t.Run(tt.name, func(t *testing.T) {
178+ got := MarshalTheme(tt.msg)
179+ if !bytes.Equal(got, tt.want) {
180+ t.Fatalf("MarshalTheme() = %#v, want %#v", got, tt.want)
181+ }
182+ back, err := UnmarshalTheme(got)
183+ if err != nil {
184+ t.Fatalf("UnmarshalTheme() error = %v", err)
185+ }
186+ if !reflect.DeepEqual(back, tt.msg) {
187+ t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
188+ }
189+ })
190+ }
191+
192+ full := MarshalTheme(tests[0].msg)
193+ assertAllPrefixesFail(t, full, UnmarshalTheme)
194+ assertTrailingGarbageFails(t, full, UnmarshalTheme)
195+}
196+
142 func TestResizeGoldenAndRoundTrip(t *testing.T) {197 func TestResizeGoldenAndRoundTrip(t *testing.T) {
143 msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}198 msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}
144 want := []byte{0x00, 0x64, 0x00, 0x28}199 want := []byte{0x00, 0x64, 0x00, 0x28}
M internal/usecase/client/client.go +38 -8
@@ -14,9 +14,12 @@ import (
1414 "io"
1515 "log/slog"
1616 "os"
17+ "strings"
1718
1819 "github.com/bnema/vev/internal/domain"
1920 "github.com/bnema/vev/internal/ports"
21+ "github.com/bnema/vev/internal/usecase/theme"
22+ "github.com/bnema/vev/pkg/renderer"
2023 )
2124
2225 // ProtocolError is a session- or protocol-level failure reported by the
@@ -115,6 +118,8 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
115118 if err != nil {
116119 cwd = ""
117120 }
121+ colorTerm := strings.ToLower(os.Getenv("COLORTERM"))
122+ trueColor := colorTerm == "truecolor" || colorTerm == "24bit"
118123 hello := ports.Hello{
119124 Version: ports.ProtocolVersion,
120125 Intent: intent,
@@ -168,7 +173,7 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
168173 sendErrCh := make(chan error, 1)
169174
170175 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh)
171- go runStdin(loopCtx, cancel, term.In(), sendCh)
176+ go runStdin(loopCtx, cancel, term.In(), sendCh, trueColor)
172177 go runResize(loopCtx, term.ResizeEvents(), sendCh)
173178
174179 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -282,17 +287,42 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
282287 // exits. That is harmless here — Attach has already returned and restored
283288 // the terminal — and matches the standard pattern for stdin pumps; a
284289 // closable stdin duplicate could lift it later if ever needed.
285-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame) {
290+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) {
286291 buf := make([]byte, stdinBufSize)
292+ var scanner theme.Scanner
293+ current := ports.Theme{TrueColor: trueColor}
294+ send := func(frame ports.Frame) bool {
295+ select {
296+ case out <- frame:
297+ return true
298+ case <-ctx.Done():
299+ return false
300+ }
301+ }
287302 for {
288303 n, rerr := in.Read(buf)
289304 if n > 0 {
290- data := make([]byte, n)
291- copy(data, buf[:n])
292- frame := ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})}
293- select {
294- case out <- frame:
295- case <-ctx.Done():
305+ ok := true
306+ scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
307+ switch kind {
308+ case 10:
309+ current.HasForeground = true
310+ current.Foreground = rgb
311+ case 11:
312+ current.HasBackground = true
313+ current.Background = rgb
314+ default:
315+ return
316+ }
317+ ok = send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
318+ }, func(data []byte) {
319+ if len(data) == 0 || !ok {
320+ return
321+ }
322+ copyData := append([]byte(nil), data...)
323+ ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: copyData})})
324+ })
325+ if !ok {
296326 return
297327 }
298328 }
@@ -14,9 +14,12 @@ import (
14 "io"14 "io"
15 "log/slog"15 "log/slog"
16 "os"16 "os"
17+ "strings"
17 18
18 "github.com/bnema/vev/internal/domain"19 "github.com/bnema/vev/internal/domain"
19 "github.com/bnema/vev/internal/ports"20 "github.com/bnema/vev/internal/ports"
21+ "github.com/bnema/vev/internal/usecase/theme"
22+ "github.com/bnema/vev/pkg/renderer"
20 )23 )
21 24
22 // ProtocolError is a session- or protocol-level failure reported by the25 // ProtocolError is a session- or protocol-level failure reported by the
@@ -115,6 +118,8 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
115 if err != nil {118 if err != nil {
116 cwd = ""119 cwd = ""
117 }120 }
121+ colorTerm := strings.ToLower(os.Getenv("COLORTERM"))
122+ trueColor := colorTerm == "truecolor" || colorTerm == "24bit"
118 hello := ports.Hello{123 hello := ports.Hello{
119 Version: ports.ProtocolVersion,124 Version: ports.ProtocolVersion,
120 Intent: intent,125 Intent: intent,
@@ -168,7 +173,7 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
168 sendErrCh := make(chan error, 1)173 sendErrCh := make(chan error, 1)
169 174
170 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh)175 go runSender(loopCtx, cancel, transport, sendCh, sendErrCh)
171- go runStdin(loopCtx, cancel, term.In(), sendCh)176+ go runStdin(loopCtx, cancel, term.In(), sendCh, trueColor)
172 go runResize(loopCtx, term.ResizeEvents(), sendCh)177 go runResize(loopCtx, term.ResizeEvents(), sendCh)
173 178
174 // 5. Output/main loop: the only goroutine that touches the terminal.179 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -282,17 +287,42 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
282 // exits. That is harmless here — Attach has already returned and restored287 // exits. That is harmless here — Attach has already returned and restored
283 // the terminal — and matches the standard pattern for stdin pumps; a288 // the terminal — and matches the standard pattern for stdin pumps; a
284 // closable stdin duplicate could lift it later if ever needed.289 // closable stdin duplicate could lift it later if ever needed.
285-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame) {290+func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) {
286 buf := make([]byte, stdinBufSize)291 buf := make([]byte, stdinBufSize)
292+ var scanner theme.Scanner
293+ current := ports.Theme{TrueColor: trueColor}
294+ send := func(frame ports.Frame) bool {
295+ select {
296+ case out <- frame:
297+ return true
298+ case <-ctx.Done():
299+ return false
300+ }
301+ }
287 for {302 for {
288 n, rerr := in.Read(buf)303 n, rerr := in.Read(buf)
289 if n > 0 {304 if n > 0 {
290- data := make([]byte, n)305+ ok := true
291- copy(data, buf[:n])306+ scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
292- frame := ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})}307+ switch kind {
293- select {308+ case 10:
294- case out <- frame:309+ current.HasForeground = true
295- case <-ctx.Done():310+ current.Foreground = rgb
311+ case 11:
312+ current.HasBackground = true
313+ current.Background = rgb
314+ default:
315+ return
316+ }
317+ ok = send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(current)})
318+ }, func(data []byte) {
319+ if len(data) == 0 || !ok {
320+ return
321+ }
322+ copyData := append([]byte(nil), data...)
323+ ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: copyData})})
324+ })
325+ if !ok {
296 return326 return
297 }327 }
298 }328 }
M internal/usecase/client/client_test.go +299 -0
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "io"
88 "log/slog"
9+ "sync"
910 "sync/atomic"
1011 "testing"
1112 "time"
@@ -66,6 +67,31 @@ func (b *blockingReader) Read(p []byte) (int, error) {
6667 }
6768 func (b *blockingReader) unblock() { close(b.ch) }
6869
70+type oneShotBlockingReader struct {
71+ data []byte
72+ done chan struct{}
73+ once sync.Once
74+}
75+
76+func newOneShotBlockingReader(data []byte) *oneShotBlockingReader {
77+ return &oneShotBlockingReader{data: data, done: make(chan struct{})}
78+}
79+
80+func (r *oneShotBlockingReader) Read(p []byte) (int, error) {
81+ read := false
82+ r.once.Do(func() {
83+ copy(p, r.data)
84+ read = true
85+ })
86+ if read {
87+ return len(r.data), nil
88+ }
89+ <-r.done
90+ return 0, io.EOF
91+}
92+
93+func (r *oneShotBlockingReader) unblock() { close(r.done) }
94+
6995 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {
7096 tm := portsmocks.NewMockTerminal(t)
7197 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
@@ -172,6 +198,279 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
172198 require.Equal(t, int32(1), restoreCount.Load())
173199 }
174200
201+func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
202+ t.Setenv("COLORTERM", "truecolor")
203+
204+ var out bytes.Buffer
205+ var restoreCount atomic.Int32
206+ resizeCh := make(chan domain.Size)
207+ input := newOneShotBlockingReader([]byte("a\x1b]11;rgb:0101/0202/0303\x07b"))
208+ defer input.unblock()
209+
210+ tm := portsmocks.NewMockTerminal(t)
211+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
212+ tm.EXPECT().EnterRaw().Return(func() error {
213+ restoreCount.Add(1)
214+ return nil
215+ }, nil).Once()
216+ tm.EXPECT().In().Return(input).Maybe()
217+ tm.EXPECT().Out().Return(&out).Maybe()
218+ tm.EXPECT().Flush().Return(nil).Maybe()
219+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
220+
221+ gotTheme := make(chan ports.Theme, 1)
222+ gotInput := make(chan []byte, 2)
223+ allowDetach := make(chan struct{})
224+
225+ tr := portsmocks.NewMockTransport(t)
226+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
227+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
228+ in, err := ports.UnmarshalInput(f.Payload)
229+ require.NoError(t, err)
230+ require.NotContains(t, string(in.Data), "\x1b]11;")
231+ gotInput <- append([]byte(nil), in.Data...)
232+ if bytes.Contains(in.Data, []byte("b")) {
233+ close(allowDetach)
234+ }
235+ return nil
236+ }).Maybe()
237+ tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
238+ th, err := ports.UnmarshalTheme(f.Payload)
239+ require.NoError(t, err)
240+ gotTheme <- th
241+ return nil
242+ }).Once()
243+
244+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
245+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
246+ recvCh := make(chan recvItem, 1)
247+ recvCh <- recvItem{f: welcome}
248+ closed := make(chan struct{})
249+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
250+ select {
251+ case it := <-recvCh:
252+ return it.f, it.err
253+ case <-allowDetach:
254+ select {
255+ case <-closed:
256+ return ports.Frame{}, io.EOF
257+ default:
258+ close(closed)
259+ return detached, nil
260+ }
261+ case <-closed:
262+ return ports.Frame{}, io.EOF
263+ }
264+ }).Maybe()
265+ tr.EXPECT().Close().Return(nil).Once()
266+
267+ err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
268+ require.NoError(t, err)
269+ require.Equal(t, int32(1), restoreCount.Load())
270+
271+ select {
272+ case th := <-gotTheme:
273+ require.True(t, th.HasBackground)
274+ require.Equal(t, uint8(1), th.Background.R)
275+ require.Equal(t, uint8(2), th.Background.G)
276+ require.Equal(t, uint8(3), th.Background.B)
277+ require.True(t, th.TrueColor)
278+ case <-time.After(2 * time.Second):
279+ t.Fatal("theme frame was not sent")
280+ }
281+
282+ var inputBytes []byte
283+ for {
284+ select {
285+ case b := <-gotInput:
286+ inputBytes = append(inputBytes, b...)
287+ default:
288+ require.Equal(t, []byte("ab"), inputBytes)
289+ return
290+ }
291+ }
292+}
293+
294+func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
295+ t.Setenv("COLORTERM", "")
296+
297+ var out bytes.Buffer
298+ var restoreCount atomic.Int32
299+ resizeCh := make(chan domain.Size)
300+ input := newOneShotBlockingReader([]byte("\x1b]10;#010203\x07"))
301+ defer input.unblock()
302+
303+ tm := portsmocks.NewMockTerminal(t)
304+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
305+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
306+ tm.EXPECT().In().Return(input).Maybe()
307+ tm.EXPECT().Out().Return(&out).Maybe()
308+ tm.EXPECT().Flush().Return(nil).Maybe()
309+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
310+
311+ gotTheme := make(chan ports.Theme, 1)
312+ allowDetach := make(chan struct{})
313+ tr := portsmocks.NewMockTransport(t)
314+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
315+ tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
316+ th, err := ports.UnmarshalTheme(f.Payload)
317+ require.NoError(t, err)
318+ gotTheme <- th
319+ close(allowDetach)
320+ return nil
321+ }).Once()
322+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
323+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
324+ recvCh := make(chan recvItem, 1)
325+ recvCh <- recvItem{f: welcome}
326+ closed := make(chan struct{})
327+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
328+ select {
329+ case it := <-recvCh:
330+ return it.f, it.err
331+ case <-allowDetach:
332+ select {
333+ case <-closed:
334+ return ports.Frame{}, io.EOF
335+ default:
336+ close(closed)
337+ return detached, nil
338+ }
339+ case <-closed:
340+ return ports.Frame{}, io.EOF
341+ }
342+ }).Maybe()
343+ tr.EXPECT().Close().Return(nil).Once()
344+
345+ err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
346+ require.NoError(t, err)
347+ select {
348+ case th := <-gotTheme:
349+ require.True(t, th.HasForeground)
350+ require.False(t, th.TrueColor)
351+ case <-time.After(2 * time.Second):
352+ t.Fatal("theme frame was not sent")
353+ }
354+}
355+
356+func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
357+ var out bytes.Buffer
358+ var restoreCount atomic.Int32
359+ resizeCh := make(chan domain.Size)
360+ input := newOneShotBlockingReader([]byte("\x1b"))
361+ defer input.unblock()
362+
363+ tm := portsmocks.NewMockTerminal(t)
364+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
365+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
366+ tm.EXPECT().In().Return(input).Maybe()
367+ tm.EXPECT().Out().Return(&out).Maybe()
368+ tm.EXPECT().Flush().Return(nil).Maybe()
369+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
370+
371+ gotInput := make(chan []byte, 1)
372+ allowDetach := make(chan struct{})
373+ tr := portsmocks.NewMockTransport(t)
374+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
375+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
376+ in, err := ports.UnmarshalInput(f.Payload)
377+ require.NoError(t, err)
378+ gotInput <- in.Data
379+ close(allowDetach)
380+ return nil
381+ }).Once()
382+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
383+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
384+ recvCh := make(chan recvItem, 1)
385+ recvCh <- recvItem{f: welcome}
386+ closed := make(chan struct{})
387+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
388+ select {
389+ case it := <-recvCh:
390+ return it.f, it.err
391+ case <-allowDetach:
392+ select {
393+ case <-closed:
394+ return ports.Frame{}, io.EOF
395+ default:
396+ close(closed)
397+ return detached, nil
398+ }
399+ case <-closed:
400+ return ports.Frame{}, io.EOF
401+ }
402+ }).Maybe()
403+ tr.EXPECT().Close().Return(nil).Once()
404+
405+ err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
406+ require.NoError(t, err)
407+ select {
408+ case got := <-gotInput:
409+ require.Equal(t, []byte("\x1b"), got)
410+ case <-time.After(2 * time.Second):
411+ t.Fatal("standalone escape input was not sent")
412+ }
413+}
414+
415+func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
416+ var out bytes.Buffer
417+ var restoreCount atomic.Int32
418+ resizeCh := make(chan domain.Size)
419+ input := newOneShotBlockingReader([]byte("\x1b[<0;1;1M"))
420+ defer input.unblock()
421+
422+ tm := portsmocks.NewMockTerminal(t)
423+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
424+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
425+ tm.EXPECT().In().Return(input).Maybe()
426+ tm.EXPECT().Out().Return(&out).Maybe()
427+ tm.EXPECT().Flush().Return(nil).Maybe()
428+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
429+
430+ gotInput := make(chan []byte, 2)
431+ allowDetach := make(chan struct{})
432+ tr := portsmocks.NewMockTransport(t)
433+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
434+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
435+ in, err := ports.UnmarshalInput(f.Payload)
436+ require.NoError(t, err)
437+ gotInput <- in.Data
438+ close(allowDetach)
439+ return nil
440+ }).Once()
441+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
442+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
443+ recvCh := make(chan recvItem, 1)
444+ recvCh <- recvItem{f: welcome}
445+ closed := make(chan struct{})
446+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
447+ select {
448+ case it := <-recvCh:
449+ return it.f, it.err
450+ case <-allowDetach:
451+ select {
452+ case <-closed:
453+ return ports.Frame{}, io.EOF
454+ default:
455+ close(closed)
456+ return detached, nil
457+ }
458+ case <-closed:
459+ return ports.Frame{}, io.EOF
460+ }
461+ }).Maybe()
462+ tr.EXPECT().Close().Return(nil).Once()
463+
464+ err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
465+ require.NoError(t, err)
466+ select {
467+ case got := <-gotInput:
468+ require.Equal(t, []byte("\x1b[<0;1;1M"), got, "SGR mouse report must arrive as one intact MsgInput frame")
469+ case <-time.After(2 * time.Second):
470+ t.Fatal("mouse report input was not sent")
471+ }
472+}
473+
175474 func TestAttachForwardsResize(t *testing.T) {
176475 var out bytes.Buffer
177476 var restoreCount atomic.Int32
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "io"7 "io"
8 "log/slog"8 "log/slog"
9+ "sync"
9 "sync/atomic"10 "sync/atomic"
10 "testing"11 "testing"
11 "time"12 "time"
@@ -66,6 +67,31 @@ func (b *blockingReader) Read(p []byte) (int, error) {
66 }67 }
67 func (b *blockingReader) unblock() { close(b.ch) }68 func (b *blockingReader) unblock() { close(b.ch) }
68 69
70+type oneShotBlockingReader struct {
71+ data []byte
72+ done chan struct{}
73+ once sync.Once
74+}
75+
76+func newOneShotBlockingReader(data []byte) *oneShotBlockingReader {
77+ return &oneShotBlockingReader{data: data, done: make(chan struct{})}
78+}
79+
80+func (r *oneShotBlockingReader) Read(p []byte) (int, error) {
81+ read := false
82+ r.once.Do(func() {
83+ copy(p, r.data)
84+ read = true
85+ })
86+ if read {
87+ return len(r.data), nil
88+ }
89+ <-r.done
90+ return 0, io.EOF
91+}
92+
93+func (r *oneShotBlockingReader) unblock() { close(r.done) }
94+
69 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {95 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {
70 tm := portsmocks.NewMockTerminal(t)96 tm := portsmocks.NewMockTerminal(t)
71 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()97 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
@@ -172,6 +198,279 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
172 require.Equal(t, int32(1), restoreCount.Load())198 require.Equal(t, int32(1), restoreCount.Load())
173 }199 }
174 200
201+func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
202+ t.Setenv("COLORTERM", "truecolor")
203+
204+ var out bytes.Buffer
205+ var restoreCount atomic.Int32
206+ resizeCh := make(chan domain.Size)
207+ input := newOneShotBlockingReader([]byte("a\x1b]11;rgb:0101/0202/0303\x07b"))
208+ defer input.unblock()
209+
210+ tm := portsmocks.NewMockTerminal(t)
211+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
212+ tm.EXPECT().EnterRaw().Return(func() error {
213+ restoreCount.Add(1)
214+ return nil
215+ }, nil).Once()
216+ tm.EXPECT().In().Return(input).Maybe()
217+ tm.EXPECT().Out().Return(&out).Maybe()
218+ tm.EXPECT().Flush().Return(nil).Maybe()
219+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
220+
221+ gotTheme := make(chan ports.Theme, 1)
222+ gotInput := make(chan []byte, 2)
223+ allowDetach := make(chan struct{})
224+
225+ tr := portsmocks.NewMockTransport(t)
226+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
227+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
228+ in, err := ports.UnmarshalInput(f.Payload)
229+ require.NoError(t, err)
230+ require.NotContains(t, string(in.Data), "\x1b]11;")
231+ gotInput <- append([]byte(nil), in.Data...)
232+ if bytes.Contains(in.Data, []byte("b")) {
233+ close(allowDetach)
234+ }
235+ return nil
236+ }).Maybe()
237+ tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
238+ th, err := ports.UnmarshalTheme(f.Payload)
239+ require.NoError(t, err)
240+ gotTheme <- th
241+ return nil
242+ }).Once()
243+
244+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
245+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
246+ recvCh := make(chan recvItem, 1)
247+ recvCh <- recvItem{f: welcome}
248+ closed := make(chan struct{})
249+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
250+ select {
251+ case it := <-recvCh:
252+ return it.f, it.err
253+ case <-allowDetach:
254+ select {
255+ case <-closed:
256+ return ports.Frame{}, io.EOF
257+ default:
258+ close(closed)
259+ return detached, nil
260+ }
261+ case <-closed:
262+ return ports.Frame{}, io.EOF
263+ }
264+ }).Maybe()
265+ tr.EXPECT().Close().Return(nil).Once()
266+
267+ err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
268+ require.NoError(t, err)
269+ require.Equal(t, int32(1), restoreCount.Load())
270+
271+ select {
272+ case th := <-gotTheme:
273+ require.True(t, th.HasBackground)
274+ require.Equal(t, uint8(1), th.Background.R)
275+ require.Equal(t, uint8(2), th.Background.G)
276+ require.Equal(t, uint8(3), th.Background.B)
277+ require.True(t, th.TrueColor)
278+ case <-time.After(2 * time.Second):
279+ t.Fatal("theme frame was not sent")
280+ }
281+
282+ var inputBytes []byte
283+ for {
284+ select {
285+ case b := <-gotInput:
286+ inputBytes = append(inputBytes, b...)
287+ default:
288+ require.Equal(t, []byte("ab"), inputBytes)
289+ return
290+ }
291+ }
292+}
293+
294+func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
295+ t.Setenv("COLORTERM", "")
296+
297+ var out bytes.Buffer
298+ var restoreCount atomic.Int32
299+ resizeCh := make(chan domain.Size)
300+ input := newOneShotBlockingReader([]byte("\x1b]10;#010203\x07"))
301+ defer input.unblock()
302+
303+ tm := portsmocks.NewMockTerminal(t)
304+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
305+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
306+ tm.EXPECT().In().Return(input).Maybe()
307+ tm.EXPECT().Out().Return(&out).Maybe()
308+ tm.EXPECT().Flush().Return(nil).Maybe()
309+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
310+
311+ gotTheme := make(chan ports.Theme, 1)
312+ allowDetach := make(chan struct{})
313+ tr := portsmocks.NewMockTransport(t)
314+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
315+ tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
316+ th, err := ports.UnmarshalTheme(f.Payload)
317+ require.NoError(t, err)
318+ gotTheme <- th
319+ close(allowDetach)
320+ return nil
321+ }).Once()
322+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
323+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
324+ recvCh := make(chan recvItem, 1)
325+ recvCh <- recvItem{f: welcome}
326+ closed := make(chan struct{})
327+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
328+ select {
329+ case it := <-recvCh:
330+ return it.f, it.err
331+ case <-allowDetach:
332+ select {
333+ case <-closed:
334+ return ports.Frame{}, io.EOF
335+ default:
336+ close(closed)
337+ return detached, nil
338+ }
339+ case <-closed:
340+ return ports.Frame{}, io.EOF
341+ }
342+ }).Maybe()
343+ tr.EXPECT().Close().Return(nil).Once()
344+
345+ err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
346+ require.NoError(t, err)
347+ select {
348+ case th := <-gotTheme:
349+ require.True(t, th.HasForeground)
350+ require.False(t, th.TrueColor)
351+ case <-time.After(2 * time.Second):
352+ t.Fatal("theme frame was not sent")
353+ }
354+}
355+
356+func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
357+ var out bytes.Buffer
358+ var restoreCount atomic.Int32
359+ resizeCh := make(chan domain.Size)
360+ input := newOneShotBlockingReader([]byte("\x1b"))
361+ defer input.unblock()
362+
363+ tm := portsmocks.NewMockTerminal(t)
364+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
365+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
366+ tm.EXPECT().In().Return(input).Maybe()
367+ tm.EXPECT().Out().Return(&out).Maybe()
368+ tm.EXPECT().Flush().Return(nil).Maybe()
369+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
370+
371+ gotInput := make(chan []byte, 1)
372+ allowDetach := make(chan struct{})
373+ tr := portsmocks.NewMockTransport(t)
374+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
375+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
376+ in, err := ports.UnmarshalInput(f.Payload)
377+ require.NoError(t, err)
378+ gotInput <- in.Data
379+ close(allowDetach)
380+ return nil
381+ }).Once()
382+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
383+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
384+ recvCh := make(chan recvItem, 1)
385+ recvCh <- recvItem{f: welcome}
386+ closed := make(chan struct{})
387+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
388+ select {
389+ case it := <-recvCh:
390+ return it.f, it.err
391+ case <-allowDetach:
392+ select {
393+ case <-closed:
394+ return ports.Frame{}, io.EOF
395+ default:
396+ close(closed)
397+ return detached, nil
398+ }
399+ case <-closed:
400+ return ports.Frame{}, io.EOF
401+ }
402+ }).Maybe()
403+ tr.EXPECT().Close().Return(nil).Once()
404+
405+ err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
406+ require.NoError(t, err)
407+ select {
408+ case got := <-gotInput:
409+ require.Equal(t, []byte("\x1b"), got)
410+ case <-time.After(2 * time.Second):
411+ t.Fatal("standalone escape input was not sent")
412+ }
413+}
414+
415+func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
416+ var out bytes.Buffer
417+ var restoreCount atomic.Int32
418+ resizeCh := make(chan domain.Size)
419+ input := newOneShotBlockingReader([]byte("\x1b[<0;1;1M"))
420+ defer input.unblock()
421+
422+ tm := portsmocks.NewMockTerminal(t)
423+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
424+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
425+ tm.EXPECT().In().Return(input).Maybe()
426+ tm.EXPECT().Out().Return(&out).Maybe()
427+ tm.EXPECT().Flush().Return(nil).Maybe()
428+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
429+
430+ gotInput := make(chan []byte, 2)
431+ allowDetach := make(chan struct{})
432+ tr := portsmocks.NewMockTransport(t)
433+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
434+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
435+ in, err := ports.UnmarshalInput(f.Payload)
436+ require.NoError(t, err)
437+ gotInput <- in.Data
438+ close(allowDetach)
439+ return nil
440+ }).Once()
441+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
442+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
443+ recvCh := make(chan recvItem, 1)
444+ recvCh <- recvItem{f: welcome}
445+ closed := make(chan struct{})
446+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
447+ select {
448+ case it := <-recvCh:
449+ return it.f, it.err
450+ case <-allowDetach:
451+ select {
452+ case <-closed:
453+ return ports.Frame{}, io.EOF
454+ default:
455+ close(closed)
456+ return detached, nil
457+ }
458+ case <-closed:
459+ return ports.Frame{}, io.EOF
460+ }
461+ }).Maybe()
462+ tr.EXPECT().Close().Return(nil).Once()
463+
464+ err := client.Attach(context.Background(), tr, tm, ports.IntentEphemeral, "")
465+ require.NoError(t, err)
466+ select {
467+ case got := <-gotInput:
468+ require.Equal(t, []byte("\x1b[<0;1;1M"), got, "SGR mouse report must arrive as one intact MsgInput frame")
469+ case <-time.After(2 * time.Second):
470+ t.Fatal("mouse report input was not sent")
471+ }
472+}
473+
175 func TestAttachForwardsResize(t *testing.T) {474 func TestAttachForwardsResize(t *testing.T) {
176 var out bytes.Buffer475 var out bytes.Buffer
177 var restoreCount atomic.Int32476 var restoreCount atomic.Int32
M internal/usecase/copy/mode.go +31 -7
@@ -141,10 +141,11 @@ func (m *Mode) SelectedText(s Snapshot) string {
141141 return strings.Join(lines, "\n")
142142 }
143143
144-func (m *Mode) Render(s Snapshot) renderer.Frame {
144+func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
145145 m.clamp(s)
146146 frame := renderer.NewFrame(s.Width, s.Height+1)
147147 lo, hi, selected := m.SelectedBounds()
148+ selectionStyle, hasSelectionStyle := optionalStyle(styles, 1)
148149 for y := range s.Height {
149150 src := m.ViewportTop + y
150151 if src >= len(s.Rows) {
@@ -155,17 +156,42 @@ func (m *Mode) Render(s Snapshot) renderer.Frame {
155156 lineSelected := selected && src >= lo && src <= hi
156157 if lineSelected {
157158 for x := range row {
158- row[x].Style.Inverse = true
159+ applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle)
159160 }
160161 }
161162 if src == m.Cursor && !lineSelected && len(row) > 0 {
162- row[0].Style.Inverse = true
163+ applySelectionStyle(&row[0].Style, selectionStyle, hasSelectionStyle)
163164 }
164165 }
165- drawCopyStatus(frame.Row(s.Height), m, len(s.Rows))
166+ style := inverseStyle()
167+ if len(styles) > 0 {
168+ style = styles[0]
169+ }
170+ drawCopyStatus(frame.Row(s.Height), m, len(s.Rows), style)
166171 return frame
167172 }
168173
174+func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
175+ if idx >= len(styles) {
176+ return renderer.Style{}, false
177+ }
178+ return styles[idx], true
179+}
180+
181+func applySelectionStyle(dst *renderer.Style, selection renderer.Style, ok bool) {
182+ if !ok || selection.Equal(inverseStyle()) {
183+ dst.Inverse = true
184+ return
185+ }
186+ *dst = selection
187+}
188+
189+func inverseStyle() renderer.Style {
190+ style := renderer.DefaultStyle()
191+ style.Inverse = true
192+ return style
193+}
194+
169195 func rowString(row []renderer.Cell) string {
170196 runes := make([]rune, 0, len(row))
171197 for _, c := range row {
@@ -181,7 +207,7 @@ func rowString(row []renderer.Cell) string {
181207 return strings.TrimRight(string(runes), " ")
182208 }
183209
184-func drawCopyStatus(row []renderer.Cell, m *Mode, total int) {
210+func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {
185211 for i := range row {
186212 row[i] = renderer.BlankCell()
187213 }
@@ -194,8 +220,6 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int) {
194220 } else {
195221 text += "0/0 "
196222 }
197- style := renderer.DefaultStyle()
198- style.Inverse = true
199223 for i, r := range text {
200224 if i >= len(row) {
201225 break
@@ -141,10 +141,11 @@ func (m *Mode) SelectedText(s Snapshot) string {
141 return strings.Join(lines, "\n")141 return strings.Join(lines, "\n")
142 }142 }
143 143
144-func (m *Mode) Render(s Snapshot) renderer.Frame {144+func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
145 m.clamp(s)145 m.clamp(s)
146 frame := renderer.NewFrame(s.Width, s.Height+1)146 frame := renderer.NewFrame(s.Width, s.Height+1)
147 lo, hi, selected := m.SelectedBounds()147 lo, hi, selected := m.SelectedBounds()
148+ selectionStyle, hasSelectionStyle := optionalStyle(styles, 1)
148 for y := range s.Height {149 for y := range s.Height {
149 src := m.ViewportTop + y150 src := m.ViewportTop + y
150 if src >= len(s.Rows) {151 if src >= len(s.Rows) {
@@ -155,17 +156,42 @@ func (m *Mode) Render(s Snapshot) renderer.Frame {
155 lineSelected := selected && src >= lo && src <= hi156 lineSelected := selected && src >= lo && src <= hi
156 if lineSelected {157 if lineSelected {
157 for x := range row {158 for x := range row {
158- row[x].Style.Inverse = true159+ applySelectionStyle(&row[x].Style, selectionStyle, hasSelectionStyle)
159 }160 }
160 }161 }
161 if src == m.Cursor && !lineSelected && len(row) > 0 {162 if src == m.Cursor && !lineSelected && len(row) > 0 {
162- row[0].Style.Inverse = true163+ applySelectionStyle(&row[0].Style, selectionStyle, hasSelectionStyle)
163 }164 }
164 }165 }
165- drawCopyStatus(frame.Row(s.Height), m, len(s.Rows))166+ style := inverseStyle()
167+ if len(styles) > 0 {
168+ style = styles[0]
169+ }
170+ drawCopyStatus(frame.Row(s.Height), m, len(s.Rows), style)
166 return frame171 return frame
167 }172 }
168 173
174+func optionalStyle(styles []renderer.Style, idx int) (renderer.Style, bool) {
175+ if idx >= len(styles) {
176+ return renderer.Style{}, false
177+ }
178+ return styles[idx], true
179+}
180+
181+func applySelectionStyle(dst *renderer.Style, selection renderer.Style, ok bool) {
182+ if !ok || selection.Equal(inverseStyle()) {
183+ dst.Inverse = true
184+ return
185+ }
186+ *dst = selection
187+}
188+
189+func inverseStyle() renderer.Style {
190+ style := renderer.DefaultStyle()
191+ style.Inverse = true
192+ return style
193+}
194+
169 func rowString(row []renderer.Cell) string {195 func rowString(row []renderer.Cell) string {
170 runes := make([]rune, 0, len(row))196 runes := make([]rune, 0, len(row))
171 for _, c := range row {197 for _, c := range row {
@@ -181,7 +207,7 @@ func rowString(row []renderer.Cell) string {
181 return strings.TrimRight(string(runes), " ")207 return strings.TrimRight(string(runes), " ")
182 }208 }
183 209
184-func drawCopyStatus(row []renderer.Cell, m *Mode, total int) {210+func drawCopyStatus(row []renderer.Cell, m *Mode, total int, style renderer.Style) {
185 for i := range row {211 for i := range row {
186 row[i] = renderer.BlankCell()212 row[i] = renderer.BlankCell()
187 }213 }
@@ -194,8 +220,6 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int) {
194 } else {220 } else {
195 text += "0/0 "221 text += "0/0 "
196 }222 }
197- style := renderer.DefaultStyle()
198- style.Inverse = true
199 for i, r := range text {223 for i, r := range text {
200 if i >= len(row) {224 if i >= len(row) {
201 break225 break
M internal/usecase/copy/mode_test.go +32 -0
@@ -265,6 +265,38 @@ func TestCopyModeRenderLineCursorMarker(t *testing.T) {
265265 }
266266 }
267267
268+func TestCopyModeSelectionFallbackKeepsAlreadyInverseCellInverse(t *testing.T) {
269+ // A cell that was already rendered inverse by the child program (e.g. its
270+ // own reverse-video attribute) must stay visibly selected: the fallback
271+ // selection style is a solid "force inverse", not an XOR toggle that would
272+ // flip it back to non-inverse and hide the highlight.
273+ rows := [][]renderer.Cell{row("alpha "), row("beta ")}
274+ rows[0][0].Style.Inverse = true
275+ s := Snapshot{Rows: rows, Width: 16, Height: 2}
276+ m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
277+
278+ frame := m.Render(s)
279+
280+ if !frame.At(0, 0).Style.Inverse {
281+ t.Fatalf("already-inverse selected cell became non-inverse; want Inverse=true")
282+ }
283+}
284+
285+func TestCopyModeSelectionUsesProvidedStyle(t *testing.T) {
286+ s := snapshot([]string{"alpha ", "beta "}, 2)
287+ m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
288+ selection := renderer.DefaultStyle()
289+ selection.HasBackgroundRGB = true
290+ selection.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
291+
292+ frame := m.Render(s, renderer.DefaultStyle(), selection)
293+
294+ got := frame.At(0, 0).Style
295+ if got.Inverse || !got.HasBackgroundRGB || got.BackgroundRGB != selection.BackgroundRGB {
296+ t.Fatalf("selected cell style = %+v, want themed selection %+v", got, selection)
297+ }
298+}
299+
268300 func frameText(row []renderer.Cell) string {
269301 var b strings.Builder
270302 for _, c := range row {
@@ -265,6 +265,38 @@ func TestCopyModeRenderLineCursorMarker(t *testing.T) {
265 }265 }
266 }266 }
267 267
268+func TestCopyModeSelectionFallbackKeepsAlreadyInverseCellInverse(t *testing.T) {
269+ // A cell that was already rendered inverse by the child program (e.g. its
270+ // own reverse-video attribute) must stay visibly selected: the fallback
271+ // selection style is a solid "force inverse", not an XOR toggle that would
272+ // flip it back to non-inverse and hide the highlight.
273+ rows := [][]renderer.Cell{row("alpha "), row("beta ")}
274+ rows[0][0].Style.Inverse = true
275+ s := Snapshot{Rows: rows, Width: 16, Height: 2}
276+ m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
277+
278+ frame := m.Render(s)
279+
280+ if !frame.At(0, 0).Style.Inverse {
281+ t.Fatalf("already-inverse selected cell became non-inverse; want Inverse=true")
282+ }
283+}
284+
285+func TestCopyModeSelectionUsesProvidedStyle(t *testing.T) {
286+ s := snapshot([]string{"alpha ", "beta "}, 2)
287+ m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
288+ selection := renderer.DefaultStyle()
289+ selection.HasBackgroundRGB = true
290+ selection.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
291+
292+ frame := m.Render(s, renderer.DefaultStyle(), selection)
293+
294+ got := frame.At(0, 0).Style
295+ if got.Inverse || !got.HasBackgroundRGB || got.BackgroundRGB != selection.BackgroundRGB {
296+ t.Fatalf("selected cell style = %+v, want themed selection %+v", got, selection)
297+ }
298+}
299+
268 func frameText(row []renderer.Cell) string {300 func frameText(row []renderer.Cell) string {
269 var b strings.Builder301 var b strings.Builder
270 for _, c := range row {302 for _, c := range row {
M internal/usecase/daemon/client.go +81 -0
@@ -38,6 +38,7 @@ import (
3838 "github.com/bnema/vev/internal/usecase/palette"
3939 "github.com/bnema/vev/internal/usecase/picker"
4040 promptui "github.com/bnema/vev/internal/usecase/prompt"
41+ themeui "github.com/bnema/vev/internal/usecase/theme"
4142 "github.com/bnema/vev/pkg/renderer"
4243 )
4344
@@ -71,6 +72,8 @@ type attachedClient struct {
7172 promptSubmit func(string) error
7273 promptPending []byte
7374 mouseScan mouse.Scanner
75+ themeMu sync.Mutex
76+ theme themeui.Theme
7477 lastCursor cursorOut
7578 sendMu sync.Mutex
7679 }
@@ -117,6 +120,18 @@ func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
117120
118121 func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
119122
123+func (ac *attachedClient) getTheme() themeui.Theme {
124+ ac.themeMu.Lock()
125+ defer ac.themeMu.Unlock()
126+ return ac.theme
127+}
128+
129+func (ac *attachedClient) setTheme(t themeui.Theme) {
130+ ac.themeMu.Lock()
131+ ac.theme = t
132+ ac.themeMu.Unlock()
133+}
134+
120135 func (ac *attachedClient) copyModeActive() bool {
121136 ac.copyMu.Lock()
122137 defer ac.copyMu.Unlock()
@@ -234,10 +249,42 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
234249 sess.mu.Lock()
235250 old := sess.client
236251 sess.client = ac
252+ tabs := append([]*tab(nil), sess.tabs...)
237253 sess.mu.Unlock()
254+ for _, tb := range tabs {
255+ tb.mu.Lock()
256+ tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
257+ tb.mu.Unlock()
258+ }
238259 return ac, old
239260 }
240261
262+// resetScreenDefaultColors clears the known default foreground/background
263+// colors on every tab in sess. Called once a client has been detached, this
264+// makes child OSC 10/11 queries go back to being swallowed (Known=false)
265+// instead of being answered with the departed client's colors, which the
266+// next client (with a different terminal theme) may never have reported.
267+// Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu,
268+// release it, then take each tb.mu in turn — never holding sess.mu and
269+// tb.mu together. Guarded against a race with a newer attach: if sess.client
270+// is non-nil by the time sess.mu is taken, a new client has already attached
271+// (and run its own attach-time reset), so this call must leave the tabs
272+// alone rather than clobbering that client's freshly applied colors.
273+func (d *Daemon) resetScreenDefaultColors(sess *session) {
274+ sess.mu.Lock()
275+ if sess.client != nil {
276+ sess.mu.Unlock()
277+ return
278+ }
279+ tabs := append([]*tab(nil), sess.tabs...)
280+ sess.mu.Unlock()
281+ for _, tb := range tabs {
282+ tb.mu.Lock()
283+ tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
284+ tb.mu.Unlock()
285+ }
286+}
287+
241288 func (d *Daemon) detachReplacedClient(old *attachedClient) {
242289 if old == nil {
243290 return
@@ -294,6 +341,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
294341 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
295342 d.resize(sess, ac, rz.Size)
296343 }
344+ case ports.MsgTheme:
345+ if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
346+ d.applyTheme(sess, ac, th)
347+ }
297348 case ports.MsgDetach:
298349 d.clientGone(sess, ac, true)
299350 return
@@ -314,6 +365,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
314365 return // already displaced by a newer client; nothing to do
315366 }
316367 d.unregisterPreview(ac)
368+ d.resetScreenDefaultColors(sess)
317369 if explicit {
318370 // Synchronous so the ack is delivered before the transport closes
319371 // (the client is actively awaiting it), but deadline-bounded so a
@@ -329,6 +381,34 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
329381
330382 // detachIfCurrent clears the client iff ac is the current one, reporting
331383
384+func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
385+ t := themeui.Theme{
386+ Foreground: msg.Foreground,
387+ Background: msg.Background,
388+ HasFG: msg.HasForeground,
389+ HasBG: msg.HasBackground,
390+ TrueColor: msg.TrueColor,
391+ Known: msg.HasForeground && msg.HasBackground,
392+ }
393+
394+ knownDefaultColors := t.HasFG && t.HasBG
395+ sess.mu.Lock()
396+ if sess.client != ac {
397+ sess.mu.Unlock()
398+ return
399+ }
400+ tabs := append([]*tab(nil), sess.tabs...)
401+ sess.mu.Unlock()
402+
403+ ac.setTheme(t)
404+ for _, tb := range tabs {
405+ tb.mu.Lock()
406+ tb.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors)
407+ tb.mu.Unlock()
408+ }
409+ d.paint(sess, ac, true)
410+}
411+
332412 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
333413 if !sz.Valid() {
334414 return
@@ -361,6 +441,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
361441 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
362442 if sess.detachIfCurrent(ac) {
363443 d.unregisterPreview(ac)
444+ d.resetScreenDefaultColors(sess)
364445 _ = ac.tr.Close()
365446 d.log.Warn("detached client after send error", "session", sess.name)
366447 if sess.ephemeral {
@@ -38,6 +38,7 @@ import (
38 "github.com/bnema/vev/internal/usecase/palette"38 "github.com/bnema/vev/internal/usecase/palette"
39 "github.com/bnema/vev/internal/usecase/picker"39 "github.com/bnema/vev/internal/usecase/picker"
40 promptui "github.com/bnema/vev/internal/usecase/prompt"40 promptui "github.com/bnema/vev/internal/usecase/prompt"
41+ themeui "github.com/bnema/vev/internal/usecase/theme"
41 "github.com/bnema/vev/pkg/renderer"42 "github.com/bnema/vev/pkg/renderer"
42 )43 )
43 44
@@ -71,6 +72,8 @@ type attachedClient struct {
71 promptSubmit func(string) error72 promptSubmit func(string) error
72 promptPending []byte73 promptPending []byte
73 mouseScan mouse.Scanner74 mouseScan mouse.Scanner
75+ themeMu sync.Mutex
76+ theme themeui.Theme
74 lastCursor cursorOut77 lastCursor cursorOut
75 sendMu sync.Mutex78 sendMu sync.Mutex
76 }79 }
@@ -117,6 +120,18 @@ func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
117 120
118 func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }121 func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
119 122
123+func (ac *attachedClient) getTheme() themeui.Theme {
124+ ac.themeMu.Lock()
125+ defer ac.themeMu.Unlock()
126+ return ac.theme
127+}
128+
129+func (ac *attachedClient) setTheme(t themeui.Theme) {
130+ ac.themeMu.Lock()
131+ ac.theme = t
132+ ac.themeMu.Unlock()
133+}
134+
120 func (ac *attachedClient) copyModeActive() bool {135 func (ac *attachedClient) copyModeActive() bool {
121 ac.copyMu.Lock()136 ac.copyMu.Lock()
122 defer ac.copyMu.Unlock()137 defer ac.copyMu.Unlock()
@@ -234,10 +249,42 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
234 sess.mu.Lock()249 sess.mu.Lock()
235 old := sess.client250 old := sess.client
236 sess.client = ac251 sess.client = ac
252+ tabs := append([]*tab(nil), sess.tabs...)
237 sess.mu.Unlock()253 sess.mu.Unlock()
254+ for _, tb := range tabs {
255+ tb.mu.Lock()
256+ tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
257+ tb.mu.Unlock()
258+ }
238 return ac, old259 return ac, old
239 }260 }
240 261
262+// resetScreenDefaultColors clears the known default foreground/background
263+// colors on every tab in sess. Called once a client has been detached, this
264+// makes child OSC 10/11 queries go back to being swallowed (Known=false)
265+// instead of being answered with the departed client's colors, which the
266+// next client (with a different terminal theme) may never have reported.
267+// Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu,
268+// release it, then take each tb.mu in turn — never holding sess.mu and
269+// tb.mu together. Guarded against a race with a newer attach: if sess.client
270+// is non-nil by the time sess.mu is taken, a new client has already attached
271+// (and run its own attach-time reset), so this call must leave the tabs
272+// alone rather than clobbering that client's freshly applied colors.
273+func (d *Daemon) resetScreenDefaultColors(sess *session) {
274+ sess.mu.Lock()
275+ if sess.client != nil {
276+ sess.mu.Unlock()
277+ return
278+ }
279+ tabs := append([]*tab(nil), sess.tabs...)
280+ sess.mu.Unlock()
281+ for _, tb := range tabs {
282+ tb.mu.Lock()
283+ tb.screen.SetDefaultColors(renderer.RGB{}, renderer.RGB{}, false)
284+ tb.mu.Unlock()
285+ }
286+}
287+
241 func (d *Daemon) detachReplacedClient(old *attachedClient) {288 func (d *Daemon) detachReplacedClient(old *attachedClient) {
242 if old == nil {289 if old == nil {
243 return290 return
@@ -294,6 +341,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
294 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {341 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
295 d.resize(sess, ac, rz.Size)342 d.resize(sess, ac, rz.Size)
296 }343 }
344+ case ports.MsgTheme:
345+ if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
346+ d.applyTheme(sess, ac, th)
347+ }
297 case ports.MsgDetach:348 case ports.MsgDetach:
298 d.clientGone(sess, ac, true)349 d.clientGone(sess, ac, true)
299 return350 return
@@ -314,6 +365,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
314 return // already displaced by a newer client; nothing to do365 return // already displaced by a newer client; nothing to do
315 }366 }
316 d.unregisterPreview(ac)367 d.unregisterPreview(ac)
368+ d.resetScreenDefaultColors(sess)
317 if explicit {369 if explicit {
318 // Synchronous so the ack is delivered before the transport closes370 // Synchronous so the ack is delivered before the transport closes
319 // (the client is actively awaiting it), but deadline-bounded so a371 // (the client is actively awaiting it), but deadline-bounded so a
@@ -329,6 +381,34 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
329 381
330 // detachIfCurrent clears the client iff ac is the current one, reporting382 // detachIfCurrent clears the client iff ac is the current one, reporting
331 383
384+func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
385+ t := themeui.Theme{
386+ Foreground: msg.Foreground,
387+ Background: msg.Background,
388+ HasFG: msg.HasForeground,
389+ HasBG: msg.HasBackground,
390+ TrueColor: msg.TrueColor,
391+ Known: msg.HasForeground && msg.HasBackground,
392+ }
393+
394+ knownDefaultColors := t.HasFG && t.HasBG
395+ sess.mu.Lock()
396+ if sess.client != ac {
397+ sess.mu.Unlock()
398+ return
399+ }
400+ tabs := append([]*tab(nil), sess.tabs...)
401+ sess.mu.Unlock()
402+
403+ ac.setTheme(t)
404+ for _, tb := range tabs {
405+ tb.mu.Lock()
406+ tb.screen.SetDefaultColors(t.Foreground, t.Background, knownDefaultColors)
407+ tb.mu.Unlock()
408+ }
409+ d.paint(sess, ac, true)
410+}
411+
332 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {412 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
333 if !sz.Valid() {413 if !sz.Valid() {
334 return414 return
@@ -361,6 +441,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
361 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {441 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
362 if sess.detachIfCurrent(ac) {442 if sess.detachIfCurrent(ac) {
363 d.unregisterPreview(ac)443 d.unregisterPreview(ac)
444+ d.resetScreenDefaultColors(sess)
364 _ = ac.tr.Close()445 _ = ac.tr.Close()
365 d.log.Warn("detached client after send error", "session", sess.name)446 d.log.Warn("detached client after send error", "session", sess.name)
366 if sess.ephemeral {447 if sess.ephemeral {
M internal/usecase/daemon/copymode.go +2 -2
@@ -307,8 +307,8 @@ func isCopyEscapePrefix(data []byte) bool {
307307 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
308308 }
309309
310-func composeCopyClientFrame(mode *scopy.Mode, tb *tab) (renderer.Frame, []renderer.Damage) {
310+func composeCopyClientFrame(mode *scopy.Mode, tb *tab, copyStatusStyle, selectionStyle renderer.Style) (renderer.Frame, []renderer.Damage) {
311311 snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
312- frame := mode.Render(snap)
312+ frame := mode.Render(snap, copyStatusStyle, selectionStyle)
313313 return frame, []renderer.Damage{renderer.FullRedraw()}
314314 }
@@ -307,8 +307,8 @@ func isCopyEscapePrefix(data []byte) bool {
307 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')307 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
308 }308 }
309 309
310-func composeCopyClientFrame(mode *scopy.Mode, tb *tab) (renderer.Frame, []renderer.Damage) {310+func composeCopyClientFrame(mode *scopy.Mode, tb *tab, copyStatusStyle, selectionStyle renderer.Style) (renderer.Frame, []renderer.Damage) {
311 snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)311 snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
312- frame := mode.Render(snap)312+ frame := mode.Render(snap, copyStatusStyle, selectionStyle)
313 return frame, []renderer.Damage{renderer.FullRedraw()}313 return frame, []renderer.Damage{renderer.FullRedraw()}
314 }314 }
M internal/usecase/daemon/palette.go +4 -3
@@ -145,9 +145,10 @@ func (e paletteExec) OpenSessionPicker() error {
145145 return nil
146146 }
147147
148-func composePaletteClientFrame(model *palette.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
149- inner := paletteModal.Composite(base, renderer.DefaultStyle())
150- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height})
148+func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
149+ styleSet := resolveThemeStyles(styles)
150+ inner := paletteModal.Composite(base, styleSet.border)
151+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.selection)
151152 for y := range min(inner.Height, modalFrame.Height) {
152153 for x := range min(inner.Width, modalFrame.Width) {
153154 base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
@@ -145,9 +145,10 @@ func (e paletteExec) OpenSessionPicker() error {
145 return nil145 return nil
146 }146 }
147 147
148-func composePaletteClientFrame(model *palette.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) {148+func composePaletteClientFrame(model *palette.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
149- inner := paletteModal.Composite(base, renderer.DefaultStyle())149+ styleSet := resolveThemeStyles(styles)
150- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height})150+ inner := paletteModal.Composite(base, styleSet.border)
151+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.selection)
151 for y := range min(inner.Height, modalFrame.Height) {152 for y := range min(inner.Height, modalFrame.Height) {
152 for x := range min(inner.Width, modalFrame.Width) {153 for x := range min(inner.Width, modalFrame.Width) {
153 base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))154 base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
M internal/usecase/daemon/picker.go +4 -3
@@ -363,9 +363,10 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
363363 return old
364364 }
365365
366-func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
367- inner := pickerModal.Composite(base, renderer.DefaultStyle())
368- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview)
366+func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
367+ styleSet := resolveThemeStyles(styles)
368+ inner := pickerModal.Composite(base, styleSet.border)
369+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview, styleSet.selection)
369370 for y := range min(inner.Height, modalFrame.Height) {
370371 for x := range min(inner.Width, modalFrame.Width) {
371372 base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
@@ -363,9 +363,10 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
363 return old363 return old
364 }364 }
365 365
366-func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame) (renderer.Frame, []renderer.Damage) {366+func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
367- inner := pickerModal.Composite(base, renderer.DefaultStyle())367+ styleSet := resolveThemeStyles(styles)
368- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview)368+ inner := pickerModal.Composite(base, styleSet.border)
369+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview, styleSet.selection)
369 for y := range min(inner.Height, modalFrame.Height) {370 for y := range min(inner.Height, modalFrame.Height) {
370 for x := range min(inner.Width, modalFrame.Width) {371 for x := range min(inner.Width, modalFrame.Width) {
371 base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))372 base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
M internal/usecase/daemon/prompt.go +4 -3
@@ -92,9 +92,10 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
9292 }
9393 }
9494
95-func composePromptClientFrame(model *promptui.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
96- inner := promptModalFor(model.Title()).Composite(base, renderer.DefaultStyle())
97- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height})
95+func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
96+ styleSet := resolveThemeStyles(styles)
97+ inner := promptModalFor(model.Title()).Composite(base, styleSet.border)
98+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.accent)
9899 for y := range min(inner.Height, modalFrame.Height) {
99100 for x := range min(inner.Width, modalFrame.Width) {
100101 base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
@@ -92,9 +92,10 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
92 }92 }
93 }93 }
94 94
95-func composePromptClientFrame(model *promptui.Model, base renderer.Frame) (renderer.Frame, []renderer.Damage) {95+func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
96- inner := promptModalFor(model.Title()).Composite(base, renderer.DefaultStyle())96+ styleSet := resolveThemeStyles(styles)
97- modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height})97+ inner := promptModalFor(model.Title()).Composite(base, styleSet.border)
98+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, styleSet.accent)
98 for y := range min(inner.Height, modalFrame.Height) {99 for y := range min(inner.Height, modalFrame.Height) {
99 for x := range min(inner.Width, modalFrame.Width) {100 for x := range min(inner.Width, modalFrame.Width) {
100 base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))101 base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
M internal/usecase/daemon/render.go +36 -7
@@ -31,6 +31,7 @@ import (
3131 "time"
3232
3333 scopy "github.com/bnema/vev/internal/usecase/copy"
34+ themeui "github.com/bnema/vev/internal/usecase/theme"
3435 "github.com/bnema/vev/pkg/renderer"
3536 "github.com/bnema/vev/pkg/vt"
3637 )
@@ -273,6 +274,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
273274 ac.promptMu.Unlock()
274275 }
275276
277+ styles := newThemeStyles(ac.getTheme())
278+
276279 tb.mu.Lock()
277280 if reset || copyActive || pickerActive || paletteActive || promptActive {
278281 ac.rend.Reset()
@@ -280,22 +283,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
280283 if reset || pickerActive || paletteActive || promptActive {
281284 ac.lastCursor.valid = false
282285 }
283- frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)
286+ frame, damage := composeClientFrame(sess, tb, reset, copyFeedback, styles)
284287 if copyActive {
285- frame, damage = composeCopyClientFrame(copyMode, tb)
288+ frame, damage = composeCopyClientFrame(copyMode, tb, styles.copyStatus, styles.selection)
286289 }
287290 if pickerActive {
288291 if previewTab == tb {
289292 preview = pickerPreviewFromLockedTab(tb)
290293 }
291- frame, damage = composePickerClientFrame(pickerModel, preview, frame)
294+ frame, damage = composePickerClientFrame(pickerModel, preview, frame, styles)
292295 }
293296 if paletteActive {
294- frame, damage = composePaletteClientFrame(paletteModel, frame)
297+ frame, damage = composePaletteClientFrame(paletteModel, frame, styles)
295298 ac.paletteMu.Unlock()
296299 }
297300 if promptActive {
298- frame, damage = composePromptClientFrame(promptModel, frame)
301+ frame, damage = composePromptClientFrame(promptModel, frame, styles)
299302 ac.promptMu.Unlock()
300303 }
301304 desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive || promptActive)
@@ -364,13 +367,39 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte
364367 return b
365368 }
366369
367-func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string) (renderer.Frame, []renderer.Damage) {
370+type themeStyles struct {
371+ statusBar renderer.Style
372+ accent renderer.Style
373+ border renderer.Style
374+ selection renderer.Style
375+ copyStatus renderer.Style
376+}
377+
378+func newThemeStyles(t themeui.Theme) themeStyles {
379+ return themeStyles{
380+ statusBar: themeui.StatusBarStyle(t),
381+ accent: themeui.AccentStyle(t),
382+ border: themeui.BorderStyle(t),
383+ selection: themeui.SelectionStyle(t),
384+ copyStatus: themeui.SelectionStyle(t),
385+ }
386+}
387+
388+func resolveThemeStyles(styles []themeStyles) themeStyles {
389+ if len(styles) > 0 {
390+ return styles[0]
391+ }
392+ return newThemeStyles(themeui.Theme{})
393+}
394+
395+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
368396 width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
369397 frame := renderer.NewFrame(width, screenRows+1)
370398 for y := range screenRows {
371399 copy(frame.Row(y), tb.screen.Frame.Row(y))
372400 }
373- drawStatus(frame.Row(screenRows), sess, rightStatus)
401+ styleSet := resolveThemeStyles(styles)
402+ drawStatus(frame.Row(screenRows), sess, rightStatus, styleSet)
374403 if full {
375404 return frame, []renderer.Damage{renderer.FullRedraw()}
376405 }
@@ -31,6 +31,7 @@ import (
31 "time"31 "time"
32 32
33 scopy "github.com/bnema/vev/internal/usecase/copy"33 scopy "github.com/bnema/vev/internal/usecase/copy"
34+ themeui "github.com/bnema/vev/internal/usecase/theme"
34 "github.com/bnema/vev/pkg/renderer"35 "github.com/bnema/vev/pkg/renderer"
35 "github.com/bnema/vev/pkg/vt"36 "github.com/bnema/vev/pkg/vt"
36 )37 )
@@ -273,6 +274,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
273 ac.promptMu.Unlock()274 ac.promptMu.Unlock()
274 }275 }
275 276
277+ styles := newThemeStyles(ac.getTheme())
278+
276 tb.mu.Lock()279 tb.mu.Lock()
277 if reset || copyActive || pickerActive || paletteActive || promptActive {280 if reset || copyActive || pickerActive || paletteActive || promptActive {
278 ac.rend.Reset()281 ac.rend.Reset()
@@ -280,22 +283,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
280 if reset || pickerActive || paletteActive || promptActive {283 if reset || pickerActive || paletteActive || promptActive {
281 ac.lastCursor.valid = false284 ac.lastCursor.valid = false
282 }285 }
283- frame, damage := composeClientFrame(sess, tb, reset, copyFeedback)286+ frame, damage := composeClientFrame(sess, tb, reset, copyFeedback, styles)
284 if copyActive {287 if copyActive {
285- frame, damage = composeCopyClientFrame(copyMode, tb)288+ frame, damage = composeCopyClientFrame(copyMode, tb, styles.copyStatus, styles.selection)
286 }289 }
287 if pickerActive {290 if pickerActive {
288 if previewTab == tb {291 if previewTab == tb {
289 preview = pickerPreviewFromLockedTab(tb)292 preview = pickerPreviewFromLockedTab(tb)
290 }293 }
291- frame, damage = composePickerClientFrame(pickerModel, preview, frame)294+ frame, damage = composePickerClientFrame(pickerModel, preview, frame, styles)
292 }295 }
293 if paletteActive {296 if paletteActive {
294- frame, damage = composePaletteClientFrame(paletteModel, frame)297+ frame, damage = composePaletteClientFrame(paletteModel, frame, styles)
295 ac.paletteMu.Unlock()298 ac.paletteMu.Unlock()
296 }299 }
297 if promptActive {300 if promptActive {
298- frame, damage = composePromptClientFrame(promptModel, frame)301+ frame, damage = composePromptClientFrame(promptModel, frame, styles)
299 ac.promptMu.Unlock()302 ac.promptMu.Unlock()
300 }303 }
301 desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive || promptActive)304 desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive || paletteActive || promptActive)
@@ -364,13 +367,39 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte
364 return b367 return b
365 }368 }
366 369
367-func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string) (renderer.Frame, []renderer.Damage) {370+type themeStyles struct {
371+ statusBar renderer.Style
372+ accent renderer.Style
373+ border renderer.Style
374+ selection renderer.Style
375+ copyStatus renderer.Style
376+}
377+
378+func newThemeStyles(t themeui.Theme) themeStyles {
379+ return themeStyles{
380+ statusBar: themeui.StatusBarStyle(t),
381+ accent: themeui.AccentStyle(t),
382+ border: themeui.BorderStyle(t),
383+ selection: themeui.SelectionStyle(t),
384+ copyStatus: themeui.SelectionStyle(t),
385+ }
386+}
387+
388+func resolveThemeStyles(styles []themeStyles) themeStyles {
389+ if len(styles) > 0 {
390+ return styles[0]
391+ }
392+ return newThemeStyles(themeui.Theme{})
393+}
394+
395+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
368 width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height396 width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
369 frame := renderer.NewFrame(width, screenRows+1)397 frame := renderer.NewFrame(width, screenRows+1)
370 for y := range screenRows {398 for y := range screenRows {
371 copy(frame.Row(y), tb.screen.Frame.Row(y))399 copy(frame.Row(y), tb.screen.Frame.Row(y))
372 }400 }
373- drawStatus(frame.Row(screenRows), sess, rightStatus)401+ styleSet := resolveThemeStyles(styles)
402+ drawStatus(frame.Row(screenRows), sess, rightStatus, styleSet)
374 if full {403 if full {
375 return frame, []renderer.Damage{renderer.FullRedraw()}404 return frame, []renderer.Damage{renderer.FullRedraw()}
376 }405 }
M internal/usecase/daemon/session.go +5 -0
@@ -110,12 +110,17 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
110110 sess.mu.Lock()
111111 name := sess.name
112112 cwd := sess.cwd
113+ client := sess.client
113114 sess.mu.Unlock()
114115 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
115116 if err != nil {
116117 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
117118 }
118119 tb := newTab(pty, tbSize)
120+ if client != nil {
121+ t := client.getTheme()
122+ tb.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG)
123+ }
119124 d.mu.Lock()
120125 if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil {
121126 d.mu.Unlock()
@@ -110,12 +110,17 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
110 sess.mu.Lock()110 sess.mu.Lock()
111 name := sess.name111 name := sess.name
112 cwd := sess.cwd112 cwd := sess.cwd
113+ client := sess.client
113 sess.mu.Unlock()114 sess.mu.Unlock()
114 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)115 pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
115 if err != nil {116 if err != nil {
116 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)117 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
117 }118 }
118 tb := newTab(pty, tbSize)119 tb := newTab(pty, tbSize)
120+ if client != nil {
121+ t := client.getTheme()
122+ tb.screen.SetDefaultColors(t.Foreground, t.Background, t.HasFG && t.HasBG)
123+ }
119 d.mu.Lock()124 d.mu.Lock()
120 if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil {125 if d.closing || d.sessions[sess.id] != sess || sess.ctx.Err() != nil {
121 d.mu.Unlock()126 d.mu.Unlock()
M internal/usecase/daemon/status.go +6 -5
@@ -31,24 +31,25 @@ import (
3131 "github.com/bnema/vev/pkg/renderer"
3232 )
3333
34-func drawStatus(row []renderer.Cell, sess *session, rightText string) {
34+func drawStatus(row []renderer.Cell, sess *session, rightText string, styles ...themeStyles) {
3535 for i := range row {
3636 row[i] = renderer.BlankCell()
3737 }
38+ styleSet := resolveThemeStyles(styles)
3839 status := sess.statusSegments()
3940 x := 0
40- writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle())
41+ writeStatusText(row, &x, " "+status.session+" ", styleSet.statusBar)
4142 for _, w := range status.tabs {
42- style := renderer.DefaultStyle()
43+ style := styleSet.statusBar
4344 if w.active {
44- style.Inverse = true
45+ style = styleSet.accent
4546 }
4647 writeStatusText(row, &x, " "+w.name+" ", style)
4748 }
4849 if rightText == "" {
4950 return
5051 }
51- style := renderer.DefaultStyle()
52+ style := styleSet.statusBar
5253 rightWidth := len([]rune(rightText)) + 1
5354 if len(row)-x-1 < rightWidth {
5455 return
@@ -31,24 +31,25 @@ import (
31 "github.com/bnema/vev/pkg/renderer"31 "github.com/bnema/vev/pkg/renderer"
32 )32 )
33 33
34-func drawStatus(row []renderer.Cell, sess *session, rightText string) {34+func drawStatus(row []renderer.Cell, sess *session, rightText string, styles ...themeStyles) {
35 for i := range row {35 for i := range row {
36 row[i] = renderer.BlankCell()36 row[i] = renderer.BlankCell()
37 }37 }
38+ styleSet := resolveThemeStyles(styles)
38 status := sess.statusSegments()39 status := sess.statusSegments()
39 x := 040 x := 0
40- writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle())41+ writeStatusText(row, &x, " "+status.session+" ", styleSet.statusBar)
41 for _, w := range status.tabs {42 for _, w := range status.tabs {
42- style := renderer.DefaultStyle()43+ style := styleSet.statusBar
43 if w.active {44 if w.active {
44- style.Inverse = true45+ style = styleSet.accent
45 }46 }
46 writeStatusText(row, &x, " "+w.name+" ", style)47 writeStatusText(row, &x, " "+w.name+" ", style)
47 }48 }
48 if rightText == "" {49 if rightText == "" {
49 return50 return
50 }51 }
51- style := renderer.DefaultStyle()52+ style := styleSet.statusBar
52 rightWidth := len([]rune(rightText)) + 153 rightWidth := len([]rune(rightText)) + 1
53 if len(row)-x-1 < rightWidth {54 if len(row)-x-1 < rightWidth {
54 return55 return
M internal/usecase/daemon/status_test.go +159 -0
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "strings"
45 "sync"
56 "testing"
67
@@ -8,6 +9,7 @@ import (
89
910 "github.com/bnema/vev/internal/domain"
1011 "github.com/bnema/vev/internal/ports"
12+ themeui "github.com/bnema/vev/internal/usecase/theme"
1113 "github.com/bnema/vev/pkg/renderer"
1214 "github.com/bnema/vev/pkg/vt"
1315 )
@@ -45,6 +47,163 @@ func TestStatusCompositionGolden(t *testing.T) {
4547 }
4648 }
4749
50+func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
51+ p, release := newBlockingPTY(t)
52+ _, sess, ac, _ := newManualSessionWithPTYs(t, p)
53+ defer release()
54+ win := sess.activeTab()
55+ win.screen = vt.NewScreen(12, 2)
56+ win.size = domain.Size{Cols: 12, Rows: 2}
57+ ac.setTheme(themeui.Theme{
58+ Foreground: renderer.RGB{R: 220, G: 220, B: 220},
59+ Background: renderer.RGB{R: 10, G: 20, B: 30},
60+ HasFG: true,
61+ HasBG: true,
62+ TrueColor: true,
63+ Known: true,
64+ })
65+
66+ d := newThemeStyles(ac.getTheme())
67+ frame, damage := composeClientFrame(sess, win, true, "", d)
68+ out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage)
69+
70+ require.NoError(t, err)
71+ require.Contains(t, string(out), ";48;2;")
72+}
73+
74+func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) {
75+ p1, releasePTY1 := newBlockingPTY(t)
76+ p2, releasePTY2 := newBlockingPTY(t)
77+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
78+ defer releasePTY1()
79+ defer releasePTY2()
80+ msg := ports.Theme{HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}}
81+
82+ d.applyTheme(sess, ac, msg)
83+
84+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground)
85+ for _, tb := range sess.tabs {
86+ var got []byte
87+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
88+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
89+ require.True(t, strings.Contains(string(got), "rgb:0101/0202/0303"), string(got))
90+ require.True(t, strings.Contains(string(got), "rgb:0404/0505/0606"), string(got))
91+ }
92+}
93+
94+func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
95+ p, release := newBlockingPTY(t)
96+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
97+ defer release()
98+ d.applyTheme(sess, ac, ports.Theme{
99+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
100+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
101+ })
102+ tr, _ := newCapturingTransport(t)
103+
104+ d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24})
105+
106+ var got []byte
107+ tb := sess.activeTab()
108+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
109+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
110+ require.Empty(t, got)
111+}
112+
113+func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
114+ p, release := newBlockingPTY(t)
115+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
116+ defer release()
117+ d.applyTheme(sess, ac, ports.Theme{
118+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
119+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
120+ })
121+
122+ // Sanity: the tab answers OSC 10/11 while ac is attached.
123+ tb := sess.activeTab()
124+ var before []byte
125+ tb.screen.OnResponse = func(b []byte) { before = append(before, b...) }
126+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
127+ require.NotEmpty(t, before)
128+
129+ d.clientGone(sess, ac, true)
130+
131+ var got []byte
132+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
133+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
134+ require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
135+}
136+
137+func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
138+ p, release := newBlockingPTY(t)
139+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
140+ defer release()
141+ d.applyTheme(sess, ac, ports.Theme{
142+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
143+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
144+ })
145+
146+ // Simulate the race window inside clientGone/detachOnSendError: the old
147+ // client has already been detached (detachIfCurrent succeeded), but
148+ // before resetScreenDefaultColors runs a new client attaches and applies
149+ // its own theme.
150+ require.True(t, sess.detachIfCurrent(ac))
151+
152+ tr, _ := newCapturingTransport(t)
153+ newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24})
154+ d.applyTheme(sess, newAC, ports.Theme{
155+ HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
156+ HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},
157+ })
158+
159+ // The late reset from the old client's detach path must not wipe the
160+ // new client's freshly applied colors.
161+ d.resetScreenDefaultColors(sess)
162+
163+ tb := sess.activeTab()
164+ var got []byte
165+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
166+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
167+ require.NotEmpty(t, got, "a newer client's screen default colors must survive a stale detach's reset")
168+}
169+
170+func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
171+ p, release := newBlockingPTY(t)
172+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
173+ defer release()
174+ d.applyTheme(sess, ac, ports.Theme{
175+ HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
176+ HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
177+ })
178+
179+ d.detachOnSendError(sess, ac)
180+
181+ tb := sess.activeTab()
182+ var got []byte
183+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
184+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
185+ require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
186+}
187+
188+func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
189+ p, release := newBlockingPTY(t)
190+ d, sess, old, _ := newManualSessionWithPTYs(t, p)
191+ defer release()
192+ tr, _ := newCapturingTransport(t)
193+ d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24})
194+
195+ d.applyTheme(sess, old, ports.Theme{
196+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
197+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
198+ })
199+
200+ var got []byte
201+ tb := sess.activeTab()
202+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
203+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
204+ require.Empty(t, got)
205+}
206+
48207 func TestStatusMarksEphemeralSession(t *testing.T) {
49208 p, release := newBlockingPTY(t)
50209 _, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "strings"
4 "sync"5 "sync"
5 "testing"6 "testing"
6 7
@@ -8,6 +9,7 @@ import (
8 9
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
12+ themeui "github.com/bnema/vev/internal/usecase/theme"
11 "github.com/bnema/vev/pkg/renderer"13 "github.com/bnema/vev/pkg/renderer"
12 "github.com/bnema/vev/pkg/vt"14 "github.com/bnema/vev/pkg/vt"
13 )15 )
@@ -45,6 +47,163 @@ func TestStatusCompositionGolden(t *testing.T) {
45 }47 }
46 }48 }
47 49
50+func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
51+ p, release := newBlockingPTY(t)
52+ _, sess, ac, _ := newManualSessionWithPTYs(t, p)
53+ defer release()
54+ win := sess.activeTab()
55+ win.screen = vt.NewScreen(12, 2)
56+ win.size = domain.Size{Cols: 12, Rows: 2}
57+ ac.setTheme(themeui.Theme{
58+ Foreground: renderer.RGB{R: 220, G: 220, B: 220},
59+ Background: renderer.RGB{R: 10, G: 20, B: 30},
60+ HasFG: true,
61+ HasBG: true,
62+ TrueColor: true,
63+ Known: true,
64+ })
65+
66+ d := newThemeStyles(ac.getTheme())
67+ frame, damage := composeClientFrame(sess, win, true, "", d)
68+ out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage)
69+
70+ require.NoError(t, err)
71+ require.Contains(t, string(out), ";48;2;")
72+}
73+
74+func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) {
75+ p1, releasePTY1 := newBlockingPTY(t)
76+ p2, releasePTY2 := newBlockingPTY(t)
77+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
78+ defer releasePTY1()
79+ defer releasePTY2()
80+ msg := ports.Theme{HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6}}
81+
82+ d.applyTheme(sess, ac, msg)
83+
84+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground)
85+ for _, tb := range sess.tabs {
86+ var got []byte
87+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
88+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
89+ require.True(t, strings.Contains(string(got), "rgb:0101/0202/0303"), string(got))
90+ require.True(t, strings.Contains(string(got), "rgb:0404/0505/0606"), string(got))
91+ }
92+}
93+
94+func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
95+ p, release := newBlockingPTY(t)
96+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
97+ defer release()
98+ d.applyTheme(sess, ac, ports.Theme{
99+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
100+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
101+ })
102+ tr, _ := newCapturingTransport(t)
103+
104+ d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24})
105+
106+ var got []byte
107+ tb := sess.activeTab()
108+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
109+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
110+ require.Empty(t, got)
111+}
112+
113+func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
114+ p, release := newBlockingPTY(t)
115+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
116+ defer release()
117+ d.applyTheme(sess, ac, ports.Theme{
118+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
119+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
120+ })
121+
122+ // Sanity: the tab answers OSC 10/11 while ac is attached.
123+ tb := sess.activeTab()
124+ var before []byte
125+ tb.screen.OnResponse = func(b []byte) { before = append(before, b...) }
126+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
127+ require.NotEmpty(t, before)
128+
129+ d.clientGone(sess, ac, true)
130+
131+ var got []byte
132+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
133+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
134+ require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
135+}
136+
137+func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
138+ p, release := newBlockingPTY(t)
139+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
140+ defer release()
141+ d.applyTheme(sess, ac, ports.Theme{
142+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
143+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
144+ })
145+
146+ // Simulate the race window inside clientGone/detachOnSendError: the old
147+ // client has already been detached (detachIfCurrent succeeded), but
148+ // before resetScreenDefaultColors runs a new client attaches and applies
149+ // its own theme.
150+ require.True(t, sess.detachIfCurrent(ac))
151+
152+ tr, _ := newCapturingTransport(t)
153+ newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24})
154+ d.applyTheme(sess, newAC, ports.Theme{
155+ HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
156+ HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},
157+ })
158+
159+ // The late reset from the old client's detach path must not wipe the
160+ // new client's freshly applied colors.
161+ d.resetScreenDefaultColors(sess)
162+
163+ tb := sess.activeTab()
164+ var got []byte
165+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
166+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
167+ require.NotEmpty(t, got, "a newer client's screen default colors must survive a stale detach's reset")
168+}
169+
170+func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
171+ p, release := newBlockingPTY(t)
172+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
173+ defer release()
174+ d.applyTheme(sess, ac, ports.Theme{
175+ HasForeground: true, Foreground: renderer.RGB{R: 7, G: 8, B: 9},
176+ HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
177+ })
178+
179+ d.detachOnSendError(sess, ac)
180+
181+ tb := sess.activeTab()
182+ var got []byte
183+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
184+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
185+ require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
186+}
187+
188+func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
189+ p, release := newBlockingPTY(t)
190+ d, sess, old, _ := newManualSessionWithPTYs(t, p)
191+ defer release()
192+ tr, _ := newCapturingTransport(t)
193+ d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24})
194+
195+ d.applyTheme(sess, old, ports.Theme{
196+ HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
197+ HasBackground: true, Background: renderer.RGB{R: 4, G: 5, B: 6},
198+ })
199+
200+ var got []byte
201+ tb := sess.activeTab()
202+ tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
203+ tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
204+ require.Empty(t, got)
205+}
206+
48 func TestStatusMarksEphemeralSession(t *testing.T) {207 func TestStatusMarksEphemeralSession(t *testing.T) {
49 p, release := newBlockingPTY(t)208 p, release := newBlockingPTY(t)
50 _, sess, _, _ := newManualSessionWithPTYs(t, p)209 _, sess, _, _ := newManualSessionWithPTYs(t, p)
M internal/usecase/palette/model.go +10 -5
@@ -96,18 +96,23 @@ func (m *Model) clamp() {
9696 }
9797 }
9898
99-func (m *Model) Render(inner domain.Size) renderer.Frame {
99+func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
100100 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
101101 if frame.Width == 0 || frame.Height == 0 {
102102 return frame
103103 }
104104 base := renderer.DefaultStyle()
105+ selection := base
106+ selection.Inverse = true
107+ if len(selectedStyle) > 0 {
108+ selection = selectedStyle[0]
109+ }
105110 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
106111 if m == nil {
107- ui.DrawInputLine(frame, 0, "> ", "", base)
112+ ui.DrawInputLine(frame, 0, "> ", "", base, selection)
108113 return frame
109114 }
110- ui.DrawInputLine(frame, 0, "> ", m.Query(), base)
115+ ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
111116 visible := frame.Height - 1
112117 if visible <= 0 {
113118 return frame
@@ -135,7 +140,7 @@ func (m *Model) Render(inner domain.Size) renderer.Frame {
135140 match := m.matches[idx]
136141 style := base
137142 if idx == m.selected {
138- style.Inverse = true
143+ style = selection
139144 }
140145 ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
141146 x := 0
@@ -146,8 +151,8 @@ func (m *Model) Render(inner domain.Size) renderer.Frame {
146151 for i, r := range []rune(match.Command.Code) {
147152 cellStyle := style
148153 if highlight[i] {
154+ cellStyle = selection
149155 cellStyle.Bold = true
150- cellStyle.Inverse = true
151156 }
152157 if x < frame.Width {
153158 frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle})
@@ -96,18 +96,23 @@ func (m *Model) clamp() {
96 }96 }
97 }97 }
98 98
99-func (m *Model) Render(inner domain.Size) renderer.Frame {99+func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
100 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))100 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
101 if frame.Width == 0 || frame.Height == 0 {101 if frame.Width == 0 || frame.Height == 0 {
102 return frame102 return frame
103 }103 }
104 base := renderer.DefaultStyle()104 base := renderer.DefaultStyle()
105+ selection := base
106+ selection.Inverse = true
107+ if len(selectedStyle) > 0 {
108+ selection = selectedStyle[0]
109+ }
105 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})110 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
106 if m == nil {111 if m == nil {
107- ui.DrawInputLine(frame, 0, "> ", "", base)112+ ui.DrawInputLine(frame, 0, "> ", "", base, selection)
108 return frame113 return frame
109 }114 }
110- ui.DrawInputLine(frame, 0, "> ", m.Query(), base)115+ ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection)
111 visible := frame.Height - 1116 visible := frame.Height - 1
112 if visible <= 0 {117 if visible <= 0 {
113 return frame118 return frame
@@ -135,7 +140,7 @@ func (m *Model) Render(inner domain.Size) renderer.Frame {
135 match := m.matches[idx]140 match := m.matches[idx]
136 style := base141 style := base
137 if idx == m.selected {142 if idx == m.selected {
138- style.Inverse = true143+ style = selection
139 }144 }
140 ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})145 ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
141 x := 0146 x := 0
@@ -146,8 +151,8 @@ func (m *Model) Render(inner domain.Size) renderer.Frame {
146 for i, r := range []rune(match.Command.Code) {151 for i, r := range []rune(match.Command.Code) {
147 cellStyle := style152 cellStyle := style
148 if highlight[i] {153 if highlight[i] {
154+ cellStyle = selection
149 cellStyle.Bold = true155 cellStyle.Bold = true
150- cellStyle.Inverse = true
151 }156 }
152 if x < frame.Width {157 if x < frame.Width {
153 frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle})158 frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle})
M internal/usecase/palette/model_test.go +18 -0
@@ -5,6 +5,7 @@ import (
55
66 "github.com/bnema/vev/internal/domain"
77 "github.com/bnema/vev/internal/usecase/command"
8+ "github.com/bnema/vev/pkg/renderer"
89 "github.com/stretchr/testify/require"
910 )
1011
@@ -89,3 +90,20 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) {
8990 require.False(t, frame.At(1, 1).Style.Bold, "unmatched P is not highlighted")
9091 require.True(t, frame.At(2, 1).Style.Bold, "matched Y is bold")
9192 }
93+
94+func TestRenderUsesSelectionStyleForFuzzyHighlights(t *testing.T) {
95+ m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})
96+ m.Insert('c')
97+ m.Insert('y')
98+ accent := renderer.DefaultStyle()
99+ accent.HasBackgroundRGB = true
100+ accent.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
101+
102+ frame := m.Render(domain.Size{Cols: 28, Rows: 3}, accent)
103+
104+ matched := frame.At(0, 1).Style
105+ require.True(t, matched.Bold)
106+ require.False(t, matched.Inverse)
107+ require.True(t, matched.HasBackgroundRGB)
108+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, matched.BackgroundRGB)
109+}
@@ -5,6 +5,7 @@ import (
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/usecase/command"7 "github.com/bnema/vev/internal/usecase/command"
8+ "github.com/bnema/vev/pkg/renderer"
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
9 )10 )
10 11
@@ -89,3 +90,20 @@ func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) {
89 require.False(t, frame.At(1, 1).Style.Bold, "unmatched P is not highlighted")90 require.False(t, frame.At(1, 1).Style.Bold, "unmatched P is not highlighted")
90 require.True(t, frame.At(2, 1).Style.Bold, "matched Y is bold")91 require.True(t, frame.At(2, 1).Style.Bold, "matched Y is bold")
91 }92 }
93+
94+func TestRenderUsesSelectionStyleForFuzzyHighlights(t *testing.T) {
95+ m := New([]command.Command{cmd("CPY", "Copy", "Enter copy mode")})
96+ m.Insert('c')
97+ m.Insert('y')
98+ accent := renderer.DefaultStyle()
99+ accent.HasBackgroundRGB = true
100+ accent.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
101+
102+ frame := m.Render(domain.Size{Cols: 28, Rows: 3}, accent)
103+
104+ matched := frame.At(0, 1).Style
105+ require.True(t, matched.Bold)
106+ require.False(t, matched.Inverse)
107+ require.True(t, matched.HasBackgroundRGB)
108+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, matched.BackgroundRGB)
109+}
M internal/usecase/picker/model.go +9 -4
@@ -134,10 +134,15 @@ func (m *Model) Clone() *Model {
134134 return &clone
135135 }
136136
137-func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame {
137+func (m *Model) Render(inner domain.Size, preview Preview, selectedStyle ...renderer.Style) renderer.Frame {
138138 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
139139 layout := ChooseLayout(inner)
140- m.renderList(frame, layout.List)
140+ selection := renderer.DefaultStyle()
141+ selection.Inverse = true
142+ if len(selectedStyle) > 0 {
143+ selection = selectedStyle[0]
144+ }
145+ m.renderList(frame, layout.List, selection)
141146 blitPreview(frame, layout.Preview, preview)
142147 return frame
143148 }
@@ -167,7 +172,7 @@ func (m *Model) firstLeaf() int {
167172 return -1
168173 }
169174
170-func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) {
175+func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection renderer.Style) {
171176 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
172177 return
173178 }
@@ -181,7 +186,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) {
181186 r := m.rows[idx]
182187 style := renderer.DefaultStyle()
183188 if idx == m.selected {
184- style.Inverse = true
189+ style = selection
185190 }
186191 label := r.label
187192 if !r.header {
@@ -134,10 +134,15 @@ func (m *Model) Clone() *Model {
134 return &clone134 return &clone
135 }135 }
136 136
137-func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame {137+func (m *Model) Render(inner domain.Size, preview Preview, selectedStyle ...renderer.Style) renderer.Frame {
138 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))138 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
139 layout := ChooseLayout(inner)139 layout := ChooseLayout(inner)
140- m.renderList(frame, layout.List)140+ selection := renderer.DefaultStyle()
141+ selection.Inverse = true
142+ if len(selectedStyle) > 0 {
143+ selection = selectedStyle[0]
144+ }
145+ m.renderList(frame, layout.List, selection)
141 blitPreview(frame, layout.Preview, preview)146 blitPreview(frame, layout.Preview, preview)
142 return frame147 return frame
143 }148 }
@@ -167,7 +172,7 @@ func (m *Model) firstLeaf() int {
167 return -1172 return -1
168 }173 }
169 174
170-func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) {175+func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection renderer.Style) {
171 if m == nil || rect.Width <= 0 || rect.Height <= 0 {176 if m == nil || rect.Width <= 0 || rect.Height <= 0 {
172 return177 return
173 }178 }
@@ -181,7 +186,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) {
181 r := m.rows[idx]186 r := m.rows[idx]
182 style := renderer.DefaultStyle()187 style := renderer.DefaultStyle()
183 if idx == m.selected {188 if idx == m.selected {
184- style.Inverse = true189+ style = selection
185 }190 }
186 label := r.label191 label := r.label
187 if !r.header {192 if !r.header {
M internal/usecase/prompt/model.go +8 -5
@@ -54,18 +54,21 @@ func (m *Model) SetError(msg string) {
5454 }
5555 }
5656
57-func (m *Model) Render(inner domain.Size) renderer.Frame {
57+func (m *Model) Render(inner domain.Size, accentStyle ...renderer.Style) renderer.Frame {
5858 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
5959 if frame.Width == 0 || frame.Height == 0 {
6060 return frame
6161 }
6262 base := renderer.DefaultStyle()
63+ accent := base
64+ accent.Inverse = true
65+ if len(accentStyle) > 0 {
66+ accent = accentStyle[0]
67+ }
6368 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
64- ui.DrawInputLine(frame, 0, "> ", m.Value(), base)
69+ ui.DrawInputLine(frame, 0, "> ", m.Value(), base, accent)
6570 if frame.Height > 1 && m != nil && m.errMsg != "" {
66- errStyle := base
67- errStyle.Inverse = true
68- ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, errStyle)
71+ ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, accent)
6972 }
7073 return frame
7174 }
@@ -54,18 +54,21 @@ func (m *Model) SetError(msg string) {
54 }54 }
55 }55 }
56 56
57-func (m *Model) Render(inner domain.Size) renderer.Frame {57+func (m *Model) Render(inner domain.Size, accentStyle ...renderer.Style) renderer.Frame {
58 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))58 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
59 if frame.Width == 0 || frame.Height == 0 {59 if frame.Width == 0 || frame.Height == 0 {
60 return frame60 return frame
61 }61 }
62 base := renderer.DefaultStyle()62 base := renderer.DefaultStyle()
63+ accent := base
64+ accent.Inverse = true
65+ if len(accentStyle) > 0 {
66+ accent = accentStyle[0]
67+ }
63 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})68 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})
64- ui.DrawInputLine(frame, 0, "> ", m.Value(), base)69+ ui.DrawInputLine(frame, 0, "> ", m.Value(), base, accent)
65 if frame.Height > 1 && m != nil && m.errMsg != "" {70 if frame.Height > 1 && m != nil && m.errMsg != "" {
66- errStyle := base71+ ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, accent)
67- errStyle.Inverse = true
68- ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, errStyle)
69 }72 }
70 return frame73 return frame
71 }74 }
A internal/usecase/theme/theme.go +268 -0
@@ -0,0 +1,268 @@
1+package theme
2+
3+import (
4+ "bytes"
5+ "strconv"
6+ "strings"
7+
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+const maxPending = 64
12+
13+// Theme describes terminal default colors reported by OSC 10/11.
14+type Theme struct {
15+ Foreground renderer.RGB
16+ Background renderer.RGB
17+ HasFG bool
18+ HasBG bool
19+ TrueColor bool
20+ Known bool
21+}
22+
23+// ParseXColor parses the XParseColor formats commonly returned by OSC 10/11.
24+func ParseXColor(s string) (renderer.RGB, bool) {
25+ if strings.HasPrefix(s, "#") {
26+ if len(s) != 7 {
27+ return renderer.RGB{}, false
28+ }
29+ r, ok := parseHexByte(s[1:3])
30+ if !ok {
31+ return renderer.RGB{}, false
32+ }
33+ g, ok := parseHexByte(s[3:5])
34+ if !ok {
35+ return renderer.RGB{}, false
36+ }
37+ b, ok := parseHexByte(s[5:7])
38+ if !ok {
39+ return renderer.RGB{}, false
40+ }
41+ return renderer.RGB{R: r, G: g, B: b}, true
42+ }
43+
44+ if !strings.HasPrefix(s, "rgb:") {
45+ return renderer.RGB{}, false
46+ }
47+ parts := strings.Split(s[len("rgb:"):], "/")
48+ if len(parts) != 3 {
49+ return renderer.RGB{}, false
50+ }
51+ width := len(parts[0])
52+ if width != 2 && width != 4 {
53+ return renderer.RGB{}, false
54+ }
55+ var out [3]uint8
56+ for i, part := range parts {
57+ if len(part) != width {
58+ return renderer.RGB{}, false
59+ }
60+ if width == 2 {
61+ v, ok := parseHexByte(part)
62+ if !ok {
63+ return renderer.RGB{}, false
64+ }
65+ out[i] = v
66+ continue
67+ }
68+ v, err := strconv.ParseUint(part, 16, 16)
69+ if err != nil {
70+ return renderer.RGB{}, false
71+ }
72+ out[i] = uint8(v >> 8)
73+ }
74+ return renderer.RGB{R: out[0], G: out[1], B: out[2]}, true
75+}
76+
77+func parseHexByte(s string) (uint8, bool) {
78+ v, err := strconv.ParseUint(s, 16, 8)
79+ if err != nil {
80+ return 0, false
81+ }
82+ return uint8(v), true
83+}
84+
85+// Scanner decodes OSC 10/11 color responses while preserving ordinary bytes.
86+type Scanner struct {
87+ pending []byte
88+}
89+
90+// Scan extracts ESC ] 10;<color> and ESC ] 11;<color> terminated by BEL or ST.
91+// All non-matching bytes are emitted through onBytes in original order, and a
92+// contiguous run of ordinary bytes (including any ESC that does not start a
93+// color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse
94+// reports or arrow keys) is always delivered as a single onBytes call so
95+// callers never see it split. Partial OSC color responses are buffered across
96+// calls, but the buffer is bounded so an unterminated OSC cannot block
97+// unrelated input forever.
98+func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) {
99+ if len(s.pending) > 0 {
100+ combined := make([]byte, 0, len(s.pending)+len(data))
101+ combined = append(combined, s.pending...)
102+ combined = append(combined, data...)
103+ data = combined
104+ s.pending = nil
105+ }
106+
107+ byteStart := 0
108+ for i := 0; i < len(data); i++ {
109+ if data[i] != '\x1b' {
110+ continue
111+ }
112+
113+ completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
114+ if !completePrefix {
115+ if possiblePrefix {
116+ if byteStart < i {
117+ onBytes(data[byteStart:i])
118+ }
119+ s.bufferOrFlush(data[i:], onBytes)
120+ return
121+ }
122+ // Not the start of a color-OSC sequence: leave this ESC inside
123+ // the current passthrough run instead of splitting it out. It
124+ // may be the introducer of an unrelated escape sequence (SGR
125+ // mouse report, arrow key, etc.) that must reach the reader
126+ // intact.
127+ continue
128+ }
129+
130+ if byteStart < i {
131+ onBytes(data[byteStart:i])
132+ }
133+
134+ termStart, termEnd := findTerminator(data[i+5:])
135+ if termStart < 0 {
136+ s.bufferOrFlush(data[i:], onBytes)
137+ return
138+ }
139+ termStart += i + 5
140+ termEnd += i + 5
141+ raw := data[i:termEnd]
142+ kind := int(data[i+3]-'0') + 10
143+ rgb, ok := ParseXColor(string(data[i+5 : termStart]))
144+ if ok {
145+ onColor(kind, rgb)
146+ } else {
147+ onBytes(raw)
148+ }
149+ i = termEnd - 1
150+ byteStart = termEnd
151+ }
152+
153+ if byteStart < len(data) {
154+ onBytes(data[byteStart:])
155+ }
156+}
157+
158+func (s *Scanner) bufferOrFlush(partial []byte, onBytes func([]byte)) {
159+ if len(partial) > maxPending {
160+ onBytes(partial)
161+ s.pending = nil
162+ return
163+ }
164+ s.pending = append(s.pending, partial...)
165+}
166+
167+func colorOSCPrefix(data []byte) (complete bool, possible bool) {
168+ if len(data) < 2 {
169+ return false, false
170+ }
171+ prefixes := [][]byte{[]byte("\x1b]10;"), []byte("\x1b]11;")}
172+ for _, prefix := range prefixes {
173+ if len(data) >= len(prefix) {
174+ if bytes.Equal(data[:len(prefix)], prefix) {
175+ return true, true
176+ }
177+ continue
178+ }
179+ if bytes.Equal(data, prefix[:len(data)]) {
180+ return false, true
181+ }
182+ }
183+ return false, false
184+}
185+
186+func findTerminator(data []byte) (start int, end int) {
187+ for i := 0; i < len(data); i++ {
188+ switch data[i] {
189+ case '\x07':
190+ return i, i + 1
191+ case '\x1b':
192+ if i+1 < len(data) && data[i+1] == '\\' {
193+ return i, i + 2
194+ }
195+ }
196+ }
197+ return -1, -1
198+}
199+
200+// Blend linearly interpolates between a and b.
201+func Blend(a, b renderer.RGB, t float64) renderer.RGB {
202+ if t < 0 {
203+ t = 0
204+ }
205+ if t > 1 {
206+ t = 1
207+ }
208+ return renderer.RGB{
209+ R: blendByte(a.R, b.R, t),
210+ G: blendByte(a.G, b.G, t),
211+ B: blendByte(a.B, b.B, t),
212+ }
213+}
214+
215+func blendByte(a, b uint8, t float64) uint8 {
216+ return uint8(float64(a) + (float64(b)-float64(a))*t + 0.5)
217+}
218+
219+func StatusBarStyle(t Theme) renderer.Style {
220+ if !usable(t) {
221+ return renderer.DefaultStyle()
222+ }
223+ style := renderer.DefaultStyle()
224+ style.HasForegroundRGB = true
225+ style.ForegroundRGB = t.Foreground
226+ style.HasBackgroundRGB = true
227+ style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.12)
228+ return style
229+}
230+
231+func AccentStyle(t Theme) renderer.Style {
232+ if !usable(t) {
233+ return inverseStyle()
234+ }
235+ style := renderer.DefaultStyle()
236+ style.HasForegroundRGB = true
237+ style.ForegroundRGB = t.Foreground
238+ style.HasBackgroundRGB = true
239+ style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.25)
240+ return style
241+}
242+
243+func BorderStyle(t Theme) renderer.Style {
244+ if !usable(t) {
245+ return renderer.DefaultStyle()
246+ }
247+ style := renderer.DefaultStyle()
248+ style.HasForegroundRGB = true
249+ style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40)
250+ return style
251+}
252+
253+func SelectionStyle(t Theme) renderer.Style {
254+ if !usable(t) {
255+ return inverseStyle()
256+ }
257+ return AccentStyle(t)
258+}
259+
260+func usable(t Theme) bool {
261+ return t.Known && t.TrueColor && t.HasFG && t.HasBG
262+}
263+
264+func inverseStyle() renderer.Style {
265+ style := renderer.DefaultStyle()
266+ style.Inverse = true
267+ return style
268+}
@@ -0,0 +1,268 @@
1+package theme
2+
3+import (
4+ "bytes"
5+ "strconv"
6+ "strings"
7+
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+const maxPending = 64
12+
13+// Theme describes terminal default colors reported by OSC 10/11.
14+type Theme struct {
15+ Foreground renderer.RGB
16+ Background renderer.RGB
17+ HasFG bool
18+ HasBG bool
19+ TrueColor bool
20+ Known bool
21+}
22+
23+// ParseXColor parses the XParseColor formats commonly returned by OSC 10/11.
24+func ParseXColor(s string) (renderer.RGB, bool) {
25+ if strings.HasPrefix(s, "#") {
26+ if len(s) != 7 {
27+ return renderer.RGB{}, false
28+ }
29+ r, ok := parseHexByte(s[1:3])
30+ if !ok {
31+ return renderer.RGB{}, false
32+ }
33+ g, ok := parseHexByte(s[3:5])
34+ if !ok {
35+ return renderer.RGB{}, false
36+ }
37+ b, ok := parseHexByte(s[5:7])
38+ if !ok {
39+ return renderer.RGB{}, false
40+ }
41+ return renderer.RGB{R: r, G: g, B: b}, true
42+ }
43+
44+ if !strings.HasPrefix(s, "rgb:") {
45+ return renderer.RGB{}, false
46+ }
47+ parts := strings.Split(s[len("rgb:"):], "/")
48+ if len(parts) != 3 {
49+ return renderer.RGB{}, false
50+ }
51+ width := len(parts[0])
52+ if width != 2 && width != 4 {
53+ return renderer.RGB{}, false
54+ }
55+ var out [3]uint8
56+ for i, part := range parts {
57+ if len(part) != width {
58+ return renderer.RGB{}, false
59+ }
60+ if width == 2 {
61+ v, ok := parseHexByte(part)
62+ if !ok {
63+ return renderer.RGB{}, false
64+ }
65+ out[i] = v
66+ continue
67+ }
68+ v, err := strconv.ParseUint(part, 16, 16)
69+ if err != nil {
70+ return renderer.RGB{}, false
71+ }
72+ out[i] = uint8(v >> 8)
73+ }
74+ return renderer.RGB{R: out[0], G: out[1], B: out[2]}, true
75+}
76+
77+func parseHexByte(s string) (uint8, bool) {
78+ v, err := strconv.ParseUint(s, 16, 8)
79+ if err != nil {
80+ return 0, false
81+ }
82+ return uint8(v), true
83+}
84+
85+// Scanner decodes OSC 10/11 color responses while preserving ordinary bytes.
86+type Scanner struct {
87+ pending []byte
88+}
89+
90+// Scan extracts ESC ] 10;<color> and ESC ] 11;<color> terminated by BEL or ST.
91+// All non-matching bytes are emitted through onBytes in original order, and a
92+// contiguous run of ordinary bytes (including any ESC that does not start a
93+// color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse
94+// reports or arrow keys) is always delivered as a single onBytes call so
95+// callers never see it split. Partial OSC color responses are buffered across
96+// calls, but the buffer is bounded so an unterminated OSC cannot block
97+// unrelated input forever.
98+func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onBytes func([]byte)) {
99+ if len(s.pending) > 0 {
100+ combined := make([]byte, 0, len(s.pending)+len(data))
101+ combined = append(combined, s.pending...)
102+ combined = append(combined, data...)
103+ data = combined
104+ s.pending = nil
105+ }
106+
107+ byteStart := 0
108+ for i := 0; i < len(data); i++ {
109+ if data[i] != '\x1b' {
110+ continue
111+ }
112+
113+ completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
114+ if !completePrefix {
115+ if possiblePrefix {
116+ if byteStart < i {
117+ onBytes(data[byteStart:i])
118+ }
119+ s.bufferOrFlush(data[i:], onBytes)
120+ return
121+ }
122+ // Not the start of a color-OSC sequence: leave this ESC inside
123+ // the current passthrough run instead of splitting it out. It
124+ // may be the introducer of an unrelated escape sequence (SGR
125+ // mouse report, arrow key, etc.) that must reach the reader
126+ // intact.
127+ continue
128+ }
129+
130+ if byteStart < i {
131+ onBytes(data[byteStart:i])
132+ }
133+
134+ termStart, termEnd := findTerminator(data[i+5:])
135+ if termStart < 0 {
136+ s.bufferOrFlush(data[i:], onBytes)
137+ return
138+ }
139+ termStart += i + 5
140+ termEnd += i + 5
141+ raw := data[i:termEnd]
142+ kind := int(data[i+3]-'0') + 10
143+ rgb, ok := ParseXColor(string(data[i+5 : termStart]))
144+ if ok {
145+ onColor(kind, rgb)
146+ } else {
147+ onBytes(raw)
148+ }
149+ i = termEnd - 1
150+ byteStart = termEnd
151+ }
152+
153+ if byteStart < len(data) {
154+ onBytes(data[byteStart:])
155+ }
156+}
157+
158+func (s *Scanner) bufferOrFlush(partial []byte, onBytes func([]byte)) {
159+ if len(partial) > maxPending {
160+ onBytes(partial)
161+ s.pending = nil
162+ return
163+ }
164+ s.pending = append(s.pending, partial...)
165+}
166+
167+func colorOSCPrefix(data []byte) (complete bool, possible bool) {
168+ if len(data) < 2 {
169+ return false, false
170+ }
171+ prefixes := [][]byte{[]byte("\x1b]10;"), []byte("\x1b]11;")}
172+ for _, prefix := range prefixes {
173+ if len(data) >= len(prefix) {
174+ if bytes.Equal(data[:len(prefix)], prefix) {
175+ return true, true
176+ }
177+ continue
178+ }
179+ if bytes.Equal(data, prefix[:len(data)]) {
180+ return false, true
181+ }
182+ }
183+ return false, false
184+}
185+
186+func findTerminator(data []byte) (start int, end int) {
187+ for i := 0; i < len(data); i++ {
188+ switch data[i] {
189+ case '\x07':
190+ return i, i + 1
191+ case '\x1b':
192+ if i+1 < len(data) && data[i+1] == '\\' {
193+ return i, i + 2
194+ }
195+ }
196+ }
197+ return -1, -1
198+}
199+
200+// Blend linearly interpolates between a and b.
201+func Blend(a, b renderer.RGB, t float64) renderer.RGB {
202+ if t < 0 {
203+ t = 0
204+ }
205+ if t > 1 {
206+ t = 1
207+ }
208+ return renderer.RGB{
209+ R: blendByte(a.R, b.R, t),
210+ G: blendByte(a.G, b.G, t),
211+ B: blendByte(a.B, b.B, t),
212+ }
213+}
214+
215+func blendByte(a, b uint8, t float64) uint8 {
216+ return uint8(float64(a) + (float64(b)-float64(a))*t + 0.5)
217+}
218+
219+func StatusBarStyle(t Theme) renderer.Style {
220+ if !usable(t) {
221+ return renderer.DefaultStyle()
222+ }
223+ style := renderer.DefaultStyle()
224+ style.HasForegroundRGB = true
225+ style.ForegroundRGB = t.Foreground
226+ style.HasBackgroundRGB = true
227+ style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.12)
228+ return style
229+}
230+
231+func AccentStyle(t Theme) renderer.Style {
232+ if !usable(t) {
233+ return inverseStyle()
234+ }
235+ style := renderer.DefaultStyle()
236+ style.HasForegroundRGB = true
237+ style.ForegroundRGB = t.Foreground
238+ style.HasBackgroundRGB = true
239+ style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.25)
240+ return style
241+}
242+
243+func BorderStyle(t Theme) renderer.Style {
244+ if !usable(t) {
245+ return renderer.DefaultStyle()
246+ }
247+ style := renderer.DefaultStyle()
248+ style.HasForegroundRGB = true
249+ style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40)
250+ return style
251+}
252+
253+func SelectionStyle(t Theme) renderer.Style {
254+ if !usable(t) {
255+ return inverseStyle()
256+ }
257+ return AccentStyle(t)
258+}
259+
260+func usable(t Theme) bool {
261+ return t.Known && t.TrueColor && t.HasFG && t.HasBG
262+}
263+
264+func inverseStyle() renderer.Style {
265+ style := renderer.DefaultStyle()
266+ style.Inverse = true
267+ return style
268+}
A internal/usecase/theme/theme_test.go +233 -0
@@ -0,0 +1,233 @@
1+package theme
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestParseXColor(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ in string
14+ want renderer.RGB
15+ ok bool
16+ }{
17+ {name: "short rgb", in: "rgb:12/ab/FF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true},
18+ {name: "wide rgb uses high byte", in: "rgb:1234/abcd/FFFF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true},
19+ {name: "hex", in: "#01aBff", want: renderer.RGB{R: 0x01, G: 0xab, B: 0xff}, ok: true},
20+ {name: "mixed width rejected", in: "rgb:12/abcd/ff", ok: false},
21+ {name: "garbage rejected", in: "not-a-color", ok: false},
22+ {name: "bad hex rejected", in: "#xyzxyz", ok: false},
23+ }
24+
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ got, ok := ParseXColor(tt.in)
28+ if ok != tt.ok {
29+ t.Fatalf("ok=%v want %v", ok, tt.ok)
30+ }
31+ if ok && got != tt.want {
32+ t.Fatalf("rgb=%+v want %+v", got, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+func TestScannerExtractsBELAndSTColors(t *testing.T) {
39+ var s Scanner
40+ var colors []struct {
41+ kind int
42+ rgb renderer.RGB
43+ }
44+ var out bytes.Buffer
45+
46+ s.Scan([]byte("a\x1b]10;#112233\x07b\x1b]11;rgb:4455/6677/8899\x1b\\c"), func(kind int, rgb renderer.RGB) {
47+ colors = append(colors, struct {
48+ kind int
49+ rgb renderer.RGB
50+ }{kind: kind, rgb: rgb})
51+ }, func(b []byte) { out.Write(b) })
52+
53+ if out.String() != "abc" {
54+ t.Fatalf("passthrough=%q want %q", out.String(), "abc")
55+ }
56+ if len(colors) != 2 {
57+ t.Fatalf("colors len=%d want 2", len(colors))
58+ }
59+ if colors[0].kind != 10 || colors[0].rgb != (renderer.RGB{R: 0x11, G: 0x22, B: 0x33}) {
60+ t.Fatalf("first color=%+v", colors[0])
61+ }
62+ if colors[1].kind != 11 || colors[1].rgb != (renderer.RGB{R: 0x44, G: 0x66, B: 0x88}) {
63+ t.Fatalf("second color=%+v", colors[1])
64+ }
65+}
66+
67+func TestScannerHandlesSplitSequences(t *testing.T) {
68+ var s Scanner
69+ var got []renderer.RGB
70+ var out bytes.Buffer
71+ onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }
72+ onBytes := func(b []byte) { out.Write(b) }
73+
74+ s.Scan([]byte("x\x1b]10;#12"), onColor, onBytes)
75+ s.Scan([]byte("3456\x07y"), onColor, onBytes)
76+
77+ if out.String() != "xy" {
78+ t.Fatalf("passthrough=%q want xy", out.String())
79+ }
80+ if len(got) != 1 || got[0] != (renderer.RGB{R: 0x12, G: 0x34, B: 0x56}) {
81+ t.Fatalf("colors=%+v", got)
82+ }
83+}
84+
85+func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {
86+ var s Scanner
87+ var out bytes.Buffer
88+ colors := 0
89+
90+ s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) })
91+
92+ if colors != 0 {
93+ t.Fatalf("colors=%d want 0", colors)
94+ }
95+ want := "ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"
96+ if out.String() != want {
97+ t.Fatalf("passthrough=%q want %q", out.String(), want)
98+ }
99+}
100+
101+func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) {
102+ var s Scanner
103+ var out bytes.Buffer
104+ colors := 0
105+
106+ s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) })
107+
108+ if colors != 0 {
109+ t.Fatalf("colors=%d want 0", colors)
110+ }
111+ if out.String() != "\x1b" {
112+ t.Fatalf("passthrough=%q want ESC", out.String())
113+ }
114+}
115+
116+func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) {
117+ var s Scanner
118+ var chunks [][]byte
119+ colors := 0
120+
121+ in := []byte("\x1b[<0;1;1M")
122+ s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) {
123+ chunks = append(chunks, append([]byte(nil), b...))
124+ })
125+
126+ if colors != 0 {
127+ t.Fatalf("colors=%d want 0", colors)
128+ }
129+ if len(chunks) != 1 {
130+ t.Fatalf("chunks=%d want 1: %q", len(chunks), chunks)
131+ }
132+ if !bytes.Equal(chunks[0], in) {
133+ t.Fatalf("chunk=%q want %q", chunks[0], in)
134+ }
135+}
136+
137+func TestScannerDoesNotSplitArrowKey(t *testing.T) {
138+ var s Scanner
139+ var chunks [][]byte
140+
141+ in := []byte("\x1b[A")
142+ s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(b []byte) {
143+ chunks = append(chunks, append([]byte(nil), b...))
144+ })
145+
146+ if len(chunks) != 1 {
147+ t.Fatalf("chunks=%d want 1: %q", len(chunks), chunks)
148+ }
149+ if !bytes.Equal(chunks[0], in) {
150+ t.Fatalf("chunk=%q want %q", chunks[0], in)
151+ }
152+}
153+
154+func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) {
155+ var s Scanner
156+ var chunks [][]byte
157+ var colors []struct {
158+ kind int
159+ rgb renderer.RGB
160+ }
161+
162+ s.Scan([]byte("abc\x1b[<64;5;5Mdef\x1b]11;rgb:11/22/33\x07ghi"), func(kind int, rgb renderer.RGB) {
163+ colors = append(colors, struct {
164+ kind int
165+ rgb renderer.RGB
166+ }{kind: kind, rgb: rgb})
167+ }, func(b []byte) {
168+ chunks = append(chunks, append([]byte(nil), b...))
169+ })
170+
171+ if len(colors) != 1 || colors[0].kind != 11 || colors[0].rgb != (renderer.RGB{R: 0x11, G: 0x22, B: 0x33}) {
172+ t.Fatalf("colors=%+v", colors)
173+ }
174+
175+ var joined bytes.Buffer
176+ for _, c := range chunks {
177+ joined.Write(c)
178+ }
179+ if want := "abc\x1b[<64;5;5Mdef" + "ghi"; joined.String() != want {
180+ t.Fatalf("joined=%q want %q", joined.String(), want)
181+ }
182+
183+ if len(chunks) == 0 || string(chunks[0]) != "abc\x1b[<64;5;5Mdef" {
184+ t.Fatalf("first chunk=%q want %q (must stay contiguous)", chunks[0], "abc\x1b[<64;5;5Mdef")
185+ }
186+}
187+
188+func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
189+ var s Scanner
190+ var out bytes.Buffer
191+
192+ s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) })
193+ s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) })
194+
195+ if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) {
196+ t.Fatalf("overflow passthrough=%q", out.String())
197+ }
198+}
199+
200+func TestStyleHelpersFallbackAndThemed(t *testing.T) {
201+ unknown := Theme{}
202+ if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) {
203+ t.Fatalf("status fallback=%+v", got)
204+ }
205+ if got := BorderStyle(Theme{Known: true}); !got.Equal(renderer.DefaultStyle()) {
206+ t.Fatalf("border fallback=%+v", got)
207+ }
208+ inverse := renderer.DefaultStyle()
209+ inverse.Inverse = true
210+ if got := AccentStyle(unknown); !got.Equal(inverse) {
211+ t.Fatalf("accent fallback=%+v want inverse", got)
212+ }
213+ if got := SelectionStyle(unknown); !got.Equal(inverse) {
214+ t.Fatalf("selection fallback=%+v want inverse", got)
215+ }
216+
217+ 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}
218+ 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 {
219+ t.Fatalf("blend=%+v want %+v", got, want)
220+ }
221+ status := StatusBarStyle(theme)
222+ if !status.HasBackgroundRGB || status.BackgroundRGB != (renderer.RGB{R: 33, G: 42, B: 50}) || !status.HasForegroundRGB || status.ForegroundRGB != theme.Foreground {
223+ t.Fatalf("status themed=%+v", status)
224+ }
225+ accent := AccentStyle(theme)
226+ if accent.Inverse || !accent.HasBackgroundRGB || accent.BackgroundRGB != (renderer.RGB{R: 58, G: 65, B: 73}) {
227+ t.Fatalf("accent themed=%+v", accent)
228+ }
229+ border := BorderStyle(theme)
230+ if !border.HasForegroundRGB || border.ForegroundRGB != (renderer.RGB{R: 124, G: 128, B: 132}) {
231+ t.Fatalf("border themed=%+v", border)
232+ }
233+}
@@ -0,0 +1,233 @@
1+package theme
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestParseXColor(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ in string
14+ want renderer.RGB
15+ ok bool
16+ }{
17+ {name: "short rgb", in: "rgb:12/ab/FF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true},
18+ {name: "wide rgb uses high byte", in: "rgb:1234/abcd/FFFF", want: renderer.RGB{R: 0x12, G: 0xab, B: 0xff}, ok: true},
19+ {name: "hex", in: "#01aBff", want: renderer.RGB{R: 0x01, G: 0xab, B: 0xff}, ok: true},
20+ {name: "mixed width rejected", in: "rgb:12/abcd/ff", ok: false},
21+ {name: "garbage rejected", in: "not-a-color", ok: false},
22+ {name: "bad hex rejected", in: "#xyzxyz", ok: false},
23+ }
24+
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ got, ok := ParseXColor(tt.in)
28+ if ok != tt.ok {
29+ t.Fatalf("ok=%v want %v", ok, tt.ok)
30+ }
31+ if ok && got != tt.want {
32+ t.Fatalf("rgb=%+v want %+v", got, tt.want)
33+ }
34+ })
35+ }
36+}
37+
38+func TestScannerExtractsBELAndSTColors(t *testing.T) {
39+ var s Scanner
40+ var colors []struct {
41+ kind int
42+ rgb renderer.RGB
43+ }
44+ var out bytes.Buffer
45+
46+ s.Scan([]byte("a\x1b]10;#112233\x07b\x1b]11;rgb:4455/6677/8899\x1b\\c"), func(kind int, rgb renderer.RGB) {
47+ colors = append(colors, struct {
48+ kind int
49+ rgb renderer.RGB
50+ }{kind: kind, rgb: rgb})
51+ }, func(b []byte) { out.Write(b) })
52+
53+ if out.String() != "abc" {
54+ t.Fatalf("passthrough=%q want %q", out.String(), "abc")
55+ }
56+ if len(colors) != 2 {
57+ t.Fatalf("colors len=%d want 2", len(colors))
58+ }
59+ if colors[0].kind != 10 || colors[0].rgb != (renderer.RGB{R: 0x11, G: 0x22, B: 0x33}) {
60+ t.Fatalf("first color=%+v", colors[0])
61+ }
62+ if colors[1].kind != 11 || colors[1].rgb != (renderer.RGB{R: 0x44, G: 0x66, B: 0x88}) {
63+ t.Fatalf("second color=%+v", colors[1])
64+ }
65+}
66+
67+func TestScannerHandlesSplitSequences(t *testing.T) {
68+ var s Scanner
69+ var got []renderer.RGB
70+ var out bytes.Buffer
71+ onColor := func(kind int, rgb renderer.RGB) { got = append(got, rgb) }
72+ onBytes := func(b []byte) { out.Write(b) }
73+
74+ s.Scan([]byte("x\x1b]10;#12"), onColor, onBytes)
75+ s.Scan([]byte("3456\x07y"), onColor, onBytes)
76+
77+ if out.String() != "xy" {
78+ t.Fatalf("passthrough=%q want xy", out.String())
79+ }
80+ if len(got) != 1 || got[0] != (renderer.RGB{R: 0x12, G: 0x34, B: 0x56}) {
81+ t.Fatalf("colors=%+v", got)
82+ }
83+}
84+
85+func TestScannerForwardsInterleavedGarbageInOrder(t *testing.T) {
86+ var s Scanner
87+ var out bytes.Buffer
88+ colors := 0
89+
90+ s.Scan([]byte("ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) })
91+
92+ if colors != 0 {
93+ t.Fatalf("colors=%d want 0", colors)
94+ }
95+ want := "ab\x1b]12;#112233\x07cd\x1b]10;nope\x07ef"
96+ if out.String() != want {
97+ t.Fatalf("passthrough=%q want %q", out.String(), want)
98+ }
99+}
100+
101+func TestScannerForwardsStandaloneEscapeImmediately(t *testing.T) {
102+ var s Scanner
103+ var out bytes.Buffer
104+ colors := 0
105+
106+ s.Scan([]byte("\x1b"), func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) { out.Write(b) })
107+
108+ if colors != 0 {
109+ t.Fatalf("colors=%d want 0", colors)
110+ }
111+ if out.String() != "\x1b" {
112+ t.Fatalf("passthrough=%q want ESC", out.String())
113+ }
114+}
115+
116+func TestScannerDoesNotSplitSGRMouseReport(t *testing.T) {
117+ var s Scanner
118+ var chunks [][]byte
119+ colors := 0
120+
121+ in := []byte("\x1b[<0;1;1M")
122+ s.Scan(in, func(kind int, rgb renderer.RGB) { colors++ }, func(b []byte) {
123+ chunks = append(chunks, append([]byte(nil), b...))
124+ })
125+
126+ if colors != 0 {
127+ t.Fatalf("colors=%d want 0", colors)
128+ }
129+ if len(chunks) != 1 {
130+ t.Fatalf("chunks=%d want 1: %q", len(chunks), chunks)
131+ }
132+ if !bytes.Equal(chunks[0], in) {
133+ t.Fatalf("chunk=%q want %q", chunks[0], in)
134+ }
135+}
136+
137+func TestScannerDoesNotSplitArrowKey(t *testing.T) {
138+ var s Scanner
139+ var chunks [][]byte
140+
141+ in := []byte("\x1b[A")
142+ s.Scan(in, func(kind int, rgb renderer.RGB) {}, func(b []byte) {
143+ chunks = append(chunks, append([]byte(nil), b...))
144+ })
145+
146+ if len(chunks) != 1 {
147+ t.Fatalf("chunks=%d want 1: %q", len(chunks), chunks)
148+ }
149+ if !bytes.Equal(chunks[0], in) {
150+ t.Fatalf("chunk=%q want %q", chunks[0], in)
151+ }
152+}
153+
154+func TestScannerKeepsMouseReportContiguousAroundColorResponse(t *testing.T) {
155+ var s Scanner
156+ var chunks [][]byte
157+ var colors []struct {
158+ kind int
159+ rgb renderer.RGB
160+ }
161+
162+ s.Scan([]byte("abc\x1b[<64;5;5Mdef\x1b]11;rgb:11/22/33\x07ghi"), func(kind int, rgb renderer.RGB) {
163+ colors = append(colors, struct {
164+ kind int
165+ rgb renderer.RGB
166+ }{kind: kind, rgb: rgb})
167+ }, func(b []byte) {
168+ chunks = append(chunks, append([]byte(nil), b...))
169+ })
170+
171+ if len(colors) != 1 || colors[0].kind != 11 || colors[0].rgb != (renderer.RGB{R: 0x11, G: 0x22, B: 0x33}) {
172+ t.Fatalf("colors=%+v", colors)
173+ }
174+
175+ var joined bytes.Buffer
176+ for _, c := range chunks {
177+ joined.Write(c)
178+ }
179+ if want := "abc\x1b[<64;5;5Mdef" + "ghi"; joined.String() != want {
180+ t.Fatalf("joined=%q want %q", joined.String(), want)
181+ }
182+
183+ if len(chunks) == 0 || string(chunks[0]) != "abc\x1b[<64;5;5Mdef" {
184+ t.Fatalf("first chunk=%q want %q (must stay contiguous)", chunks[0], "abc\x1b[<64;5;5Mdef")
185+ }
186+}
187+
188+func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
189+ var s Scanner
190+ var out bytes.Buffer
191+
192+ s.Scan(append([]byte("\x1b]10;"), bytes.Repeat([]byte("a"), 70)...), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) })
193+ s.Scan([]byte("Z"), func(kind int, rgb renderer.RGB) {}, func(b []byte) { out.Write(b) })
194+
195+ if out.Len() == 0 || !bytes.Contains(out.Bytes(), []byte("\x1b]10;")) || !bytes.HasSuffix(out.Bytes(), []byte("Z")) {
196+ t.Fatalf("overflow passthrough=%q", out.String())
197+ }
198+}
199+
200+func TestStyleHelpersFallbackAndThemed(t *testing.T) {
201+ unknown := Theme{}
202+ if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) {
203+ t.Fatalf("status fallback=%+v", got)
204+ }
205+ if got := BorderStyle(Theme{Known: true}); !got.Equal(renderer.DefaultStyle()) {
206+ t.Fatalf("border fallback=%+v", got)
207+ }
208+ inverse := renderer.DefaultStyle()
209+ inverse.Inverse = true
210+ if got := AccentStyle(unknown); !got.Equal(inverse) {
211+ t.Fatalf("accent fallback=%+v want inverse", got)
212+ }
213+ if got := SelectionStyle(unknown); !got.Equal(inverse) {
214+ t.Fatalf("selection fallback=%+v want inverse", got)
215+ }
216+
217+ 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}
218+ 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 {
219+ t.Fatalf("blend=%+v want %+v", got, want)
220+ }
221+ status := StatusBarStyle(theme)
222+ if !status.HasBackgroundRGB || status.BackgroundRGB != (renderer.RGB{R: 33, G: 42, B: 50}) || !status.HasForegroundRGB || status.ForegroundRGB != theme.Foreground {
223+ t.Fatalf("status themed=%+v", status)
224+ }
225+ accent := AccentStyle(theme)
226+ if accent.Inverse || !accent.HasBackgroundRGB || accent.BackgroundRGB != (renderer.RGB{R: 58, G: 65, B: 73}) {
227+ t.Fatalf("accent themed=%+v", accent)
228+ }
229+ border := BorderStyle(theme)
230+ if !border.HasForegroundRGB || border.ForegroundRGB != (renderer.RGB{R: 124, G: 128, B: 132}) {
231+ t.Fatalf("border themed=%+v", border)
232+ }
233+}
M internal/usecase/ui/textinput.go +4 -1
@@ -21,7 +21,7 @@ func (t *TextInput) Value() string { return string(t.runes) }
2121 func (t *TextInput) SetValue(value string) { t.runes = []rune(value) }
2222
2323 // DrawInputLine draws prefix + value followed by a reverse-video caret cell.
24-func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) {
24+func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style, caretStyle ...renderer.Style) {
2525 if y < 0 || y >= f.Height {
2626 return
2727 }
@@ -29,6 +29,9 @@ func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer
2929 if x >= 0 && x < f.Width {
3030 caret := style
3131 caret.Inverse = true
32+ if len(caretStyle) > 0 {
33+ caret = caretStyle[0]
34+ }
3235 f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret})
3336 }
3437 }
@@ -21,7 +21,7 @@ func (t *TextInput) Value() string { return string(t.runes) }
21 func (t *TextInput) SetValue(value string) { t.runes = []rune(value) }21 func (t *TextInput) SetValue(value string) { t.runes = []rune(value) }
22 22
23 // DrawInputLine draws prefix + value followed by a reverse-video caret cell.23 // DrawInputLine draws prefix + value followed by a reverse-video caret cell.
24-func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) {24+func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style, caretStyle ...renderer.Style) {
25 if y < 0 || y >= f.Height {25 if y < 0 || y >= f.Height {
26 return26 return
27 }27 }
@@ -29,6 +29,9 @@ func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer
29 if x >= 0 && x < f.Width {29 if x >= 0 && x < f.Width {
30 caret := style30 caret := style
31 caret.Inverse = true31 caret.Inverse = true
32+ if len(caretStyle) > 0 {
33+ caret = caretStyle[0]
34+ }
32 f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret})35 f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret})
33 }36 }
34 }37 }
M pkg/vt/query_test.go +56 -0
@@ -3,6 +3,8 @@ package vt
33 import (
44 "bytes"
55 "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
68 )
79
810 func TestScreenQueryResponses(t *testing.T) {
@@ -44,9 +46,63 @@ func TestScreenQueryResponses(t *testing.T) {
4446 }
4547 }
4648
49+func TestScreenOSCDefaultColorQueries(t *testing.T) {
50+ cases := []struct {
51+ name string
52+ input string
53+ want string
54+ }{
55+ {name: "foreground BEL", input: "\x1b]10;?\a", want: "\x1b]10;rgb:1212/3434/5656\a"},
56+ {name: "background BEL", input: "\x1b]11;?\a", want: "\x1b]11;rgb:7878/9a9a/bcbc\a"},
57+ {name: "foreground ST", input: "\x1b]10;?\x1b\\", want: "\x1b]10;rgb:1212/3434/5656\x1b\\"},
58+ {name: "background ST", input: "\x1b]11;?\x1b\\", want: "\x1b]11;rgb:7878/9a9a/bcbc\x1b\\"},
59+ }
60+ for _, tc := range cases {
61+ t.Run(tc.name, func(t *testing.T) {
62+ s := NewScreen(80, 24)
63+ s.SetDefaultColors(
64+ renderer.RGB{R: 0x12, G: 0x34, B: 0x56},
65+ renderer.RGB{R: 0x78, G: 0x9a, B: 0xbc},
66+ true,
67+ )
68+ var got bytes.Buffer
69+ s.OnResponse = func(b []byte) { got.Write(b) }
70+ s.Write([]byte(tc.input))
71+ if got.String() != tc.want {
72+ t.Fatalf("response = %q, want %q", got.String(), tc.want)
73+ }
74+ })
75+ }
76+}
77+
78+func TestScreenOSCDefaultColorQueriesUnknownAreSilent(t *testing.T) {
79+ s := NewScreen(80, 24)
80+ s.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, false)
81+ var got bytes.Buffer
82+ s.OnResponse = func(b []byte) { got.Write(b) }
83+ s.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
84+ if got.Len() != 0 {
85+ t.Fatalf("response = %q, want silence", got.String())
86+ }
87+}
88+
89+func TestScreenOSCDefaultColorQuerySplitAcrossWrites(t *testing.T) {
90+ s := NewScreen(80, 24)
91+ s.SetDefaultColors(renderer.RGB{R: 0x01, G: 0x02, B: 0x03}, renderer.RGB{R: 0x04, G: 0x05, B: 0x06}, true)
92+ var got bytes.Buffer
93+ s.OnResponse = func(b []byte) { got.Write(b) }
94+ s.Write([]byte("\x1b]10"))
95+ s.Write([]byte(";?\a"))
96+ if got.String() != "\x1b]10;rgb:0101/0202/0303\a" {
97+ t.Fatalf("response = %q", got.String())
98+ }
99+}
100+
47101 func TestScreenQueriesWithNilResponderDoNotPanic(t *testing.T) {
48102 s := NewScreen(80, 24)
49103 s.Write([]byte("\x1b[c\x1b[6n\x1b[?2026$p"))
104+ s.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, true)
105+ s.Write([]byte("\x1b]10;?\a\x1b]11;?\x1b\\"))
50106 }
51107
52108 func TestCSIuDispatch(t *testing.T) {
@@ -3,6 +3,8 @@ package vt
3 import (3 import (
4 "bytes"4 "bytes"
5 "testing"5 "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
6 )8 )
7 9
8 func TestScreenQueryResponses(t *testing.T) {10 func TestScreenQueryResponses(t *testing.T) {
@@ -44,9 +46,63 @@ func TestScreenQueryResponses(t *testing.T) {
44 }46 }
45 }47 }
46 48
49+func TestScreenOSCDefaultColorQueries(t *testing.T) {
50+ cases := []struct {
51+ name string
52+ input string
53+ want string
54+ }{
55+ {name: "foreground BEL", input: "\x1b]10;?\a", want: "\x1b]10;rgb:1212/3434/5656\a"},
56+ {name: "background BEL", input: "\x1b]11;?\a", want: "\x1b]11;rgb:7878/9a9a/bcbc\a"},
57+ {name: "foreground ST", input: "\x1b]10;?\x1b\\", want: "\x1b]10;rgb:1212/3434/5656\x1b\\"},
58+ {name: "background ST", input: "\x1b]11;?\x1b\\", want: "\x1b]11;rgb:7878/9a9a/bcbc\x1b\\"},
59+ }
60+ for _, tc := range cases {
61+ t.Run(tc.name, func(t *testing.T) {
62+ s := NewScreen(80, 24)
63+ s.SetDefaultColors(
64+ renderer.RGB{R: 0x12, G: 0x34, B: 0x56},
65+ renderer.RGB{R: 0x78, G: 0x9a, B: 0xbc},
66+ true,
67+ )
68+ var got bytes.Buffer
69+ s.OnResponse = func(b []byte) { got.Write(b) }
70+ s.Write([]byte(tc.input))
71+ if got.String() != tc.want {
72+ t.Fatalf("response = %q, want %q", got.String(), tc.want)
73+ }
74+ })
75+ }
76+}
77+
78+func TestScreenOSCDefaultColorQueriesUnknownAreSilent(t *testing.T) {
79+ s := NewScreen(80, 24)
80+ s.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, false)
81+ var got bytes.Buffer
82+ s.OnResponse = func(b []byte) { got.Write(b) }
83+ s.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
84+ if got.Len() != 0 {
85+ t.Fatalf("response = %q, want silence", got.String())
86+ }
87+}
88+
89+func TestScreenOSCDefaultColorQuerySplitAcrossWrites(t *testing.T) {
90+ s := NewScreen(80, 24)
91+ s.SetDefaultColors(renderer.RGB{R: 0x01, G: 0x02, B: 0x03}, renderer.RGB{R: 0x04, G: 0x05, B: 0x06}, true)
92+ var got bytes.Buffer
93+ s.OnResponse = func(b []byte) { got.Write(b) }
94+ s.Write([]byte("\x1b]10"))
95+ s.Write([]byte(";?\a"))
96+ if got.String() != "\x1b]10;rgb:0101/0202/0303\a" {
97+ t.Fatalf("response = %q", got.String())
98+ }
99+}
100+
47 func TestScreenQueriesWithNilResponderDoNotPanic(t *testing.T) {101 func TestScreenQueriesWithNilResponderDoNotPanic(t *testing.T) {
48 s := NewScreen(80, 24)102 s := NewScreen(80, 24)
49 s.Write([]byte("\x1b[c\x1b[6n\x1b[?2026$p"))103 s.Write([]byte("\x1b[c\x1b[6n\x1b[?2026$p"))
104+ s.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, true)
105+ s.Write([]byte("\x1b]10;?\a\x1b]11;?\x1b\\"))
50 }106 }
51 107
52 func TestCSIuDispatch(t *testing.T) {108 func TestCSIuDispatch(t *testing.T) {
M pkg/vt/screen.go +51 -2
@@ -40,6 +40,10 @@ type Screen struct {
4040 // The host wires it to the PTY input. Nil disables responses.
4141 OnResponse func([]byte)
4242
43+ defaultFG renderer.RGB
44+ defaultBG renderer.RGB
45+ defaultColorsKnown bool
46+
4347 damage []renderer.Damage
4448 escapeBuf []byte
4549
@@ -121,6 +125,15 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
121125 // and never sends the matching end sequence.
122126 func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }
123127
128+// SetDefaultColors sets the terminal default foreground/background colors used
129+// to answer child OSC 10/11 color queries. Passing ok=false makes color queries
130+// silent until known colors are supplied again.
131+func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) {
132+ s.defaultFG = fg
133+ s.defaultBG = bg
134+ s.defaultColorsKnown = ok
135+}
136+
124137 func (s *Screen) CursorRow() int { return s.Row }
125138 func (s *Screen) CursorCol() int { return s.Col }
126139 func (s *Screen) CursorVisible() bool {
@@ -480,7 +493,7 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
480493 }
481494 switch data[1] {
482495 case ']':
483- return consumeOSC(data)
496+ return s.consumeOSC(data)
484497 case 'P':
485498 return consumeSTString(data)
486499 case '_', '^', 'X':
@@ -541,13 +554,15 @@ func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) {
541554 return end + 1, false
542555 }
543556
544-func consumeOSC(data []byte) (consumed int, partial bool) {
557+func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) {
545558 for i := 2; i < len(data); i++ {
546559 switch data[i] {
547560 case 0x07:
561+ s.applyOSC(data[2:i], []byte{0x07})
548562 return i + 1, false
549563 case 0x1b:
550564 if i+1 < len(data) && data[i+1] == '\\' {
565+ s.applyOSC(data[2:i], []byte{0x1b, '\\'})
551566 return i + 2, false
552567 }
553568 }
@@ -555,6 +570,40 @@ func consumeOSC(data []byte) (consumed int, partial bool) {
555570 return 0, true
556571 }
557572
573+func (s *Screen) applyOSC(payload, terminator []byte) {
574+ if !s.defaultColorsKnown {
575+ return
576+ }
577+
578+ var color renderer.RGB
579+ switch string(payload) {
580+ case "10;?":
581+ color = s.defaultFG
582+ case "11;?":
583+ color = s.defaultBG
584+ default:
585+ return
586+ }
587+
588+ resp := make([]byte, 0, len(payload)+len("\x1b];rgb:0000/0000/0000")+len(terminator))
589+ resp = append(resp, "\x1b]"...)
590+ resp = append(resp, payload[:2]...)
591+ resp = append(resp, ";rgb:"...)
592+ resp = appendOSCColorComponent(resp, color.R)
593+ resp = append(resp, '/')
594+ resp = appendOSCColorComponent(resp, color.G)
595+ resp = append(resp, '/')
596+ resp = appendOSCColorComponent(resp, color.B)
597+ resp = append(resp, terminator...)
598+ s.respond(resp)
599+}
600+
601+func appendOSCColorComponent(dst []byte, c uint8) []byte {
602+ const hex = "0123456789abcdef"
603+ v := uint16(c)<<8 | uint16(c)
604+ return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf])
605+}
606+
558607 func consumeSTString(data []byte) (consumed int, partial bool) {
559608 for i := 2; i < len(data); i++ {
560609 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
@@ -40,6 +40,10 @@ type Screen struct {
40 // The host wires it to the PTY input. Nil disables responses.40 // The host wires it to the PTY input. Nil disables responses.
41 OnResponse func([]byte)41 OnResponse func([]byte)
42 42
43+ defaultFG renderer.RGB
44+ defaultBG renderer.RGB
45+ defaultColorsKnown bool
46+
43 damage []renderer.Damage47 damage []renderer.Damage
44 escapeBuf []byte48 escapeBuf []byte
45 49
@@ -121,6 +125,15 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
121 // and never sends the matching end sequence.125 // and never sends the matching end sequence.
122 func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }126 func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }
123 127
128+// SetDefaultColors sets the terminal default foreground/background colors used
129+// to answer child OSC 10/11 color queries. Passing ok=false makes color queries
130+// silent until known colors are supplied again.
131+func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) {
132+ s.defaultFG = fg
133+ s.defaultBG = bg
134+ s.defaultColorsKnown = ok
135+}
136+
124 func (s *Screen) CursorRow() int { return s.Row }137 func (s *Screen) CursorRow() int { return s.Row }
125 func (s *Screen) CursorCol() int { return s.Col }138 func (s *Screen) CursorCol() int { return s.Col }
126 func (s *Screen) CursorVisible() bool {139 func (s *Screen) CursorVisible() bool {
@@ -480,7 +493,7 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
480 }493 }
481 switch data[1] {494 switch data[1] {
482 case ']':495 case ']':
483- return consumeOSC(data)496+ return s.consumeOSC(data)
484 case 'P':497 case 'P':
485 return consumeSTString(data)498 return consumeSTString(data)
486 case '_', '^', 'X':499 case '_', '^', 'X':
@@ -541,13 +554,15 @@ func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) {
541 return end + 1, false554 return end + 1, false
542 }555 }
543 556
544-func consumeOSC(data []byte) (consumed int, partial bool) {557+func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) {
545 for i := 2; i < len(data); i++ {558 for i := 2; i < len(data); i++ {
546 switch data[i] {559 switch data[i] {
547 case 0x07:560 case 0x07:
561+ s.applyOSC(data[2:i], []byte{0x07})
548 return i + 1, false562 return i + 1, false
549 case 0x1b:563 case 0x1b:
550 if i+1 < len(data) && data[i+1] == '\\' {564 if i+1 < len(data) && data[i+1] == '\\' {
565+ s.applyOSC(data[2:i], []byte{0x1b, '\\'})
551 return i + 2, false566 return i + 2, false
552 }567 }
553 }568 }
@@ -555,6 +570,40 @@ func consumeOSC(data []byte) (consumed int, partial bool) {
555 return 0, true570 return 0, true
556 }571 }
557 572
573+func (s *Screen) applyOSC(payload, terminator []byte) {
574+ if !s.defaultColorsKnown {
575+ return
576+ }
577+
578+ var color renderer.RGB
579+ switch string(payload) {
580+ case "10;?":
581+ color = s.defaultFG
582+ case "11;?":
583+ color = s.defaultBG
584+ default:
585+ return
586+ }
587+
588+ resp := make([]byte, 0, len(payload)+len("\x1b];rgb:0000/0000/0000")+len(terminator))
589+ resp = append(resp, "\x1b]"...)
590+ resp = append(resp, payload[:2]...)
591+ resp = append(resp, ";rgb:"...)
592+ resp = appendOSCColorComponent(resp, color.R)
593+ resp = append(resp, '/')
594+ resp = appendOSCColorComponent(resp, color.G)
595+ resp = append(resp, '/')
596+ resp = appendOSCColorComponent(resp, color.B)
597+ resp = append(resp, terminator...)
598+ s.respond(resp)
599+}
600+
601+func appendOSCColorComponent(dst []byte, c uint8) []byte {
602+ const hex = "0123456789abcdef"
603+ v := uint16(c)<<8 | uint16(c)
604+ return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf])
605+}
606+
558 func consumeSTString(data []byte) (consumed int, partial bool) {607 func consumeSTString(data []byte) (consumed int, partial bool) {
559 for i := 2; i < len(data); i++ {608 for i := 2; i < len(data); i++ {
560 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {609 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {

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