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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
22 files · +475 -46
M README.md +6 -0
| @@ -131,6 +131,12 @@ Host * | |||
| 131 | 131 | 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 | 140 | ## Keys | |
| 135 | 141 | ||
| 136 | 142 | All bindings use Alt directly; there is no prefix key. | |
| @@ -131,6 +131,12 @@ Host * | |||||
| 131 | ControlPersist 10m | 131 | 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 | ## Keys | 140 | ## 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 ( | |||
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 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 | 20 | tests := []struct { | |
| 19 | 21 | name string | |
| 20 | 22 | 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 string | 21 | name string | ||
| 20 | target string | 22 | target string | ||
M internal/app/run_test.go +2 -0
| @@ -423,6 +423,8 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 423 | 423 | type fakeClipboardReader struct{ ports.ClipboardReader } | |
| 424 | 424 | ||
| 425 | 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 | 428 | var gotTarget, gotSession, gotDialer string | |
| 427 | 429 | var gotRemote bool | |
| 428 | 430 | 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 string | 428 | var gotTarget, gotSession, gotDialer string | ||
| 427 | var gotRemote bool | 429 | var gotRemote bool | ||
| 428 | var gotClipboard ports.ClipboardReader | 430 | var gotClipboard ports.ClipboardReader | ||
M internal/ports/frame.go +1 -1
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 8 | |
| 4 | + | const ProtocolVersion uint16 = 9 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | 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 = 8 | 4 | + | 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 uint8 | 7 | type MsgType uint8 | ||
M internal/ports/frame_test.go +2 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | 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) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -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 { | |||
| 65 | 65 | Size domain.Size | |
| 66 | 66 | TermEnv string | |
| 67 | 67 | Cwd string | |
| 68 | + | TrueColor bool | |
| 68 | 69 | } | |
| 69 | 70 | ||
| 70 | 71 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | |
| @@ -303,6 +304,11 @@ func MarshalHello(h Hello) []byte { | |||
| 303 | 304 | w.putUint16(uint16(h.Size.Rows)) | |
| 304 | 305 | w.putString(h.TermEnv) | |
| 305 | 306 | w.putString(h.Cwd) | |
| 307 | + | if h.TrueColor { | |
| 308 | + | w.putUint8(1) | |
| 309 | + | } else { | |
| 310 | + | w.putUint8(0) | |
| 311 | + | } | |
| 306 | 312 | return w.b | |
| 307 | 313 | } | |
| 308 | 314 | ||
| @@ -344,6 +350,11 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 344 | 350 | if h.Cwd, err = r.getString(); err != nil { | |
| 345 | 351 | 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 | 358 | if err := r.done(); err != nil { | |
| 348 | 359 | return Hello{}, err | |
| 349 | 360 | } | |
| @@ -65,6 +65,7 @@ type Hello struct { | |||||
| 65 | Size domain.Size | 65 | Size domain.Size | ||
| 66 | TermEnv string | 66 | TermEnv string | ||
| 67 | Cwd string | 67 | 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.b | 312 | 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{}, err | 351 | 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{}, err | 359 | return Hello{}, err | ||
| 349 | } | 360 | } | ||
M internal/ports/wire_test.go +10 -8
| @@ -68,20 +68,22 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||
| 68 | 68 | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 69 | 69 | TermEnv: "xterm-256color", | |
| 70 | 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 | 76 | name: "empty strings", | |
| 76 | 77 | 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, | |
| 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 | ||
| @@ -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 { | |||
| 332 | 332 | 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 | 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 | 346 | // 1. Handshake: send Hello with our size and TERM. | |
| 337 | 347 | size, err := term.Size() | |
| @@ -342,8 +352,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 342 | 352 | if err != nil { | |
| 343 | 353 | cwd = "" | |
| 344 | 354 | } | |
| 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) | |
| 347 | 358 | hello := ports.Hello{ | |
| 348 | 359 | Version: ports.ProtocolVersion, | |
| 349 | 360 | Intent: intent, | |
| @@ -351,8 +362,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 351 | 362 | ResumeToken: resumeToken, | |
| 352 | 363 | Name: name, | |
| 353 | 364 | Size: size, | |
| 354 | - | TermEnv: os.Getenv("TERM"), | |
| 365 | + | TermEnv: termEnv, | |
| 355 | 366 | Cwd: cwd, | |
| 367 | + | TrueColor: trueColor, | |
| 356 | 368 | } | |
| 357 | 369 | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | |
| 358 | 370 | return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)} | |
| @@ -332,6 +332,16 @@ type attachResult struct { | |||||
| 332 | err error | 332 | 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 { | |||
| 151 | 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 | 191 | func TestAttachHappyPath(t *testing.T) { | |
| 155 | 192 | var out bytes.Buffer | |
| 156 | 193 | var restoreCount atomic.Int32 | |
| @@ -332,6 +369,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) { | |||
| 332 | 369 | } | |
| 333 | 370 | ||
| 334 | 371 | func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) { | |
| 372 | + | t.Setenv("TERM", "xterm-256color") | |
| 335 | 373 | t.Setenv("COLORTERM", "") | |
| 336 | 374 | ||
| 337 | 375 | 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.Buffer | 192 | var out bytes.Buffer | ||
| 156 | var restoreCount atomic.Int32 | 193 | 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.Buffer | 375 | 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 | |||
| 598 | 598 | return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | |
| 599 | 599 | } | |
| 600 | 600 | } | |
| 601 | + | term := terminalEnv{TrueColor: h.TrueColor} | |
| 601 | 602 | ||
| 602 | 603 | d.mu.Lock() | |
| 603 | 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 | 626 | cwd := d.dirOrHome(stopped.cwd) | |
| 626 | 627 | 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 | 629 | if err != nil { | |
| 629 | 630 | d.mu.Unlock() | |
| 630 | 631 | return nil, nil, err | |
| @@ -632,19 +633,25 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 632 | 633 | } | |
| 633 | 634 | d.purgeParkedForSessionLocked(sess) | |
| 634 | 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 | 639 | d.mu.Unlock() | |
| 636 | 640 | d.detachReplacedClient(old) | |
| 637 | 641 | return sess, ac, nil | |
| 638 | 642 | ||
| 639 | 643 | case ports.IntentEphemeral: | |
| 640 | 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 | 646 | if err != nil { | |
| 643 | 647 | d.mu.Unlock() | |
| 644 | 648 | return nil, nil, err | |
| 645 | 649 | } | |
| 646 | 650 | d.purgeParkedForSessionLocked(sess) | |
| 647 | 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 | 655 | d.mu.Unlock() | |
| 649 | 656 | d.detachReplacedClient(old) | |
| 650 | 657 | return sess, ac, nil | |
| @@ -662,13 +669,16 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 662 | 669 | d.mu.Unlock() | |
| 663 | 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 | 673 | if err != nil { | |
| 667 | 674 | d.mu.Unlock() | |
| 668 | 675 | return nil, nil, err | |
| 669 | 676 | } | |
| 670 | 677 | d.purgeParkedForSessionLocked(sess) | |
| 671 | 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 | 682 | d.mu.Unlock() | |
| 673 | 683 | d.detachReplacedClient(old) | |
| 674 | 684 | return sess, ac, nil | |
| @@ -683,7 +693,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 683 | 693 | } | |
| 684 | 694 | cwd := d.dirOrHome(stopped.cwd) | |
| 685 | 695 | 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 | 697 | if err != nil { | |
| 688 | 698 | d.mu.Unlock() | |
| 689 | 699 | return nil, nil, err | |
| @@ -691,6 +701,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 691 | 701 | } | |
| 692 | 702 | d.purgeParkedForSessionLocked(sess) | |
| 693 | 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 | 707 | d.mu.Unlock() | |
| 695 | 708 | d.detachReplacedClient(old) | |
| 696 | 709 | 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 error | 627 | 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, err | 631 | 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, nil | 641 | 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, err | 648 | 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, nil | 657 | 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, err | 675 | 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, nil | 684 | 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 error | 695 | 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, err | 699 | 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, nil | 709 | return sess, ac, nil | ||
M internal/usecase/daemon/pane_actions.go +2 -1
| @@ -34,6 +34,7 @@ func (d *Daemon) spawnPaneOp( | |||
| 34 | 34 | ||
| 35 | 35 | sess.mu.Lock() | |
| 36 | 36 | name, cwd := sess.name, sess.cwd | |
| 37 | + | term := sess.terminal | |
| 37 | 38 | sess.mu.Unlock() | |
| 38 | 39 | ||
| 39 | 40 | tb.mu.Lock() | |
| @@ -68,7 +69,7 @@ func (d *Daemon) spawnPaneOp( | |||
| 68 | 69 | tb.mu.Unlock() | |
| 69 | 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 | 73 | if err != nil { | |
| 73 | 74 | d.log.Warn("pty spawn failed", "err", err, "session", name, "pane", newID, "kind", "pane") | |
| 74 | 75 | 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.cwd | 36 | 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 ( | |||
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | 17 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -320,3 +321,49 @@ func blockingRead(t *testing.T) func([]byte) (int, error) { | |||
| 320 | 321 | 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 | + | } | |
| @@ -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.EOF | 321 | 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 | |||
| 389 | 389 | d.mu.Unlock() | |
| 390 | 390 | return nil | |
| 391 | 391 | } | |
| 392 | + | term := from.terminal | |
| 392 | 393 | from.client = nil | |
| 393 | 394 | ac.setSession(nil) | |
| 394 | 395 | from.mu.Unlock() | |
| 395 | 396 | ||
| 396 | 397 | targetSess.mu.Lock() | |
| 397 | 398 | old := targetSess.client | |
| 399 | + | targetSess.terminal = term | |
| 398 | 400 | targetSess.client = ac | |
| 399 | 401 | if target.TabIndex >= 0 && target.TabIndex < len(targetSess.tabs) { | |
| 400 | 402 | targetSess.active = target.TabIndex | |
| @@ -420,8 +422,9 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 420 | 422 | d.mu.Unlock() | |
| 421 | 423 | return | |
| 422 | 424 | } | |
| 425 | + | term := from.terminal | |
| 423 | 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 | 428 | if err != nil { | |
| 426 | 429 | from.mu.Unlock() | |
| 427 | 430 | 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 nil | 390 | return nil | ||
| 391 | } | 391 | } | ||
| 392 | + | term := from.terminal | |||
| 392 | from.client = nil | 393 | 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.client | 398 | old := targetSess.client | ||
| 399 | + | targetSess.terminal = term | |||
| 398 | targetSess.client = ac | 400 | 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.TabIndex | 402 | 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 | return | 423 | 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 ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | + | "github.com/stretchr/testify/mock" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/vev/internal/domain" | |
| 12 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 13 | 15 | "github.com/bnema/vev/internal/usecase/keys" | |
| 14 | 16 | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 15 | 18 | "github.com/bnema/vev/pkg/renderer" | |
| 16 | 19 | ) | |
| 17 | 20 | ||
| @@ -181,6 +184,47 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||
| 181 | 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 | 228 | func TestPickerPreviewSinglePaneSnapshotsFocusedPane(t *testing.T) { | |
| 185 | 229 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |
| 186 | 230 | p := tb.focusedPane() | |
| @@ -358,3 +402,39 @@ func TestPickerResizeRecomposesModal(t *testing.T) { | |||
| 358 | 402 | require.Contains(t, string(msg.Data), "┌") | |
| 359 | 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 | + | } | |
| @@ -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 | |||
| 149 | 149 | ac.setSession(sess) | |
| 150 | 150 | sess.mu.Lock() | |
| 151 | 151 | sess.client = ac | |
| 152 | + | sess.terminal = terminalEnv{TrueColor: h.TrueColor} | |
| 152 | 153 | sess.mu.Unlock() | |
| 153 | 154 | d.touchMRU(sess) | |
| 154 | 155 | 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 = ac | 151 | 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) { | |||
| 305 | 305 | ||
| 306 | 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 | + | } | |
| @@ -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 ( | |||
| 41 | 41 | "github.com/bnema/vev/internal/usecase/layout" | |
| 42 | 42 | ) | |
| 43 | 43 | ||
| 44 | + | type terminalEnv struct { | |
| 45 | + | TrueColor bool | |
| 46 | + | } | |
| 47 | + | ||
| 44 | 48 | type session struct { | |
| 45 | 49 | id domain.SessionID | |
| 46 | 50 | name string | |
| @@ -56,6 +60,7 @@ type session struct { | |||
| 56 | 60 | clipboardQueue []clipboardForward | |
| 57 | 61 | clipboardWorkerRunning bool | |
| 58 | 62 | cwd string | |
| 63 | + | terminal terminalEnv | |
| 59 | 64 | createdAt int64 | |
| 60 | 65 | mruAt atomic.Uint64 | |
| 61 | 66 | 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 | 130 | tbSize := tabSize(sz) | |
| 126 | 131 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | |
| 127 | 132 | if err != nil { | |
| 128 | 133 | 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 | 136 | if err != nil { | |
| 132 | 137 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session") | |
| 133 | 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 | 159 | cancel: cancel, | |
| 155 | 160 | tabs: []*tab{tb}, | |
| 156 | 161 | cwd: cwd, | |
| 162 | + | terminal: term, | |
| 157 | 163 | createdAt: createdAt, | |
| 158 | 164 | } | |
| 159 | 165 | if lastUsedSeq > 0 { | |
| @@ -197,6 +203,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 197 | 203 | } | |
| 198 | 204 | from.mu.Lock() | |
| 199 | 205 | cwd := from.cwd | |
| 206 | + | term := from.terminal | |
| 200 | 207 | if from.client != ac { | |
| 201 | 208 | from.mu.Unlock() | |
| 202 | 209 | d.mu.Unlock() | |
| @@ -204,7 +211,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 204 | 211 | } | |
| 205 | 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 | 215 | if err != nil { | |
| 209 | 216 | d.mu.Unlock() | |
| 210 | 217 | return err | |
| @@ -238,12 +245,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 238 | 245 | name := sess.name | |
| 239 | 246 | cwd := sess.cwd | |
| 240 | 247 | client := sess.client | |
| 248 | + | term := sess.terminal | |
| 241 | 249 | sess.mu.Unlock() | |
| 242 | 250 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | |
| 243 | 251 | if err != nil { | |
| 244 | 252 | 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 | 255 | if err != nil { | |
| 248 | 256 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | |
| 249 | 257 | return fmt.Errorf("daemon: spawning tab for session %q: %w", name, err) | |
| @@ -720,15 +728,26 @@ func (d *Daemon) refreshSessionCwd(sess *session) { | |||
| 720 | 728 | d.mu.Unlock() | |
| 721 | 729 | } | |
| 722 | 730 | ||
| 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) | |
| 727 | 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 | 740 | continue | |
| 730 | 741 | } | |
| 731 | 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 | } | |
| @@ -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.SessionID | 49 | id domain.SessionID | ||
| 46 | name string | 50 | name string | ||
| @@ -56,6 +60,7 @@ type session struct { | |||||
| 56 | clipboardQueue []clipboardForward | 60 | clipboardQueue []clipboardForward | ||
| 57 | clipboardWorkerRunning bool | 61 | clipboardWorkerRunning bool | ||
| 58 | cwd string | 62 | cwd string | ||
| 63 | + | terminal terminalEnv | |||
| 59 | createdAt int64 | 64 | createdAt int64 | ||
| 60 | mruAt atomic.Uint64 | 65 | mruAt atomic.Uint64 | ||
| 61 | snapDirty atomic.Bool | 66 | 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, err | 133 | 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.cwd | 205 | 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 err | 217 | return err | ||
| @@ -238,12 +245,13 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||||
| 238 | name := sess.name | 245 | name := sess.name | ||
| 239 | cwd := sess.cwd | 246 | cwd := sess.cwd | ||
| 240 | client := sess.client | 247 | 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 err | 252 | 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 TERM | 731 | + | // 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 | continue | 740 | 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) { | |||
| 767 | 767 | d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 1, lastUsedSeq: 42} | |
| 768 | 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 | 771 | require.NoError(t, err) | |
| 772 | 772 | require.Equal(t, uint64(42), sess.mruAt.Load()) | |
| 773 | 773 | require.Equal(t, uint64(42), state.record(t, "work").LastUsedSeq) | |
| @@ -781,7 +781,7 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | |||
| 781 | 781 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 782 | 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 | 785 | require.NoError(t, err) | |
| 786 | 786 | require.True(t, state.has("work")) | |
| 787 | 787 | ||
| @@ -802,7 +802,7 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) { | |||
| 802 | 802 | WithStore(store)(d) | |
| 803 | 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 | 806 | require.NoError(t, err) | |
| 807 | 807 | require.False(t, state.has("0")) | |
| 808 | 808 | require.EqualError(t, d.renameSession(sess, "taken"), "name already in use") | |
| @@ -820,7 +820,7 @@ func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) { | |||
| 820 | 820 | cwd := "/tmp/work" | |
| 821 | 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 | 824 | require.NoError(t, err) | |
| 825 | 825 | state.mu.Lock() | |
| 826 | 826 | setsAfterCreate := state.sets | |
| @@ -896,7 +896,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) { | |||
| 896 | 896 | func TestChildEnvEscapesLegacySessionName(t *testing.T) { | |
| 897 | 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 | 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 | 916 | ).Once() | |
| 917 | 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 | 921 | require.NoError(t, err) | |
| 922 | 922 | defer func() { | |
| 923 | 923 | _ = d.killSession(sess, ports.ReasonServerShutdown, false) | |
| @@ -939,8 +939,12 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) { | |||
| 939 | 939 | require.NotEqual(t, tabStableID, paneStableID) | |
| 940 | 940 | require.Contains(t, gotEnv, "KEEP=1") | |
| 941 | 941 | require.NotContains(t, gotEnv, "TERM=old") | |
| 942 | + | require.NotContains(t, gotEnv, "COLORTERM=old") | |
| 943 | + | require.NotContains(t, gotEnv, "TERM_PROGRAM=old") | |
| 942 | 944 | require.NotContains(t, gotEnv, "VEV=old") | |
| 943 | 945 | require.Contains(t, gotEnv, "TERM=xterm-direct") | |
| 946 | + | require.Contains(t, gotEnv, "COLORTERM=truecolor") | |
| 947 | + | require.Contains(t, gotEnv, "TERM_PROGRAM=vev") | |
| 944 | 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 | 973 | p, release := newBlockingPTY(t) | |
| 970 | 974 | defer release() | |
| 971 | 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 | 977 | require.NoError(t, err) | |
| 974 | 978 | require.ErrorIs(t, d.renameSession(sess, "my work"), domain.ErrInvalidSessionName) | |
| 975 | 979 | } | |
| @@ -985,9 +989,9 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | |||
| 985 | 989 | WithStore(store)(d) | |
| 986 | 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 | 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 | 995 | require.NoError(t, err) | |
| 992 | 996 | _ = d.killSession(natural, ports.ReasonSessionKilled, false) | |
| 993 | 997 | require.True(t, state.has("natural")) | |
| @@ -999,3 +1003,125 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) { | |||
| 999 | 1003 | _ = d.killSession(other, ports.ReasonSessionKilled, true) | |
| 1000 | 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 | + | } | |
| @@ -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.sets | 826 | 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 | |||
| 67 | 67 | ||
| 68 | 68 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 69 | 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 | 74 | closeOpened := func() { | |
| 71 | 75 | for _, tb := range opened { | |
| 72 | 76 | tb.closeAllPanes() | |
| @@ -132,7 +136,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 132 | 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 | 140 | if err != nil { | |
| 137 | 141 | closeOpened() | |
| 138 | 142 | return err | |
| @@ -154,7 +158,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 154 | 158 | return nil | |
| 155 | 159 | } | |
| 156 | 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 | 162 | if sess.active < 0 || sess.active >= len(sess.tabs) { | |
| 159 | 163 | sess.active = 0 | |
| 160 | 164 | } | |
| @@ -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 err | 142 | return err | ||
| @@ -154,7 +158,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 154 | return nil | 158 | 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 = 0 | 163 | sess.active = 0 | ||
| 160 | } | 164 | } | ||
M internal/usecase/daemon/snapshot_restore_test.go +8 -2
| @@ -88,9 +88,15 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 88 | 88 | require.Equal(t, "t_saved", tabStableID) | |
| 89 | 89 | require.Equal(t, "p_saved_1", pane1StableID) | |
| 90 | 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 | 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 | 100 | require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID) | |
| 95 | 101 | } | |
| 96 | 102 | ||
| @@ -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) { | |||
| 556 | 556 | cur := &session{id: "cur", name: "current", tabs: []*tab{{}}} | |
| 557 | 557 | 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 | 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 | 562 | require.NoError(t, err) | |
| 563 | 563 | ||
| 564 | 564 | 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] = cur | 557 | 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, "") | ||