detached 47168d8

feat(terminal): propagate truecolor capability

commit
47168d8d98766fa14aa22b3d86000abd5f584ca0
author
brice · · 2026-07-06 17:37 UTC
committer
brice · · 2026-07-06 18:01 UTC
parent
7447d40
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
22 files · +475 -46
M README.md +6 -0
@@ -131,6 +131,12 @@ Host *
131131 ControlPersist 10m
132132 ```
133133
134+## Terminal color
135+
136+When the attaching client reports truecolor support, vev advertises panes as `TERM=xterm-direct` and exports `COLORTERM=truecolor` to child processes. Otherwise, panes use the conservative `TERM=xterm-256color` and do not receive `COLORTERM`. This works for local and remote attach because the client capability is carried inside vev's protocol rather than relying on SSH `SendEnv`/`AcceptEnv`.
137+
138+Applications that use terminfo can detect direct color from `xterm-direct`; applications that use the common environment convention can use `COLORTERM=truecolor` when it is present.
139+
134140 ## Keys
135141
136142 All bindings use Alt directly; there is no prefix key.
@@ -131,6 +131,12 @@ Host *
131 ControlPersist 10m131 ControlPersist 10m
132 ```132 ```
133 133
134+## Terminal color
135+
136+When the attaching client reports truecolor support, vev advertises panes as `TERM=xterm-direct` and exports `COLORTERM=truecolor` to child processes. Otherwise, panes use the conservative `TERM=xterm-256color` and do not receive `COLORTERM`. This works for local and remote attach because the client capability is carried inside vev's protocol rather than relying on SSH `SendEnv`/`AcceptEnv`.
137+
138+Applications that use terminfo can detect direct color from `xterm-direct`; applications that use the common environment convention can use `COLORTERM=truecolor` when it is present.
139+
134 ## Keys140 ## Keys
135 141
136 All bindings use Alt directly; there is no prefix key.142 All bindings use Alt directly; there is no prefix key.
M internal/adapters/sshstdio/transport_test.go +2 -0
@@ -15,6 +15,8 @@ import (
1515 )
1616
1717 func TestBuildCommandUsesExecArgs(t *testing.T) {
18+ // BuildCommand deliberately starts `ssh -- target 'vev' '_stdio' [session]`.
19+ // Terminal color capability is carried in vev's Hello message, not in ssh env.
1820 tests := []struct {
1921 name string
2022 target string
@@ -15,6 +15,8 @@ import (
15 )15 )
16 16
17 func TestBuildCommandUsesExecArgs(t *testing.T) {17 func TestBuildCommandUsesExecArgs(t *testing.T) {
18+ // BuildCommand deliberately starts `ssh -- target 'vev' '_stdio' [session]`.
19+ // Terminal color capability is carried in vev's Hello message, not in ssh env.
18 tests := []struct {20 tests := []struct {
19 name string21 name string
20 target string22 target string
M internal/app/run_test.go +2 -0
@@ -423,6 +423,8 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
423423 type fakeClipboardReader struct{ ports.ClipboardReader }
424424
425425 func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
426+ // The local client still owns terminal capability detection for remote attach;
427+ // sshstdio is only a framed transport to `vev _stdio` on the remote host.
426428 var gotTarget, gotSession, gotDialer string
427429 var gotRemote bool
428430 var gotClipboard ports.ClipboardReader
@@ -423,6 +423,8 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
423 type fakeClipboardReader struct{ ports.ClipboardReader }423 type fakeClipboardReader struct{ ports.ClipboardReader }
424 424
425 func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {425 func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
426+ // The local client still owns terminal capability detection for remote attach;
427+ // sshstdio is only a framed transport to `vev _stdio` on the remote host.
426 var gotTarget, gotSession, gotDialer string428 var gotTarget, gotSession, gotDialer string
427 var gotRemote bool429 var gotRemote bool
428 var gotClipboard ports.ClipboardReader430 var gotClipboard ports.ClipboardReader
M internal/ports/frame.go +1 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 8
4+const ProtocolVersion uint16 = 9
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 84+const ProtocolVersion uint16 = 9
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 8 {
7- t.Fatalf("ProtocolVersion = %d, want 8", ProtocolVersion)
6+ if ProtocolVersion != 9 {
7+ t.Fatalf("ProtocolVersion = %d, want 9", ProtocolVersion)
88 }
99 }
1010
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 8 {6+ if ProtocolVersion != 9 {
7- t.Fatalf("ProtocolVersion = %d, want 8", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 9", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/wire.go +11 -0
@@ -65,6 +65,7 @@ type Hello struct {
6565 Size domain.Size
6666 TermEnv string
6767 Cwd string
68+ TrueColor bool
6869 }
6970
7071 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -303,6 +304,11 @@ func MarshalHello(h Hello) []byte {
303304 w.putUint16(uint16(h.Size.Rows))
304305 w.putString(h.TermEnv)
305306 w.putString(h.Cwd)
307+ if h.TrueColor {
308+ w.putUint8(1)
309+ } else {
310+ w.putUint8(0)
311+ }
306312 return w.b
307313 }
308314
@@ -344,6 +350,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
344350 if h.Cwd, err = r.getString(); err != nil {
345351 return Hello{}, err
346352 }
353+ trueColor, err := r.getUint8()
354+ if err != nil {
355+ return Hello{}, err
356+ }
357+ h.TrueColor = trueColor != 0
347358 if err := r.done(); err != nil {
348359 return Hello{}, err
349360 }
@@ -65,6 +65,7 @@ type Hello struct {
65 Size domain.Size65 Size domain.Size
66 TermEnv string66 TermEnv string
67 Cwd string67 Cwd string
68+ TrueColor bool
68 }69 }
69 70
70 // Input carries raw bytes typed/pasted by the client, destined for the PTY.71 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -303,6 +304,11 @@ func MarshalHello(h Hello) []byte {
303 w.putUint16(uint16(h.Size.Rows))304 w.putUint16(uint16(h.Size.Rows))
304 w.putString(h.TermEnv)305 w.putString(h.TermEnv)
305 w.putString(h.Cwd)306 w.putString(h.Cwd)
307+ if h.TrueColor {
308+ w.putUint8(1)
309+ } else {
310+ w.putUint8(0)
311+ }
306 return w.b312 return w.b
307 }313 }
308 314
@@ -344,6 +350,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
344 if h.Cwd, err = r.getString(); err != nil {350 if h.Cwd, err = r.getString(); err != nil {
345 return Hello{}, err351 return Hello{}, err
346 }352 }
353+ trueColor, err := r.getUint8()
354+ if err != nil {
355+ return Hello{}, err
356+ }
357+ h.TrueColor = trueColor != 0
347 if err := r.done(); err != nil {358 if err := r.done(); err != nil {
348 return Hello{}, err359 return Hello{}, err
349 }360 }
M internal/ports/wire_test.go +10 -8
@@ -68,20 +68,22 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
6868 Size: domain.Size{Cols: 80, Rows: 24},
6969 TermEnv: "xterm-256color",
7070 Cwd: "/tmp/project",
71+ TrueColor: true,
7172 },
72- want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74},
73+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01},
7374 },
7475 {
7576 name: "empty strings",
7677 msg: Hello{
77- Version: 1,
78- Intent: IntentEphemeral,
79- Name: "",
80- Size: domain.Size{Cols: 0, Rows: 0},
81- TermEnv: "",
82- Cwd: "",
78+ Version: 1,
79+ Intent: IntentEphemeral,
80+ Name: "",
81+ Size: domain.Size{Cols: 0, Rows: 0},
82+ TermEnv: "",
83+ Cwd: "",
84+ TrueColor: false,
8385 },
84- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
86+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
8587 },
8688 }
8789
@@ -68,20 +68,22 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
68 Size: domain.Size{Cols: 80, Rows: 24},68 Size: domain.Size{Cols: 80, Rows: 24},
69 TermEnv: "xterm-256color",69 TermEnv: "xterm-256color",
70 Cwd: "/tmp/project",70 Cwd: "/tmp/project",
71+ TrueColor: true,
71 },72 },
72- want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74},73+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01},
73 },74 },
74 {75 {
75 name: "empty strings",76 name: "empty strings",
76 msg: Hello{77 msg: Hello{
77- Version: 1,78+ Version: 1,
78- Intent: IntentEphemeral,79+ Intent: IntentEphemeral,
79- Name: "",80+ Name: "",
80- Size: domain.Size{Cols: 0, Rows: 0},81+ Size: domain.Size{Cols: 0, Rows: 0},
81- TermEnv: "",82+ TermEnv: "",
82- Cwd: "",83+ Cwd: "",
84+ TrueColor: false,
83 },85 },
84- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},86+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
85 },87 },
86 }88 }
87 89
M internal/usecase/client/client.go +15 -3
@@ -332,6 +332,16 @@ type attachResult struct {
332332 err error
333333 }
334334
335+func detectTrueColor(termEnv, colorTerm string) bool {
336+ switch strings.ToLower(strings.TrimSpace(colorTerm)) {
337+ case "truecolor", "24bit":
338+ return true
339+ }
340+
341+ termEnv = strings.ToLower(strings.TrimSpace(termEnv))
342+ return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct")
343+}
344+
335345 func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), remote bool, clipboard ports.ClipboardReader, log *slog.Logger) attachResult {
336346 // 1. Handshake: send Hello with our size and TERM.
337347 size, err := term.Size()
@@ -342,8 +352,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
342352 if err != nil {
343353 cwd = ""
344354 }
345- colorTerm := strings.ToLower(os.Getenv("COLORTERM"))
346- trueColor := colorTerm == "truecolor" || colorTerm == "24bit"
355+ termEnv := os.Getenv("TERM")
356+ colorTerm := os.Getenv("COLORTERM")
357+ trueColor := detectTrueColor(termEnv, colorTerm)
347358 hello := ports.Hello{
348359 Version: ports.ProtocolVersion,
349360 Intent: intent,
@@ -351,8 +362,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
351362 ResumeToken: resumeToken,
352363 Name: name,
353364 Size: size,
354- TermEnv: os.Getenv("TERM"),
365+ TermEnv: termEnv,
355366 Cwd: cwd,
367+ TrueColor: trueColor,
356368 }
357369 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
358370 return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}
@@ -332,6 +332,16 @@ type attachResult struct {
332 err error332 err error
333 }333 }
334 334
335+func detectTrueColor(termEnv, colorTerm string) bool {
336+ switch strings.ToLower(strings.TrimSpace(colorTerm)) {
337+ case "truecolor", "24bit":
338+ return true
339+ }
340+
341+ termEnv = strings.ToLower(strings.TrimSpace(termEnv))
342+ return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct")
343+}
344+
335 func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), remote bool, clipboard ports.ClipboardReader, log *slog.Logger) attachResult {345 func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func(), remote bool, clipboard ports.ClipboardReader, log *slog.Logger) attachResult {
336 // 1. Handshake: send Hello with our size and TERM.346 // 1. Handshake: send Hello with our size and TERM.
337 size, err := term.Size()347 size, err := term.Size()
@@ -342,8 +352,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
342 if err != nil {352 if err != nil {
343 cwd = ""353 cwd = ""
344 }354 }
345- colorTerm := strings.ToLower(os.Getenv("COLORTERM"))355+ termEnv := os.Getenv("TERM")
346- trueColor := colorTerm == "truecolor" || colorTerm == "24bit"356+ colorTerm := os.Getenv("COLORTERM")
357+ trueColor := detectTrueColor(termEnv, colorTerm)
347 hello := ports.Hello{358 hello := ports.Hello{
348 Version: ports.ProtocolVersion,359 Version: ports.ProtocolVersion,
349 Intent: intent,360 Intent: intent,
@@ -351,8 +362,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
351 ResumeToken: resumeToken,362 ResumeToken: resumeToken,
352 Name: name,363 Name: name,
353 Size: size,364 Size: size,
354- TermEnv: os.Getenv("TERM"),365+ TermEnv: termEnv,
355 Cwd: cwd,366 Cwd: cwd,
367+ TrueColor: trueColor,
356 }368 }
357 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {369 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
358 return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}370 return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}
M internal/usecase/client/client_test.go +38 -0
@@ -151,6 +151,43 @@ func isType(typ ports.MsgType) any {
151151 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })
152152 }
153153
154+func TestAttachHelloIncludesTrueColor(t *testing.T) {
155+ t.Setenv("TERM", "xterm-256color")
156+ t.Setenv("COLORTERM", "truecolor")
157+
158+ var out bytes.Buffer
159+ var restoreCount atomic.Int32
160+ resizeCh := make(chan domain.Size)
161+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
162+ defer in.unblock()
163+
164+ gotHello := make(chan ports.Hello, 1)
165+ tr := portsmocks.NewMockTransport(t)
166+ tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
167+ hello, err := ports.UnmarshalHello(f.Payload)
168+ require.NoError(t, err)
169+ gotHello <- hello
170+ return nil
171+ }).Once()
172+ unblock := scriptRecv(tr,
173+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
174+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
175+ )
176+ defer unblock()
177+ tr.EXPECT().Close().Return(nil).Once()
178+
179+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
180+ require.NoError(t, err)
181+
182+ select {
183+ case hello := <-gotHello:
184+ require.Equal(t, "xterm-256color", hello.TermEnv)
185+ require.True(t, hello.TrueColor)
186+ case <-time.After(2 * time.Second):
187+ t.Fatal("hello frame was not sent")
188+ }
189+}
190+
154191 func TestAttachHappyPath(t *testing.T) {
155192 var out bytes.Buffer
156193 var restoreCount atomic.Int32
@@ -332,6 +369,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
332369 }
333370
334371 func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
372+ t.Setenv("TERM", "xterm-256color")
335373 t.Setenv("COLORTERM", "")
336374
337375 var out bytes.Buffer
@@ -151,6 +151,43 @@ func isType(typ ports.MsgType) any {
151 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })151 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })
152 }152 }
153 153
154+func TestAttachHelloIncludesTrueColor(t *testing.T) {
155+ t.Setenv("TERM", "xterm-256color")
156+ t.Setenv("COLORTERM", "truecolor")
157+
158+ var out bytes.Buffer
159+ var restoreCount atomic.Int32
160+ resizeCh := make(chan domain.Size)
161+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
162+ defer in.unblock()
163+
164+ gotHello := make(chan ports.Hello, 1)
165+ tr := portsmocks.NewMockTransport(t)
166+ tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
167+ hello, err := ports.UnmarshalHello(f.Payload)
168+ require.NoError(t, err)
169+ gotHello <- hello
170+ return nil
171+ }).Once()
172+ unblock := scriptRecv(tr,
173+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
174+ recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
175+ )
176+ defer unblock()
177+ tr.EXPECT().Close().Return(nil).Once()
178+
179+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
180+ require.NoError(t, err)
181+
182+ select {
183+ case hello := <-gotHello:
184+ require.Equal(t, "xterm-256color", hello.TermEnv)
185+ require.True(t, hello.TrueColor)
186+ case <-time.After(2 * time.Second):
187+ t.Fatal("hello frame was not sent")
188+ }
189+}
190+
154 func TestAttachHappyPath(t *testing.T) {191 func TestAttachHappyPath(t *testing.T) {
155 var out bytes.Buffer192 var out bytes.Buffer
156 var restoreCount atomic.Int32193 var restoreCount atomic.Int32
@@ -332,6 +369,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
332 }369 }
333 370
334 func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {371 func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
372+ t.Setenv("TERM", "xterm-256color")
335 t.Setenv("COLORTERM", "")373 t.Setenv("COLORTERM", "")
336 374
337 var out bytes.Buffer375 var out bytes.Buffer
A internal/usecase/client/truecolor_test.go +31 -0
@@ -0,0 +1,31 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestDetectTrueColor(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ termEnv string
13+ colorTerm string
14+ want bool
15+ }{
16+ {name: "COLORTERM truecolor", termEnv: "screen", colorTerm: "truecolor", want: true},
17+ {name: "COLORTERM 24bit", termEnv: "screen", colorTerm: "24bit", want: true},
18+ {name: "COLORTERM trims and ignores case", termEnv: "screen", colorTerm: " TRUECOLOR ", want: true},
19+ {name: "xterm-direct", termEnv: "xterm-direct", colorTerm: "", want: true},
20+ {name: "direct suffix", termEnv: "foot-direct", colorTerm: "", want: true},
21+ {name: "direct suffix ignores case and space", termEnv: " FOOT-DIRECT ", colorTerm: "", want: true},
22+ {name: "xterm 256 only", termEnv: "xterm-256color", colorTerm: "", want: false},
23+ {name: "empty", termEnv: "", colorTerm: "", want: false},
24+ }
25+
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ require.Equal(t, tt.want, detectTrueColor(tt.termEnv, tt.colorTerm))
29+ })
30+ }
31+}
@@ -0,0 +1,31 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestDetectTrueColor(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ termEnv string
13+ colorTerm string
14+ want bool
15+ }{
16+ {name: "COLORTERM truecolor", termEnv: "screen", colorTerm: "truecolor", want: true},
17+ {name: "COLORTERM 24bit", termEnv: "screen", colorTerm: "24bit", want: true},
18+ {name: "COLORTERM trims and ignores case", termEnv: "screen", colorTerm: " TRUECOLOR ", want: true},
19+ {name: "xterm-direct", termEnv: "xterm-direct", colorTerm: "", want: true},
20+ {name: "direct suffix", termEnv: "foot-direct", colorTerm: "", want: true},
21+ {name: "direct suffix ignores case and space", termEnv: " FOOT-DIRECT ", colorTerm: "", want: true},
22+ {name: "xterm 256 only", termEnv: "xterm-256color", colorTerm: "", want: false},
23+ {name: "empty", termEnv: "", colorTerm: "", want: false},
24+ }
25+
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ require.Equal(t, tt.want, detectTrueColor(tt.termEnv, tt.colorTerm))
29+ })
30+ }
31+}
M internal/usecase/daemon/daemon.go +17 -4
@@ -598,6 +598,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
598598 return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
599599 }
600600 }
601+ term := terminalEnv{TrueColor: h.TrueColor}
601602
602603 d.mu.Lock()
603604 // Shutdown/create interlock: once shutdown has begun (last session removed,
@@ -624,7 +625,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
624625 }
625626 cwd := d.dirOrHome(stopped.cwd)
626627 var err error
627- sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
628+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)
628629 if err != nil {
629630 d.mu.Unlock()
630631 return nil, nil, err
@@ -632,19 +633,25 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
632633 }
633634 d.purgeParkedForSessionLocked(sess)
634635 ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
636+ sess.mu.Lock()
637+ sess.terminal = term
638+ sess.mu.Unlock()
635639 d.mu.Unlock()
636640 d.detachReplacedClient(old)
637641 return sess, ac, nil
638642
639643 case ports.IntentEphemeral:
640644 name := d.allocEphemeralNameLocked()
641- sess, err := d.createSessionLocked(name, true, h.Cwd, sz)
645+ sess, err := d.createSessionLocked(name, true, h.Cwd, sz, term)
642646 if err != nil {
643647 d.mu.Unlock()
644648 return nil, nil, err
645649 }
646650 d.purgeParkedForSessionLocked(sess)
647651 ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID})
652+ sess.mu.Lock()
653+ sess.terminal = term
654+ sess.mu.Unlock()
648655 d.mu.Unlock()
649656 d.detachReplacedClient(old)
650657 return sess, ac, nil
@@ -662,13 +669,16 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
662669 d.mu.Unlock()
663670 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
664671 }
665- sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz)
672+ sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, term)
666673 if err != nil {
667674 d.mu.Unlock()
668675 return nil, nil, err
669676 }
670677 d.purgeParkedForSessionLocked(sess)
671678 ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
679+ sess.mu.Lock()
680+ sess.terminal = term
681+ sess.mu.Unlock()
672682 d.mu.Unlock()
673683 d.detachReplacedClient(old)
674684 return sess, ac, nil
@@ -683,7 +693,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
683693 }
684694 cwd := d.dirOrHome(stopped.cwd)
685695 var err error
686- sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
696+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)
687697 if err != nil {
688698 d.mu.Unlock()
689699 return nil, nil, err
@@ -691,6 +701,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
691701 }
692702 d.purgeParkedForSessionLocked(sess)
693703 ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
704+ sess.mu.Lock()
705+ sess.terminal = term
706+ sess.mu.Unlock()
694707 d.mu.Unlock()
695708 d.detachReplacedClient(old)
696709 return sess, ac, nil
@@ -598,6 +598,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
598 return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}598 return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
599 }599 }
600 }600 }
601+ term := terminalEnv{TrueColor: h.TrueColor}
601 602
602 d.mu.Lock()603 d.mu.Lock()
603 // Shutdown/create interlock: once shutdown has begun (last session removed,604 // Shutdown/create interlock: once shutdown has begun (last session removed,
@@ -624,7 +625,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
624 }625 }
625 cwd := d.dirOrHome(stopped.cwd)626 cwd := d.dirOrHome(stopped.cwd)
626 var err error627 var err error
627- sess, err = d.createSessionLocked(h.Name, false, cwd, sz)628+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)
628 if err != nil {629 if err != nil {
629 d.mu.Unlock()630 d.mu.Unlock()
630 return nil, nil, err631 return nil, nil, err
@@ -632,19 +633,25 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
632 }633 }
633 d.purgeParkedForSessionLocked(sess)634 d.purgeParkedForSessionLocked(sess)
634 ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})635 ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
636+ sess.mu.Lock()
637+ sess.terminal = term
638+ sess.mu.Unlock()
635 d.mu.Unlock()639 d.mu.Unlock()
636 d.detachReplacedClient(old)640 d.detachReplacedClient(old)
637 return sess, ac, nil641 return sess, ac, nil
638 642
639 case ports.IntentEphemeral:643 case ports.IntentEphemeral:
640 name := d.allocEphemeralNameLocked()644 name := d.allocEphemeralNameLocked()
641- sess, err := d.createSessionLocked(name, true, h.Cwd, sz)645+ sess, err := d.createSessionLocked(name, true, h.Cwd, sz, term)
642 if err != nil {646 if err != nil {
643 d.mu.Unlock()647 d.mu.Unlock()
644 return nil, nil, err648 return nil, nil, err
645 }649 }
646 d.purgeParkedForSessionLocked(sess)650 d.purgeParkedForSessionLocked(sess)
647 ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID})651 ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID})
652+ sess.mu.Lock()
653+ sess.terminal = term
654+ sess.mu.Unlock()
648 d.mu.Unlock()655 d.mu.Unlock()
649 d.detachReplacedClient(old)656 d.detachReplacedClient(old)
650 return sess, ac, nil657 return sess, ac, nil
@@ -662,13 +669,16 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
662 d.mu.Unlock()669 d.mu.Unlock()
663 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}670 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
664 }671 }
665- sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz)672+ sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, term)
666 if err != nil {673 if err != nil {
667 d.mu.Unlock()674 d.mu.Unlock()
668 return nil, nil, err675 return nil, nil, err
669 }676 }
670 d.purgeParkedForSessionLocked(sess)677 d.purgeParkedForSessionLocked(sess)
671 ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})678 ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
679+ sess.mu.Lock()
680+ sess.terminal = term
681+ sess.mu.Unlock()
672 d.mu.Unlock()682 d.mu.Unlock()
673 d.detachReplacedClient(old)683 d.detachReplacedClient(old)
674 return sess, ac, nil684 return sess, ac, nil
@@ -683,7 +693,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
683 }693 }
684 cwd := d.dirOrHome(stopped.cwd)694 cwd := d.dirOrHome(stopped.cwd)
685 var err error695 var err error
686- sess, err = d.createSessionLocked(h.Name, false, cwd, sz)696+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)
687 if err != nil {697 if err != nil {
688 d.mu.Unlock()698 d.mu.Unlock()
689 return nil, nil, err699 return nil, nil, err
@@ -691,6 +701,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
691 }701 }
692 d.purgeParkedForSessionLocked(sess)702 d.purgeParkedForSessionLocked(sess)
693 ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})703 ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
704+ sess.mu.Lock()
705+ sess.terminal = term
706+ sess.mu.Unlock()
694 d.mu.Unlock()707 d.mu.Unlock()
695 d.detachReplacedClient(old)708 d.detachReplacedClient(old)
696 return sess, ac, nil709 return sess, ac, nil
M internal/usecase/daemon/pane_actions.go +2 -1
@@ -34,6 +34,7 @@ func (d *Daemon) spawnPaneOp(
3434
3535 sess.mu.Lock()
3636 name, cwd := sess.name, sess.cwd
37+ term := sess.terminal
3738 sess.mu.Unlock()
3839
3940 tb.mu.Lock()
@@ -68,7 +69,7 @@ func (d *Daemon) spawnPaneOp(
6869 tb.mu.Unlock()
6970 return fmt.Errorf("daemon: generating pane identity: %w", err)
7071 }
71- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, rectSize(newRect))
72+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
7273 if err != nil {
7374 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
7475 tb.mu.Lock()
@@ -34,6 +34,7 @@ func (d *Daemon) spawnPaneOp(
34 34
35 sess.mu.Lock()35 sess.mu.Lock()
36 name, cwd := sess.name, sess.cwd36 name, cwd := sess.name, sess.cwd
37+ term := sess.terminal
37 sess.mu.Unlock()38 sess.mu.Unlock()
38 39
39 tb.mu.Lock()40 tb.mu.Lock()
@@ -68,7 +69,7 @@ func (d *Daemon) spawnPaneOp(
68 tb.mu.Unlock()69 tb.mu.Unlock()
69 return fmt.Errorf("daemon: generating pane identity: %w", err)70 return fmt.Errorf("daemon: generating pane identity: %w", err)
70 }71 }
71- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, rectSize(newRect))72+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
72 if err != nil {73 if err != nil {
73 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")74 d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane")
74 tb.mu.Lock()75 tb.mu.Lock()
M internal/usecase/daemon/pane_actions_test.go +47 -0
@@ -12,6 +12,7 @@ import (
1212 "github.com/stretchr/testify/require"
1313
1414 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
1516 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1617 scopy "github.com/bnema/vev/internal/usecase/copy"
1718 "github.com/bnema/vev/internal/usecase/layout"
@@ -320,3 +321,49 @@ func blockingRead(t *testing.T) func([]byte) (int, error) {
320321 return 0, io.EOF
321322 }
322323 }
324+
325+func TestSplitPaneInheritsTerminalEnv(t *testing.T) {
326+ tests := []struct {
327+ name string
328+ term terminalEnv
329+ wantContain []string
330+ wantNotContain []string
331+ }{
332+ {
333+ name: "truecolor",
334+ term: terminalEnv{TrueColor: true},
335+ wantContain: []string{"TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},
336+ },
337+ {
338+ name: "no truecolor",
339+ term: terminalEnv{},
340+ wantContain: []string{"TERM=xterm-256color", "TERM_PROGRAM=vev"},
341+ wantNotContain: []string{"TERM=xterm-direct", "COLORTERM=truecolor"},
342+ },
343+ }
344+
345+ for _, tt := range tests {
346+ t.Run(tt.name, func(t *testing.T) {
347+ d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
348+ sess.terminal = tt.term
349+ newPTY := portsmocks.NewMockPTY(t)
350+ oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
351+ newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
352+ var gotEnv []string
353+ factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
354+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
355+ gotEnv = append([]string(nil), env...)
356+ return newPTY, nil
357+ },
358+ ).Once()
359+
360+ require.NoError(t, d.splitPane(sess, nil, layout.Right))
361+ for _, want := range tt.wantContain {
362+ require.Contains(t, gotEnv, want)
363+ }
364+ for _, notWant := range tt.wantNotContain {
365+ require.NotContains(t, gotEnv, notWant)
366+ }
367+ })
368+ }
369+}
@@ -12,6 +12,7 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 scopy "github.com/bnema/vev/internal/usecase/copy"17 scopy "github.com/bnema/vev/internal/usecase/copy"
17 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
@@ -320,3 +321,49 @@ func blockingRead(t *testing.T) func([]byte) (int, error) {
320 return 0, io.EOF321 return 0, io.EOF
321 }322 }
322 }323 }
324+
325+func TestSplitPaneInheritsTerminalEnv(t *testing.T) {
326+ tests := []struct {
327+ name string
328+ term terminalEnv
329+ wantContain []string
330+ wantNotContain []string
331+ }{
332+ {
333+ name: "truecolor",
334+ term: terminalEnv{TrueColor: true},
335+ wantContain: []string{"TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},
336+ },
337+ {
338+ name: "no truecolor",
339+ term: terminalEnv{},
340+ wantContain: []string{"TERM=xterm-256color", "TERM_PROGRAM=vev"},
341+ wantNotContain: []string{"TERM=xterm-direct", "COLORTERM=truecolor"},
342+ },
343+ }
344+
345+ for _, tt := range tests {
346+ t.Run(tt.name, func(t *testing.T) {
347+ d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
348+ sess.terminal = tt.term
349+ newPTY := portsmocks.NewMockPTY(t)
350+ oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
351+ newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
352+ var gotEnv []string
353+ factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).RunAndReturn(
354+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
355+ gotEnv = append([]string(nil), env...)
356+ return newPTY, nil
357+ },
358+ ).Once()
359+
360+ require.NoError(t, d.splitPane(sess, nil, layout.Right))
361+ for _, want := range tt.wantContain {
362+ require.Contains(t, gotEnv, want)
363+ }
364+ for _, notWant := range tt.wantNotContain {
365+ require.NotContains(t, gotEnv, notWant)
366+ }
367+ })
368+ }
369+}
M internal/usecase/daemon/picker.go +4 -1
@@ -389,12 +389,14 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
389389 d.mu.Unlock()
390390 return nil
391391 }
392+ term := from.terminal
392393 from.client = nil
393394 ac.setSession(nil)
394395 from.mu.Unlock()
395396
396397 targetSess.mu.Lock()
397398 old := targetSess.client
399+ targetSess.terminal = term
398400 targetSess.client = ac
399401 if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
400402 targetSess.active = target.TabIndex
@@ -420,8 +422,9 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
420422 d.mu.Unlock()
421423 return
422424 }
425+ term := from.terminal
423426 cwd := d.dirOrHome(stopped.cwd)
424- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size)
427+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term)
425428 if err != nil {
426429 from.mu.Unlock()
427430 d.mu.Unlock()
@@ -389,12 +389,14 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
389 d.mu.Unlock()389 d.mu.Unlock()
390 return nil390 return nil
391 }391 }
392+ term := from.terminal
392 from.client = nil393 from.client = nil
393 ac.setSession(nil)394 ac.setSession(nil)
394 from.mu.Unlock()395 from.mu.Unlock()
395 396
396 targetSess.mu.Lock()397 targetSess.mu.Lock()
397 old := targetSess.client398 old := targetSess.client
399+ targetSess.terminal = term
398 targetSess.client = ac400 targetSess.client = ac
399 if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {401 if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) {
400 targetSess.active = target.TabIndex402 targetSess.active = target.TabIndex
@@ -420,8 +422,9 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
420 d.mu.Unlock()422 d.mu.Unlock()
421 return423 return
422 }424 }
425+ term := from.terminal
423 cwd := d.dirOrHome(stopped.cwd)426 cwd := d.dirOrHome(stopped.cwd)
424- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size)427+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term)
425 if err != nil {428 if err != nil {
426 from.mu.Unlock()429 from.mu.Unlock()
427 d.mu.Unlock()430 d.mu.Unlock()
M internal/usecase/daemon/picker_test.go +80 -0
@@ -6,12 +6,15 @@ import (
66 "testing"
77 "time"
88
9+ "github.com/stretchr/testify/mock"
910 "github.com/stretchr/testify/require"
1011
1112 "github.com/bnema/vev/internal/domain"
1213 "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1315 "github.com/bnema/vev/internal/usecase/keys"
1416 "github.com/bnema/vev/internal/usecase/layout"
17+ "github.com/bnema/vev/internal/usecase/picker"
1518 "github.com/bnema/vev/pkg/renderer"
1619 )
1720
@@ -181,6 +184,47 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
181184 awaitFrame(t, sends1, ports.MsgOutput)
182185 }
183186
187+func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
188+ p1, releasePTY1 := newBlockingPTY(t)
189+ p2, releasePTY2 := newBlockingPTY(t)
190+ p3, releasePTY3 := newBlockingPTY(t)
191+ defer releasePTY1()
192+ defer releasePTY2()
193+ defer releasePTY3()
194+ var openedEnv []string
195+ f := portsmocks.NewMockPTYFactory(t)
196+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
197+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
198+ openedEnv = append([]string(nil), env...)
199+ return p3, nil
200+ },
201+ ).Once()
202+ d := newTestDaemon(t, f, stubClock{})
203+ tr1, _ := newCapturingTransport(t)
204+ tr2, _ := newCapturingTransport(t)
205+ ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
206+ ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
207+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
208+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
209+ defer cancel1()
210+ defer cancel2()
211+ sess1 := &session{id: "s1", name: "alpha", cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1, terminal: terminalEnv{}}
212+ sess2 := &session{id: "s2", name: "beta", cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2, terminal: terminalEnv{TrueColor: true}}
213+ ac1.setSession(sess1)
214+ ac2.setSession(sess2)
215+ d.sessions[sess1.id] = sess1
216+ d.sessions[sess2.id] = sess2
217+
218+ old := d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})
219+
220+ require.Same(t, ac2, old)
221+ require.False(t, sess2.terminal.TrueColor)
222+ require.NoError(t, d.createTab(sess2, domain.Size{Cols: 80, Rows: 24}))
223+ require.Contains(t, openedEnv, "TERM=xterm-256color")
224+ require.NotContains(t, openedEnv, "COLORTERM=truecolor")
225+ require.NotContains(t, openedEnv, "TERM=xterm-direct")
226+}
227+
184228 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
185229 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
186230 p := tb.focusedPane()
@@ -358,3 +402,39 @@ func TestPickerResizeRecomposesModal(t *testing.T) {
358402 require.Contains(t, string(msg.Data), "┌")
359403 require.Contains(t, string(msg.Data), "Sessions")
360404 }
405+
406+func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
407+ sz := domain.Size{Cols: 80, Rows: 24}
408+ p1, release1 := newBlockingPTY(t)
409+ defer release1()
410+ p2, release2 := newBlockingPTY(t)
411+ defer release2()
412+ var opens [][]string
413+ f := portsmocks.NewMockPTYFactory(t)
414+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
415+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
416+ opens = append(opens, append([]string(nil), env...))
417+ if len(opens) == 1 {
418+ return p1, nil
419+ }
420+ return p2, nil
421+ },
422+ ).Twice()
423+ d := newTestDaemon(t, f, stubClock{})
424+ d.stopped["old"] = stoppedSession{name: "old", cwd: t.TempDir(), createdAt: 1}
425+ tr := portsmocks.NewMockTransport(t)
426+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
427+ tr.EXPECT().Close().Return(nil).Maybe()
428+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
429+ require.NoError(t, err)
430+ d.resumeStoppedAndSwitch(sess, ac, picker.Target{Name: "old", Stopped: true})
431+ got := ac.sess.Get()
432+ require.NotNil(t, got)
433+ got.mu.Lock()
434+ defer got.mu.Unlock()
435+ require.True(t, got.terminal.TrueColor)
436+ require.Len(t, opens, 2)
437+ require.Contains(t, opens[1], "TERM=xterm-direct")
438+ require.Contains(t, opens[1], "COLORTERM=truecolor")
439+ require.Contains(t, opens[1], "TERM_PROGRAM=vev")
440+}
@@ -6,12 +6,15 @@ import (
6 "testing"6 "testing"
7 "time"7 "time"
8 8
9+ "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 11
11 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
13 "github.com/bnema/vev/internal/usecase/keys"15 "github.com/bnema/vev/internal/usecase/keys"
14 "github.com/bnema/vev/internal/usecase/layout"16 "github.com/bnema/vev/internal/usecase/layout"
17+ "github.com/bnema/vev/internal/usecase/picker"
15 "github.com/bnema/vev/pkg/renderer"18 "github.com/bnema/vev/pkg/renderer"
16 )19 )
17 20
@@ -181,6 +184,47 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
181 awaitFrame(t, sends1, ports.MsgOutput)184 awaitFrame(t, sends1, ports.MsgOutput)
182 }185 }
183 186
187+func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
188+ p1, releasePTY1 := newBlockingPTY(t)
189+ p2, releasePTY2 := newBlockingPTY(t)
190+ p3, releasePTY3 := newBlockingPTY(t)
191+ defer releasePTY1()
192+ defer releasePTY2()
193+ defer releasePTY3()
194+ var openedEnv []string
195+ f := portsmocks.NewMockPTYFactory(t)
196+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
197+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
198+ openedEnv = append([]string(nil), env...)
199+ return p3, nil
200+ },
201+ ).Once()
202+ d := newTestDaemon(t, f, stubClock{})
203+ tr1, _ := newCapturingTransport(t)
204+ tr2, _ := newCapturingTransport(t)
205+ ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
206+ ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
207+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
208+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
209+ defer cancel1()
210+ defer cancel2()
211+ sess1 := &session{id: "s1", name: "alpha", cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1, terminal: terminalEnv{}}
212+ sess2 := &session{id: "s2", name: "beta", cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2, terminal: terminalEnv{TrueColor: true}}
213+ ac1.setSession(sess1)
214+ ac2.setSession(sess2)
215+ d.sessions[sess1.id] = sess1
216+ d.sessions[sess2.id] = sess2
217+
218+ old := d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})
219+
220+ require.Same(t, ac2, old)
221+ require.False(t, sess2.terminal.TrueColor)
222+ require.NoError(t, d.createTab(sess2, domain.Size{Cols: 80, Rows: 24}))
223+ require.Contains(t, openedEnv, "TERM=xterm-256color")
224+ require.NotContains(t, openedEnv, "COLORTERM=truecolor")
225+ require.NotContains(t, openedEnv, "TERM=xterm-direct")
226+}
227+
184 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {228 func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) {
185 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})229 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
186 p := tb.focusedPane()230 p := tb.focusedPane()
@@ -358,3 +402,39 @@ func TestPickerResizeRecomposesModal(t *testing.T) {
358 require.Contains(t, string(msg.Data), "┌")402 require.Contains(t, string(msg.Data), "┌")
359 require.Contains(t, string(msg.Data), "Sessions")403 require.Contains(t, string(msg.Data), "Sessions")
360 }404 }
405+
406+func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
407+ sz := domain.Size{Cols: 80, Rows: 24}
408+ p1, release1 := newBlockingPTY(t)
409+ defer release1()
410+ p2, release2 := newBlockingPTY(t)
411+ defer release2()
412+ var opens [][]string
413+ f := portsmocks.NewMockPTYFactory(t)
414+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
415+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
416+ opens = append(opens, append([]string(nil), env...))
417+ if len(opens) == 1 {
418+ return p1, nil
419+ }
420+ return p2, nil
421+ },
422+ ).Twice()
423+ d := newTestDaemon(t, f, stubClock{})
424+ d.stopped["old"] = stoppedSession{name: "old", cwd: t.TempDir(), createdAt: 1}
425+ tr := portsmocks.NewMockTransport(t)
426+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
427+ tr.EXPECT().Close().Return(nil).Maybe()
428+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
429+ require.NoError(t, err)
430+ d.resumeStoppedAndSwitch(sess, ac, picker.Target{Name: "old", Stopped: true})
431+ got := ac.sess.Get()
432+ require.NotNil(t, got)
433+ got.mu.Lock()
434+ defer got.mu.Unlock()
435+ require.True(t, got.terminal.TrueColor)
436+ require.Len(t, opens, 2)
437+ require.Contains(t, opens[1], "TERM=xterm-direct")
438+ require.Contains(t, opens[1], "COLORTERM=truecolor")
439+ require.Contains(t, opens[1], "TERM_PROGRAM=vev")
440+}
M internal/usecase/daemon/resume.go +1 -0
@@ -149,6 +149,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
149149 ac.setSession(sess)
150150 sess.mu.Lock()
151151 sess.client = ac
152+ sess.terminal = terminalEnv{TrueColor: h.TrueColor}
152153 sess.mu.Unlock()
153154 d.touchMRU(sess)
154155 d.log.Info("client resumed", "session", sess.name)
@@ -149,6 +149,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
149 ac.setSession(sess)149 ac.setSession(sess)
150 sess.mu.Lock()150 sess.mu.Lock()
151 sess.client = ac151 sess.client = ac
152+ sess.terminal = terminalEnv{TrueColor: h.TrueColor}
152 sess.mu.Unlock()153 sess.mu.Unlock()
153 d.touchMRU(sess)154 d.touchMRU(sess)
154 d.log.Info("client resumed", "session", sess.name)155 d.log.Info("client resumed", "session", sess.name)
M internal/usecase/daemon/resume_test.go +25 -0
@@ -305,3 +305,28 @@ func TestSequencedInputDoesNotPrematurelyEchoAck(t *testing.T) {
305305
306306 require.Zero(t, ac.echoAck.Load())
307307 }
308+
309+func TestResumeParkedUpdatesTerminalEnv(t *testing.T) {
310+ pty, release := newBlockingPTY(t)
311+ defer release()
312+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
313+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
314+ hello := helloResumeCapable(ports.IntentNew, "work", 0)
315+ hello.TrueColor = false
316+ sess, ac, err := d.route(hello, tr)
317+ require.NoError(t, err)
318+ token := ac.resumeToken
319+ require.True(t, sess.detachIfCurrent(ac))
320+ require.True(t, d.parkAttachment(sess, ac))
321+
322+ resumeHello := helloResumeCapable(ports.IntentResume, "work", token)
323+ resumeHello.TrueColor = true
324+ d.mu.Lock()
325+ _, _, ok, err := d.resumeParkedLocked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
326+ d.mu.Unlock()
327+ require.NoError(t, err)
328+ require.True(t, ok)
329+ sess.mu.Lock()
330+ defer sess.mu.Unlock()
331+ require.True(t, sess.terminal.TrueColor)
332+}
@@ -305,3 +305,28 @@ func TestSequencedInputDoesNotPrematurelyEchoAck(t *testing.T) {
305 305
306 require.Zero(t, ac.echoAck.Load())306 require.Zero(t, ac.echoAck.Load())
307 }307 }
308+
309+func TestResumeParkedUpdatesTerminalEnv(t *testing.T) {
310+ pty, release := newBlockingPTY(t)
311+ defer release()
312+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
313+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
314+ hello := helloResumeCapable(ports.IntentNew, "work", 0)
315+ hello.TrueColor = false
316+ sess, ac, err := d.route(hello, tr)
317+ require.NoError(t, err)
318+ token := ac.resumeToken
319+ require.True(t, sess.detachIfCurrent(ac))
320+ require.True(t, d.parkAttachment(sess, ac))
321+
322+ resumeHello := helloResumeCapable(ports.IntentResume, "work", token)
323+ resumeHello.TrueColor = true
324+ d.mu.Lock()
325+ _, _, ok, err := d.resumeParkedLocked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
326+ d.mu.Unlock()
327+ require.NoError(t, err)
328+ require.True(t, ok)
329+ sess.mu.Lock()
330+ defer sess.mu.Unlock()
331+ require.True(t, sess.terminal.TrueColor)
332+}
M internal/usecase/daemon/session.go +29 -10
@@ -41,6 +41,10 @@ import (
4141 "github.com/bnema/vev/internal/usecase/layout"
4242 )
4343
44+type terminalEnv struct {
45+ TrueColor bool
46+}
47+
4448 type session struct {
4549 id domain.SessionID
4650 name string
@@ -56,6 +60,7 @@ type session struct {
5660 clipboardQueue []clipboardForward
5761 clipboardWorkerRunning bool
5862 cwd string
63+ terminal terminalEnv
5964 createdAt int64
6065 mruAt atomic.Uint64
6166 snapDirty atomic.Bool
@@ -121,13 +126,13 @@ func (d *Daemon) touchMRU(sess *session) {
121126 }
122127 }
123128
124-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
129+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv) (*session, error) {
125130 tbSize := tabSize(sz)
126131 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
127132 if err != nil {
128133 return nil, err
129134 }
130- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)
135+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
131136 if err != nil {
132137 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
133138 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
@@ -154,6 +159,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
154159 cancel: cancel,
155160 tabs: []*tab{tb},
156161 cwd: cwd,
162+ terminal: term,
157163 createdAt: createdAt,
158164 }
159165 if lastUsedSeq > 0 {
@@ -197,6 +203,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
197203 }
198204 from.mu.Lock()
199205 cwd := from.cwd
206+ term := from.terminal
200207 if from.client != ac {
201208 from.mu.Unlock()
202209 d.mu.Unlock()
@@ -204,7 +211,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
204211 }
205212 from.mu.Unlock()
206213
207- newSess, err := d.createSessionLocked(name, false, cwd, sz)
214+ newSess, err := d.createSessionLocked(name, false, cwd, sz, term)
208215 if err != nil {
209216 d.mu.Unlock()
210217 return err
@@ -238,12 +245,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
238245 name := sess.name
239246 cwd := sess.cwd
240247 client := sess.client
248+ term := sess.terminal
241249 sess.mu.Unlock()
242250 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
243251 if err != nil {
244252 return err
245253 }
246- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)
254+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
247255 if err != nil {
248256 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
249257 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
@@ -720,15 +728,26 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
720728 d.mu.Unlock()
721729 }
722730
723-// childEnv builds the session child's environment: the daemon's own, with TERM
724-// and VEV forced to well-known values.
725-func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string {
726- out := make([]string, 0, len(d.baseEnv)+2)
731+// childEnv builds the session child's environment: the daemon's own, with terminal
732+// and VEV values forced to well-known values.
733+func (d *Daemon) childEnv(name, tabStableID, paneStableID string, term terminalEnv) []string {
734+ out := make([]string, 0, len(d.baseEnv)+4)
727735 for _, e := range d.baseEnv {
728- if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") {
736+ if strings.HasPrefix(e, "TERM=") ||
737+ strings.HasPrefix(e, "COLORTERM=") ||
738+ strings.HasPrefix(e, "TERM_PROGRAM=") ||
739+ strings.HasPrefix(e, "VEV=") {
729740 continue
730741 }
731742 out = append(out, e)
732743 }
733- return append(out, "TERM=xterm-direct", "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID)
744+ if term.TrueColor {
745+ out = append(out, "TERM=xterm-direct", "COLORTERM=truecolor")
746+ } else {
747+ out = append(out, "TERM=xterm-256color")
748+ }
749+ return append(out,
750+ "TERM_PROGRAM=vev",
751+ "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID,
752+ )
734753 }
@@ -41,6 +41,10 @@ import (
41 "github.com/bnema/vev/internal/usecase/layout"41 "github.com/bnema/vev/internal/usecase/layout"
42 )42 )
43 43
44+type terminalEnv struct {
45+ TrueColor bool
46+}
47+
44 type session struct {48 type session struct {
45 id domain.SessionID49 id domain.SessionID
46 name string50 name string
@@ -56,6 +60,7 @@ type session struct {
56 clipboardQueue []clipboardForward60 clipboardQueue []clipboardForward
57 clipboardWorkerRunning bool61 clipboardWorkerRunning bool
58 cwd string62 cwd string
63+ terminal terminalEnv
59 createdAt int6464 createdAt int64
60 mruAt atomic.Uint6465 mruAt atomic.Uint64
61 snapDirty atomic.Bool66 snapDirty atomic.Bool
@@ -121,13 +126,13 @@ func (d *Daemon) touchMRU(sess *session) {
121 }126 }
122 }127 }
123 128
124-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {129+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv) (*session, error) {
125 tbSize := tabSize(sz)130 tbSize := tabSize(sz)
126 tabStableID, paneStableID, err := d.newTabPaneStableIDs()131 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
127 if err != nil {132 if err != nil {
128 return nil, err133 return nil, err
129 }134 }
130- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)135+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
131 if err != nil {136 if err != nil {
132 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")137 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
133 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)138 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
@@ -154,6 +159,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
154 cancel: cancel,159 cancel: cancel,
155 tabs: []*tab{tb},160 tabs: []*tab{tb},
156 cwd: cwd,161 cwd: cwd,
162+ terminal: term,
157 createdAt: createdAt,163 createdAt: createdAt,
158 }164 }
159 if lastUsedSeq > 0 {165 if lastUsedSeq > 0 {
@@ -197,6 +203,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
197 }203 }
198 from.mu.Lock()204 from.mu.Lock()
199 cwd := from.cwd205 cwd := from.cwd
206+ term := from.terminal
200 if from.client != ac {207 if from.client != ac {
201 from.mu.Unlock()208 from.mu.Unlock()
202 d.mu.Unlock()209 d.mu.Unlock()
@@ -204,7 +211,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
204 }211 }
205 from.mu.Unlock()212 from.mu.Unlock()
206 213
207- newSess, err := d.createSessionLocked(name, false, cwd, sz)214+ newSess, err := d.createSessionLocked(name, false, cwd, sz, term)
208 if err != nil {215 if err != nil {
209 d.mu.Unlock()216 d.mu.Unlock()
210 return err217 return err
@@ -238,12 +245,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
238 name := sess.name245 name := sess.name
239 cwd := sess.cwd246 cwd := sess.cwd
240 client := sess.client247 client := sess.client
248+ term := sess.terminal
241 sess.mu.Unlock()249 sess.mu.Unlock()
242 tabStableID, paneStableID, err := d.newTabPaneStableIDs()250 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
243 if err != nil {251 if err != nil {
244 return err252 return err
245 }253 }
246- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID), cwd, tbSize)254+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
247 if err != nil {255 if err != nil {
248 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")256 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
249 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)257 return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err)
@@ -720,15 +728,26 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
720 d.mu.Unlock()728 d.mu.Unlock()
721 }729 }
722 730
723-// childEnv builds the session child's environment: the daemon's own, with TERM731+// childEnv builds the session child's environment: the daemon's own, with terminal
724-// and VEV forced to well-known values.732+// and VEV values forced to well-known values.
725-func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string {733+func (d *Daemon) childEnv(name, tabStableID, paneStableID string, term terminalEnv) []string {
726- out := make([]string, 0, len(d.baseEnv)+2)734+ out := make([]string, 0, len(d.baseEnv)+4)
727 for _, e := range d.baseEnv {735 for _, e := range d.baseEnv {
728- if strings.HasPrefix(e, "TERM=") || strings.HasPrefix(e, "VEV=") {736+ if strings.HasPrefix(e, "TERM=") ||
737+ strings.HasPrefix(e, "COLORTERM=") ||
738+ strings.HasPrefix(e, "TERM_PROGRAM=") ||
739+ strings.HasPrefix(e, "VEV=") {
729 continue740 continue
730 }741 }
731 out = append(out, e)742 out = append(out, e)
732 }743 }
733- return append(out, "TERM=xterm-direct", "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID)744+ if term.TrueColor {
745+ out = append(out, "TERM=xterm-direct", "COLORTERM=truecolor")
746+ } else {
747+ out = append(out, "TERM=xterm-256color")
748+ }
749+ return append(out,
750+ "TERM_PROGRAM=vev",
751+ "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID,
752+ )
734 }753 }
M internal/usecase/daemon/session_test.go +136 -10
@@ -767,7 +767,7 @@ func TestCreateSessionSeedsMRUFromStopped(t *testing.T) {
767767 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42}
768768 d.mruSeq.Store(42)
769769
770- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
770+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
771771 require.NoError(t, err)
772772 require.Equal(t, uint64(42), sess.mruAt.Load())
773773 require.Equal(t, uint64(42), state.record(t, "work").LastUsedSeq)
@@ -781,7 +781,7 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
781781 d := newTestDaemon(t, newFactory(t, p), stubClock{})
782782 WithStore(store)(d)
783783
784- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
784+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
785785 require.NoError(t, err)
786786 require.True(t, state.has("work"))
787787
@@ -802,7 +802,7 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
802802 WithStore(store)(d)
803803 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
804804
805- sess, err := d.createSessionLocked("0", true, "/tmp/e", sz)
805+ sess, err := d.createSessionLocked("0", true, "/tmp/e", sz, terminalEnv{})
806806 require.NoError(t, err)
807807 require.False(t, state.has("0"))
808808 require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")
@@ -820,7 +820,7 @@ func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {
820820 cwd := "/tmp/work"
821821 WithCwdReader(func(int) (string, error) { return cwd, nil })(d)
822822
823- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
823+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
824824 require.NoError(t, err)
825825 state.mu.Lock()
826826 setsAfterCreate := state.sets
@@ -896,7 +896,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
896896 func TestChildEnvEscapesLegacySessionName(t *testing.T) {
897897 d := newTestDaemon(t, nil, stubClock{})
898898
899- got := d.childEnv("legacy,name=value", "t_alpha", "p_beta")
899+ got := d.childEnv("legacy,name=value", "t_alpha", "p_beta", terminalEnv{TrueColor: true})
900900
901901 require.Contains(t, got, "VEV=session=legacy%2Cname%3Dvalue,tab=t_alpha,pane=p_beta")
902902 }
@@ -915,9 +915,9 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
915915 },
916916 ).Once()
917917 d := newTestDaemon(t, f, stubClock{})
918- d.baseEnv = []string{"KEEP=1", "TERM=old", "VEV=old"}
918+ d.baseEnv = []string{"KEEP=1", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
919919
920- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
920+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{TrueColor: true})
921921 require.NoError(t, err)
922922 defer func() {
923923 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
@@ -939,8 +939,12 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
939939 require.NotEqual(t, tabStableID, paneStableID)
940940 require.Contains(t, gotEnv, "KEEP=1")
941941 require.NotContains(t, gotEnv, "TERM=old")
942+ require.NotContains(t, gotEnv, "COLORTERM=old")
943+ require.NotContains(t, gotEnv, "TERM_PROGRAM=old")
942944 require.NotContains(t, gotEnv, "VEV=old")
943945 require.Contains(t, gotEnv, "TERM=xterm-direct")
946+ require.Contains(t, gotEnv, "COLORTERM=truecolor")
947+ require.Contains(t, gotEnv, "TERM_PROGRAM=vev")
944948 require.Contains(t, gotEnv, "VEV=session=work,tab="+tabStableID+",pane="+paneStableID)
945949 }
946950
@@ -969,7 +973,7 @@ func TestRenameSessionUnsafeNameRejected(t *testing.T) {
969973 p, release := newBlockingPTY(t)
970974 defer release()
971975 d := newTestDaemon(t, newFactory(t, p), stubClock{})
972- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
976+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
973977 require.NoError(t, err)
974978 require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName)
975979 }
@@ -985,9 +989,9 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
985989 WithStore(store)(d)
986990 WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)
987991
988- natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz)
992+ natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz, terminalEnv{})
989993 require.NoError(t, err)
990- other, err := d.createSessionLocked("other", false, "/tmp/other", sz)
994+ other, err := d.createSessionLocked("other", false, "/tmp/other", sz, terminalEnv{})
991995 require.NoError(t, err)
992996 _ = d.killSession(natural, ports.ReasonSessionKilled, false)
993997 require.True(t, state.has("natural"))
@@ -999,3 +1003,125 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
9991003 _ = d.killSession(other, ports.ReasonSessionKilled, true)
10001004 require.False(t, state.has("other"))
10011005 }
1006+
1007+func TestChildEnvTrueColorCapability(t *testing.T) {
1008+ tests := []struct {
1009+ name string
1010+ baseEnv []string
1011+ term terminalEnv
1012+ wantContain []string
1013+ wantNotContain []string
1014+ }{
1015+ {
1016+ name: "exports truecolor",
1017+ baseEnv: []string{"KEEP=1", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"},
1018+ term: terminalEnv{TrueColor: true},
1019+ wantContain: []string{
1020+ "KEEP=1",
1021+ "TERM=xterm-direct",
1022+ "COLORTERM=truecolor",
1023+ "TERM_PROGRAM=vev",
1024+ "VEV=session=work,tab=t_alpha,pane=p_beta",
1025+ },
1026+ wantNotContain: []string{"TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"},
1027+ },
1028+ {
1029+ name: "omits truecolor when unsupported",
1030+ baseEnv: []string{"COLORTERM=old"},
1031+ term: terminalEnv{},
1032+ wantContain: []string{"TERM=xterm-256color", "TERM_PROGRAM=vev"},
1033+ wantNotContain: []string{"TERM=xterm-direct", "COLORTERM=truecolor"},
1034+ },
1035+ }
1036+
1037+ for _, tt := range tests {
1038+ t.Run(tt.name, func(t *testing.T) {
1039+ d := newTestDaemon(t, nil, stubClock{})
1040+ d.baseEnv = tt.baseEnv
1041+
1042+ got := d.childEnv("work", "t_alpha", "p_beta", tt.term)
1043+
1044+ for _, want := range tt.wantContain {
1045+ require.Contains(t, got, want)
1046+ }
1047+ for _, notWant := range tt.wantNotContain {
1048+ require.NotContains(t, got, notWant)
1049+ }
1050+ })
1051+ }
1052+}
1053+
1054+func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1055+ sz := domain.Size{Cols: 80, Rows: 24}
1056+ p1, release1 := newBlockingPTY(t)
1057+ defer release1()
1058+ p2, release2 := newBlockingPTY(t)
1059+ defer release2()
1060+
1061+ var opens [][]string
1062+ f := portsmocks.NewMockPTYFactory(t)
1063+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1064+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1065+ opens = append(opens, append([]string(nil), env...))
1066+ if len(opens) == 1 {
1067+ return p1, nil
1068+ }
1069+ return p2, nil
1070+ },
1071+ ).Twice()
1072+
1073+ d := newTestDaemon(t, f, stubClock{})
1074+ d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
1075+ tr := portsmocks.NewMockTransport(t)
1076+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1077+ tr.EXPECT().Close().Return(nil).Maybe()
1078+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
1079+ require.NoError(t, err)
1080+ defer func() {
1081+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
1082+ d.sessWg.Wait()
1083+ }()
1084+
1085+ require.Contains(t, opens[0], "TERM=xterm-direct")
1086+ require.Contains(t, opens[0], "COLORTERM=truecolor")
1087+ require.Contains(t, opens[0], "TERM_PROGRAM=vev")
1088+ require.NoError(t, d.createTab(sess, ac.size))
1089+ require.Contains(t, opens[1], "TERM=xterm-direct")
1090+ require.Contains(t, opens[1], "COLORTERM=truecolor")
1091+ require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1092+}
1093+
1094+func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1095+ sz := domain.Size{Cols: 80, Rows: 24}
1096+ p1, release1 := newBlockingPTY(t)
1097+ defer release1()
1098+ p2, release2 := newBlockingPTY(t)
1099+ defer release2()
1100+ var opens [][]string
1101+ f := portsmocks.NewMockPTYFactory(t)
1102+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1103+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1104+ opens = append(opens, append([]string(nil), env...))
1105+ if len(opens) == 1 {
1106+ return p1, nil
1107+ }
1108+ return p2, nil
1109+ },
1110+ ).Twice()
1111+ d := newTestDaemon(t, f, stubClock{})
1112+ tr := portsmocks.NewMockTransport(t)
1113+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1114+ tr.EXPECT().Close().Return(nil).Maybe()
1115+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
1116+ require.NoError(t, err)
1117+ require.NoError(t, d.createSessionAndSwitch(sess, ac, "next"))
1118+ got := ac.sess.Get()
1119+ require.NotNil(t, got)
1120+ got.mu.Lock()
1121+ defer got.mu.Unlock()
1122+ require.True(t, got.terminal.TrueColor)
1123+ require.Len(t, opens, 2)
1124+ require.Contains(t, opens[1], "TERM=xterm-direct")
1125+ require.Contains(t, opens[1], "COLORTERM=truecolor")
1126+ require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1127+}
@@ -767,7 +767,7 @@ func TestCreateSessionSeedsMRUFromStopped(t *testing.T) {
767 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42}767 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42}
768 d.mruSeq.Store(42)768 d.mruSeq.Store(42)
769 769
770- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)770+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
771 require.NoError(t, err)771 require.NoError(t, err)
772 require.Equal(t, uint64(42), sess.mruAt.Load())772 require.Equal(t, uint64(42), sess.mruAt.Load())
773 require.Equal(t, uint64(42), state.record(t, "work").LastUsedSeq)773 require.Equal(t, uint64(42), state.record(t, "work").LastUsedSeq)
@@ -781,7 +781,7 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
781 d := newTestDaemon(t, newFactory(t, p), stubClock{})781 d := newTestDaemon(t, newFactory(t, p), stubClock{})
782 WithStore(store)(d)782 WithStore(store)(d)
783 783
784- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)784+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
785 require.NoError(t, err)785 require.NoError(t, err)
786 require.True(t, state.has("work"))786 require.True(t, state.has("work"))
787 787
@@ -802,7 +802,7 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
802 WithStore(store)(d)802 WithStore(store)(d)
803 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}803 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
804 804
805- sess, err := d.createSessionLocked("0", true, "/tmp/e", sz)805+ sess, err := d.createSessionLocked("0", true, "/tmp/e", sz, terminalEnv{})
806 require.NoError(t, err)806 require.NoError(t, err)
807 require.False(t, state.has("0"))807 require.False(t, state.has("0"))
808 require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")808 require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")
@@ -820,7 +820,7 @@ func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {
820 cwd := "/tmp/work"820 cwd := "/tmp/work"
821 WithCwdReader(func(int) (string, error) { return cwd, nil })(d)821 WithCwdReader(func(int) (string, error) { return cwd, nil })(d)
822 822
823- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)823+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
824 require.NoError(t, err)824 require.NoError(t, err)
825 state.mu.Lock()825 state.mu.Lock()
826 setsAfterCreate := state.sets826 setsAfterCreate := state.sets
@@ -896,7 +896,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
896 func TestChildEnvEscapesLegacySessionName(t *testing.T) {896 func TestChildEnvEscapesLegacySessionName(t *testing.T) {
897 d := newTestDaemon(t, nil, stubClock{})897 d := newTestDaemon(t, nil, stubClock{})
898 898
899- got := d.childEnv("legacy,name=value", "t_alpha", "p_beta")899+ got := d.childEnv("legacy,name=value", "t_alpha", "p_beta", terminalEnv{TrueColor: true})
900 900
901 require.Contains(t, got, "VEV=session=legacy%2Cname%3Dvalue,tab=t_alpha,pane=p_beta")901 require.Contains(t, got, "VEV=session=legacy%2Cname%3Dvalue,tab=t_alpha,pane=p_beta")
902 }902 }
@@ -915,9 +915,9 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
915 },915 },
916 ).Once()916 ).Once()
917 d := newTestDaemon(t, f, stubClock{})917 d := newTestDaemon(t, f, stubClock{})
918- d.baseEnv = []string{"KEEP=1", "TERM=old", "VEV=old"}918+ d.baseEnv = []string{"KEEP=1", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
919 919
920- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)920+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{TrueColor: true})
921 require.NoError(t, err)921 require.NoError(t, err)
922 defer func() {922 defer func() {
923 _ = d.killSession(sess, ports.ReasonServerShutdown, false)923 _ = d.killSession(sess, ports.ReasonServerShutdown, false)
@@ -939,8 +939,12 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
939 require.NotEqual(t, tabStableID, paneStableID)939 require.NotEqual(t, tabStableID, paneStableID)
940 require.Contains(t, gotEnv, "KEEP=1")940 require.Contains(t, gotEnv, "KEEP=1")
941 require.NotContains(t, gotEnv, "TERM=old")941 require.NotContains(t, gotEnv, "TERM=old")
942+ require.NotContains(t, gotEnv, "COLORTERM=old")
943+ require.NotContains(t, gotEnv, "TERM_PROGRAM=old")
942 require.NotContains(t, gotEnv, "VEV=old")944 require.NotContains(t, gotEnv, "VEV=old")
943 require.Contains(t, gotEnv, "TERM=xterm-direct")945 require.Contains(t, gotEnv, "TERM=xterm-direct")
946+ require.Contains(t, gotEnv, "COLORTERM=truecolor")
947+ require.Contains(t, gotEnv, "TERM_PROGRAM=vev")
944 require.Contains(t, gotEnv, "VEV=session=work,tab="+tabStableID+",pane="+paneStableID)948 require.Contains(t, gotEnv, "VEV=session=work,tab="+tabStableID+",pane="+paneStableID)
945 }949 }
946 950
@@ -969,7 +973,7 @@ func TestRenameSessionUnsafeNameRejected(t *testing.T) {
969 p, release := newBlockingPTY(t)973 p, release := newBlockingPTY(t)
970 defer release()974 defer release()
971 d := newTestDaemon(t, newFactory(t, p), stubClock{})975 d := newTestDaemon(t, newFactory(t, p), stubClock{})
972- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)976+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
973 require.NoError(t, err)977 require.NoError(t, err)
974 require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName)978 require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName)
975 }979 }
@@ -985,9 +989,9 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
985 WithStore(store)(d)989 WithStore(store)(d)
986 WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)990 WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)
987 991
988- natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz)992+ natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz, terminalEnv{})
989 require.NoError(t, err)993 require.NoError(t, err)
990- other, err := d.createSessionLocked("other", false, "/tmp/other", sz)994+ other, err := d.createSessionLocked("other", false, "/tmp/other", sz, terminalEnv{})
991 require.NoError(t, err)995 require.NoError(t, err)
992 _ = d.killSession(natural, ports.ReasonSessionKilled, false)996 _ = d.killSession(natural, ports.ReasonSessionKilled, false)
993 require.True(t, state.has("natural"))997 require.True(t, state.has("natural"))
@@ -999,3 +1003,125 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
999 _ = d.killSession(other, ports.ReasonSessionKilled, true)1003 _ = d.killSession(other, ports.ReasonSessionKilled, true)
1000 require.False(t, state.has("other"))1004 require.False(t, state.has("other"))
1001 }1005 }
1006+
1007+func TestChildEnvTrueColorCapability(t *testing.T) {
1008+ tests := []struct {
1009+ name string
1010+ baseEnv []string
1011+ term terminalEnv
1012+ wantContain []string
1013+ wantNotContain []string
1014+ }{
1015+ {
1016+ name: "exports truecolor",
1017+ baseEnv: []string{"KEEP=1", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"},
1018+ term: terminalEnv{TrueColor: true},
1019+ wantContain: []string{
1020+ "KEEP=1",
1021+ "TERM=xterm-direct",
1022+ "COLORTERM=truecolor",
1023+ "TERM_PROGRAM=vev",
1024+ "VEV=session=work,tab=t_alpha,pane=p_beta",
1025+ },
1026+ wantNotContain: []string{"TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"},
1027+ },
1028+ {
1029+ name: "omits truecolor when unsupported",
1030+ baseEnv: []string{"COLORTERM=old"},
1031+ term: terminalEnv{},
1032+ wantContain: []string{"TERM=xterm-256color", "TERM_PROGRAM=vev"},
1033+ wantNotContain: []string{"TERM=xterm-direct", "COLORTERM=truecolor"},
1034+ },
1035+ }
1036+
1037+ for _, tt := range tests {
1038+ t.Run(tt.name, func(t *testing.T) {
1039+ d := newTestDaemon(t, nil, stubClock{})
1040+ d.baseEnv = tt.baseEnv
1041+
1042+ got := d.childEnv("work", "t_alpha", "p_beta", tt.term)
1043+
1044+ for _, want := range tt.wantContain {
1045+ require.Contains(t, got, want)
1046+ }
1047+ for _, notWant := range tt.wantNotContain {
1048+ require.NotContains(t, got, notWant)
1049+ }
1050+ })
1051+ }
1052+}
1053+
1054+func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1055+ sz := domain.Size{Cols: 80, Rows: 24}
1056+ p1, release1 := newBlockingPTY(t)
1057+ defer release1()
1058+ p2, release2 := newBlockingPTY(t)
1059+ defer release2()
1060+
1061+ var opens [][]string
1062+ f := portsmocks.NewMockPTYFactory(t)
1063+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1064+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1065+ opens = append(opens, append([]string(nil), env...))
1066+ if len(opens) == 1 {
1067+ return p1, nil
1068+ }
1069+ return p2, nil
1070+ },
1071+ ).Twice()
1072+
1073+ d := newTestDaemon(t, f, stubClock{})
1074+ d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
1075+ tr := portsmocks.NewMockTransport(t)
1076+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1077+ tr.EXPECT().Close().Return(nil).Maybe()
1078+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
1079+ require.NoError(t, err)
1080+ defer func() {
1081+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
1082+ d.sessWg.Wait()
1083+ }()
1084+
1085+ require.Contains(t, opens[0], "TERM=xterm-direct")
1086+ require.Contains(t, opens[0], "COLORTERM=truecolor")
1087+ require.Contains(t, opens[0], "TERM_PROGRAM=vev")
1088+ require.NoError(t, d.createTab(sess, ac.size))
1089+ require.Contains(t, opens[1], "TERM=xterm-direct")
1090+ require.Contains(t, opens[1], "COLORTERM=truecolor")
1091+ require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1092+}
1093+
1094+func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1095+ sz := domain.Size{Cols: 80, Rows: 24}
1096+ p1, release1 := newBlockingPTY(t)
1097+ defer release1()
1098+ p2, release2 := newBlockingPTY(t)
1099+ defer release2()
1100+ var opens [][]string
1101+ f := portsmocks.NewMockPTYFactory(t)
1102+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1103+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1104+ opens = append(opens, append([]string(nil), env...))
1105+ if len(opens) == 1 {
1106+ return p1, nil
1107+ }
1108+ return p2, nil
1109+ },
1110+ ).Twice()
1111+ d := newTestDaemon(t, f, stubClock{})
1112+ tr := portsmocks.NewMockTransport(t)
1113+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1114+ tr.EXPECT().Close().Return(nil).Maybe()
1115+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
1116+ require.NoError(t, err)
1117+ require.NoError(t, d.createSessionAndSwitch(sess, ac, "next"))
1118+ got := ac.sess.Get()
1119+ require.NotNil(t, got)
1120+ got.mu.Lock()
1121+ defer got.mu.Unlock()
1122+ require.True(t, got.terminal.TrueColor)
1123+ require.Len(t, opens, 2)
1124+ require.Contains(t, opens[1], "TERM=xterm-direct")
1125+ require.Contains(t, opens[1], "COLORTERM=truecolor")
1126+ require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1127+}
M internal/usecase/daemon/snapshot.go +6 -2
@@ -67,6 +67,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
6767
6868 sctx, cancel := context.WithCancel(d.serveCtx)
6969 opened := make([]*tab, 0, len(snap.Tabs))
70+ // Snapshot restore runs before any client Hello is available, so restored
71+ // panes start with the conservative terminal environment. The next attach or
72+ // resume updates sess.terminal for future panes from the client's capability.
73+ restoreTerm := terminalEnv{}
7074 closeOpened := func() {
7175 for _, tb := range opened {
7276 tb.closeAllPanes()
@@ -132,7 +136,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
132136 return fmt.Errorf("snapshot: generating pane identity: %w", err)
133137 }
134138 }
135- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID), paneSnap.Cwd, contentSize)
139+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
136140 if err != nil {
137141 closeOpened()
138142 return err
@@ -154,7 +158,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
154158 return nil
155159 }
156160 createdAt := int64(snap.CreatedAt)
157- sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), createdAt: createdAt}
161+ sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt}
158162 if sess.active < 0 || sess.active >= len(sess.tabs) {
159163 sess.active = 0
160164 }
@@ -67,6 +67,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
67 67
68 sctx, cancel := context.WithCancel(d.serveCtx)68 sctx, cancel := context.WithCancel(d.serveCtx)
69 opened := make([]*tab, 0, len(snap.Tabs))69 opened := make([]*tab, 0, len(snap.Tabs))
70+ // Snapshot restore runs before any client Hello is available, so restored
71+ // panes start with the conservative terminal environment. The next attach or
72+ // resume updates sess.terminal for future panes from the client's capability.
73+ restoreTerm := terminalEnv{}
70 closeOpened := func() {74 closeOpened := func() {
71 for _, tb := range opened {75 for _, tb := range opened {
72 tb.closeAllPanes()76 tb.closeAllPanes()
@@ -132,7 +136,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
132 return fmt.Errorf("snapshot: generating pane identity: %w", err)136 return fmt.Errorf("snapshot: generating pane identity: %w", err)
133 }137 }
134 }138 }
135- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID), paneSnap.Cwd, contentSize)139+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(snap.Name, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, contentSize)
136 if err != nil {140 if err != nil {
137 closeOpened()141 closeOpened()
138 return err142 return err
@@ -154,7 +158,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
154 return nil158 return nil
155 }159 }
156 createdAt := int64(snap.CreatedAt)160 createdAt := int64(snap.CreatedAt)
157- sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), createdAt: createdAt}161+ sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: opened, active: int(snap.Active), terminal: restoreTerm, createdAt: createdAt}
158 if sess.active < 0 || sess.active >= len(sess.tabs) {162 if sess.active < 0 || sess.active >= len(sess.tabs) {
159 sess.active = 0163 sess.active = 0
160 }164 }
M internal/usecase/daemon/snapshot_restore_test.go +8 -2
@@ -88,9 +88,15 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
8888 require.Equal(t, "t_saved", tabStableID)
8989 require.Equal(t, "p_saved_1", pane1StableID)
9090 require.Equal(t, "p_saved_2", pane2StableID)
91- require.Contains(t, factory.opens[0].env, "TERM=xterm-direct")
91+ require.Contains(t, factory.opens[0].env, "TERM=xterm-256color")
92+ require.NotContains(t, factory.opens[0].env, "TERM=xterm-direct")
93+ require.NotContains(t, factory.opens[0].env, "COLORTERM=truecolor")
94+ require.Contains(t, factory.opens[0].env, "TERM_PROGRAM=vev")
9295 require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID)
93- require.Contains(t, factory.opens[1].env, "TERM=xterm-direct")
96+ require.Contains(t, factory.opens[1].env, "TERM=xterm-256color")
97+ require.NotContains(t, factory.opens[1].env, "TERM=xterm-direct")
98+ require.NotContains(t, factory.opens[1].env, "COLORTERM=truecolor")
99+ require.Contains(t, factory.opens[1].env, "TERM_PROGRAM=vev")
94100 require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
95101 }
96102
@@ -88,9 +88,15 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
88 require.Equal(t, "t_saved", tabStableID)88 require.Equal(t, "t_saved", tabStableID)
89 require.Equal(t, "p_saved_1", pane1StableID)89 require.Equal(t, "p_saved_1", pane1StableID)
90 require.Equal(t, "p_saved_2", pane2StableID)90 require.Equal(t, "p_saved_2", pane2StableID)
91- require.Contains(t, factory.opens[0].env, "TERM=xterm-direct")91+ require.Contains(t, factory.opens[0].env, "TERM=xterm-256color")
92+ require.NotContains(t, factory.opens[0].env, "TERM=xterm-direct")
93+ require.NotContains(t, factory.opens[0].env, "COLORTERM=truecolor")
94+ require.Contains(t, factory.opens[0].env, "TERM_PROGRAM=vev")
92 require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID)95 require.Contains(t, factory.opens[0].env, "VEV=session=work,tab="+tabStableID+",pane="+pane1StableID)
93- require.Contains(t, factory.opens[1].env, "TERM=xterm-direct")96+ require.Contains(t, factory.opens[1].env, "TERM=xterm-256color")
97+ require.NotContains(t, factory.opens[1].env, "TERM=xterm-direct")
98+ require.NotContains(t, factory.opens[1].env, "COLORTERM=truecolor")
99+ require.Contains(t, factory.opens[1].env, "TERM_PROGRAM=vev")
94 require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)100 require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
95 }101 }
96 102
M internal/usecase/daemon/status_test.go +2 -2
@@ -556,9 +556,9 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
556556 cur := &session{id: "cur", name: "current", tabs: []*tab{{}}}
557557 d.sessions[cur.id] = cur
558558
559- _, err := d.createSessionLocked("zeta", false, "/tmp/zeta", sz)
559+ _, err := d.createSessionLocked("zeta", false, "/tmp/zeta", sz, terminalEnv{})
560560 require.NoError(t, err)
561- _, err = d.createSessionLocked("alpha", false, "/tmp/alpha", sz)
561+ _, err = d.createSessionLocked("alpha", false, "/tmp/alpha", sz, terminalEnv{})
562562 require.NoError(t, err)
563563
564564 state := d.barStateFor(cur, "")
@@ -556,9 +556,9 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
556 cur := &session{id: "cur", name: "current", tabs: []*tab{{}}}556 cur := &session{id: "cur", name: "current", tabs: []*tab{{}}}
557 d.sessions[cur.id] = cur557 d.sessions[cur.id] = cur
558 558
559- _, err := d.createSessionLocked("zeta", false, "/tmp/zeta", sz)559+ _, err := d.createSessionLocked("zeta", false, "/tmp/zeta", sz, terminalEnv{})
560 require.NoError(t, err)560 require.NoError(t, err)
561- _, err = d.createSessionLocked("alpha", false, "/tmp/alpha", sz)561+ _, err = d.createSessionLocked("alpha", false, "/tmp/alpha", sz, terminalEnv{})
562 require.NoError(t, err)562 require.NoError(t, err)
563 563
564 state := d.barStateFor(cur, "")564 state := d.barStateFor(cur, "")

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