detached 385a8c0

feat(terminal): isolate attachment capabilities

commit
385a8c0093eeced89d7ecd4961274902360defc8
author
brice · · 2026-08-14 09:52 UTC
parent
5e4da5d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
27 files · +323 -133
M docs/terminal.md +3 -1
@@ -2,7 +2,9 @@
22
33 ## Color
44
5-When the client reports truecolor, new panes get `TERM=xterm-direct` and `COLORTERM=truecolor`; otherwise `TERM=xterm-256color`. The capability travels inside vev's own protocol, so it works over SSH without `SendEnv`/`AcceptEnv`. Panes restored from a snapshot derive the same capability from the daemon's startup environment until a client attaches and refreshes it.
5+Panes always receive vev's stable compatibility contract: `TERM=xterm-256color`, `COLORTERM=truecolor`, and `TERM_PROGRAM=vev`. This contract does not mirror the terminal attached outside vev.
6+
7+Each attachment independently detects its output color support from terminal environment signals. vev preserves RGB pane state and converts it to the xterm 256-color palette only for attachments that do not advertise truecolor. An attachment reporting a 256-color terminal receives a one-time warning before its first paint. Environment signals are advisory—especially through nested multiplexers—and do not advertise Kitty graphics support.
68
79 ## Environment
810
@@ -2,7 +2,9 @@
2 2
3 ## Color3 ## Color
4 4
5-When the client reports truecolor, new panes get `TERM=xterm-direct` and `COLORTERM=truecolor`; otherwise `TERM=xterm-256color`. The capability travels inside vev's own protocol, so it works over SSH without `SendEnv`/`AcceptEnv`. Panes restored from a snapshot derive the same capability from the daemon's startup environment until a client attaches and refreshes it.5+Panes always receive vev's stable compatibility contract: `TERM=xterm-256color`, `COLORTERM=truecolor`, and `TERM_PROGRAM=vev`. This contract does not mirror the terminal attached outside vev.
6+
7+Each attachment independently detects its output color support from terminal environment signals. vev preserves RGB pane state and converts it to the xterm 256-color palette only for attachments that do not advertise truecolor. An attachment reporting a 256-color terminal receives a one-time warning before its first paint. Environment signals are advisory—especially through nested multiplexers—and do not advertise Kitty graphics support.
6 8
7 ## Environment9 ## Environment
8 10
M internal/app/daemon_integration_test.go +1 -1
@@ -220,7 +220,7 @@ func assertChildEnvironment(t *testing.T, tr ports.Transport, p *pump, sz domain
220220 "SHELL=" + filepath.Base(wantShell),
221221 "XDG_RUNTIME_DIR=" + wantRuntimeDir,
222222 "WAYLAND_DISPLAY=" + wantWayland,
223- "TERM=xterm-direct",
223+ "TERM=xterm-256color",
224224 "COLORTERM=truecolor",
225225 "TERM_PROGRAM=vev",
226226 "VEV_PREFIX=session=environment,tab=",
@@ -220,7 +220,7 @@ func assertChildEnvironment(t *testing.T, tr ports.Transport, p *pump, sz domain
220 "SHELL=" + filepath.Base(wantShell),220 "SHELL=" + filepath.Base(wantShell),
221 "XDG_RUNTIME_DIR=" + wantRuntimeDir,221 "XDG_RUNTIME_DIR=" + wantRuntimeDir,
222 "WAYLAND_DISPLAY=" + wantWayland,222 "WAYLAND_DISPLAY=" + wantWayland,
223- "TERM=xterm-direct",223+ "TERM=xterm-256color",
224 "COLORTERM=truecolor",224 "COLORTERM=truecolor",
225 "TERM_PROGRAM=vev",225 "TERM_PROGRAM=vev",
226 "VEV_PREFIX=session=environment,tab=",226 "VEV_PREFIX=session=environment,tab=",
M internal/ports/terminal.go +92 -6
@@ -17,13 +17,99 @@ type Terminal interface {
1717 Flush() error
1818 }
1919
20-// DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
21-func DetectTrueColor(termEnv, colorTerm string) bool {
22- switch strings.ToLower(strings.TrimSpace(colorTerm)) {
20+// TerminalColorMode is the color output mode selected for one attachment.
21+type TerminalColorMode uint8
22+
23+const (
24+ // TrueColor is the zero value so manually constructed attachments retain the
25+ // historical renderer behavior; live Hello paths always use the detector.
26+ TerminalColorTrueColor TerminalColorMode = iota
27+ TerminalColorIndexed256
28+)
29+
30+// TerminalCapabilitySource records how confidently a terminal capability was
31+// selected. Environment observations are advisory because nested multiplexers
32+// can retain values from an outer terminal.
33+type TerminalCapabilitySource uint8
34+
35+const (
36+ TerminalCapabilityUnknown TerminalCapabilitySource = iota
37+ TerminalCapabilityHeuristic
38+ TerminalCapabilityDeclared
39+)
40+
41+// TerminalApplication identifies a known terminal application when its
42+// environment provides a trustworthy origin signal. It does not imply support
43+// for any application-specific protocol on the current output path.
44+type TerminalApplication uint8
45+
46+const (
47+ TerminalApplicationUnknown TerminalApplication = iota
48+ TerminalApplicationKitty
49+)
50+
51+// TerminalCapabilities describes the output features selected for one client
52+// attachment. Pane processes must not derive their terminal environment from
53+// these capabilities.
54+type TerminalCapabilities struct {
55+ ColorMode TerminalColorMode
56+ ColorSource TerminalCapabilitySource
57+ Application TerminalApplication
58+}
59+
60+// TrueColor reports whether this attachment can receive RGB ANSI output.
61+func (c TerminalCapabilities) TrueColor() bool {
62+ return c.ColorMode == TerminalColorTrueColor
63+}
64+
65+// DetectTerminalCapabilities derives conservative attachment capabilities from
66+// a client environment. Missing evidence selects indexed color; it never proves
67+// the physical terminal cannot display direct color.
68+func DetectTerminalCapabilities(env []string) TerminalCapabilities {
69+ values := environmentValues(env)
70+ term := strings.ToLower(strings.TrimSpace(values["TERM"]))
71+ colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"]))
72+ caps := TerminalCapabilities{ColorMode: TerminalColorIndexed256}
73+
74+ if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" {
75+ caps.Application = TerminalApplicationKitty
76+ }
77+
78+ switch colorTerm {
2379 case "truecolor", "24bit":
24- return true
80+ caps.ColorMode = TerminalColorTrueColor
81+ caps.ColorSource = TerminalCapabilityDeclared
82+ return caps
83+ }
84+ if term == "xterm-direct" || strings.HasSuffix(term, "-direct") {
85+ caps.ColorMode = TerminalColorTrueColor
86+ caps.ColorSource = TerminalCapabilityDeclared
87+ return caps
88+ }
89+ if term == "xterm-kitty" && caps.Application == TerminalApplicationKitty {
90+ caps.ColorMode = TerminalColorTrueColor
91+ caps.ColorSource = TerminalCapabilityHeuristic
92+ return caps
93+ }
94+ if strings.Contains(term, "256color") || term == "dumb" {
95+ caps.ColorSource = TerminalCapabilityDeclared
2596 }
97+ return caps
98+}
99+
100+func environmentValues(env []string) map[string]string {
101+ values := make(map[string]string, len(env))
102+ for _, entry := range env {
103+ key, value, ok := strings.Cut(entry, "=")
104+ if !ok {
105+ continue
106+ }
107+ values[key] = value
108+ }
109+ return values
110+}
26111
27- termEnv = strings.ToLower(strings.TrimSpace(termEnv))
28- return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct")
112+// DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
113+func DetectTrueColor(termEnv, colorTerm string) bool {
114+ return DetectTerminalCapabilities([]string{"TERM=" + termEnv, "COLORTERM=" + colorTerm}).TrueColor()
29115 }
@@ -17,13 +17,99 @@ type Terminal interface {
17 Flush() error17 Flush() error
18 }18 }
19 19
20-// DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.20+// TerminalColorMode is the color output mode selected for one attachment.
21-func DetectTrueColor(termEnv, colorTerm string) bool {21+type TerminalColorMode uint8
22- switch strings.ToLower(strings.TrimSpace(colorTerm)) {22+
23+const (
24+ // TrueColor is the zero value so manually constructed attachments retain the
25+ // historical renderer behavior; live Hello paths always use the detector.
26+ TerminalColorTrueColor TerminalColorMode = iota
27+ TerminalColorIndexed256
28+)
29+
30+// TerminalCapabilitySource records how confidently a terminal capability was
31+// selected. Environment observations are advisory because nested multiplexers
32+// can retain values from an outer terminal.
33+type TerminalCapabilitySource uint8
34+
35+const (
36+ TerminalCapabilityUnknown TerminalCapabilitySource = iota
37+ TerminalCapabilityHeuristic
38+ TerminalCapabilityDeclared
39+)
40+
41+// TerminalApplication identifies a known terminal application when its
42+// environment provides a trustworthy origin signal. It does not imply support
43+// for any application-specific protocol on the current output path.
44+type TerminalApplication uint8
45+
46+const (
47+ TerminalApplicationUnknown TerminalApplication = iota
48+ TerminalApplicationKitty
49+)
50+
51+// TerminalCapabilities describes the output features selected for one client
52+// attachment. Pane processes must not derive their terminal environment from
53+// these capabilities.
54+type TerminalCapabilities struct {
55+ ColorMode TerminalColorMode
56+ ColorSource TerminalCapabilitySource
57+ Application TerminalApplication
58+}
59+
60+// TrueColor reports whether this attachment can receive RGB ANSI output.
61+func (c TerminalCapabilities) TrueColor() bool {
62+ return c.ColorMode == TerminalColorTrueColor
63+}
64+
65+// DetectTerminalCapabilities derives conservative attachment capabilities from
66+// a client environment. Missing evidence selects indexed color; it never proves
67+// the physical terminal cannot display direct color.
68+func DetectTerminalCapabilities(env []string) TerminalCapabilities {
69+ values := environmentValues(env)
70+ term := strings.ToLower(strings.TrimSpace(values["TERM"]))
71+ colorTerm := strings.ToLower(strings.TrimSpace(values["COLORTERM"]))
72+ caps := TerminalCapabilities{ColorMode: TerminalColorIndexed256}
73+
74+ if values["KITTY_WINDOW_ID"] != "" || values["KITTY_PID"] != "" || values["KITTY_LISTEN_ON"] != "" {
75+ caps.Application = TerminalApplicationKitty
76+ }
77+
78+ switch colorTerm {
23 case "truecolor", "24bit":79 case "truecolor", "24bit":
24- return true80+ caps.ColorMode = TerminalColorTrueColor
81+ caps.ColorSource = TerminalCapabilityDeclared
82+ return caps
83+ }
84+ if term == "xterm-direct" || strings.HasSuffix(term, "-direct") {
85+ caps.ColorMode = TerminalColorTrueColor
86+ caps.ColorSource = TerminalCapabilityDeclared
87+ return caps
88+ }
89+ if term == "xterm-kitty" && caps.Application == TerminalApplicationKitty {
90+ caps.ColorMode = TerminalColorTrueColor
91+ caps.ColorSource = TerminalCapabilityHeuristic
92+ return caps
93+ }
94+ if strings.Contains(term, "256color") || term == "dumb" {
95+ caps.ColorSource = TerminalCapabilityDeclared
25 }96 }
97+ return caps
98+}
99+
100+func environmentValues(env []string) map[string]string {
101+ values := make(map[string]string, len(env))
102+ for _, entry := range env {
103+ key, value, ok := strings.Cut(entry, "=")
104+ if !ok {
105+ continue
106+ }
107+ values[key] = value
108+ }
109+ return values
110+}
26 111
27- termEnv = strings.ToLower(strings.TrimSpace(termEnv))112+// DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
28- return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct")113+func DetectTrueColor(termEnv, colorTerm string) bool {
114+ return DetectTerminalCapabilities([]string{"TERM=" + termEnv, "COLORTERM=" + colorTerm}).TrueColor()
29 }115 }
A internal/ports/terminal_test.go +59 -0
@@ -0,0 +1,59 @@
1+package ports
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestDetectTerminalCapabilities(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ env []string
13+ wantMode TerminalColorMode
14+ wantSource TerminalCapabilitySource
15+ wantApp TerminalApplication
16+ }{
17+ {
18+ name: "declared truecolor",
19+ env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"},
20+ wantMode: TerminalColorTrueColor,
21+ wantSource: TerminalCapabilityDeclared,
22+ },
23+ {
24+ name: "kitty signals infer truecolor",
25+ env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"},
26+ wantMode: TerminalColorTrueColor,
27+ wantSource: TerminalCapabilityHeuristic,
28+ wantApp: TerminalApplicationKitty,
29+ },
30+ {
31+ name: "256 color terminal remains a constrained attachment",
32+ env: []string{"TERM=xterm-256color"},
33+ wantMode: TerminalColorIndexed256,
34+ wantSource: TerminalCapabilityDeclared,
35+ },
36+ {
37+ name: "unknown terminal is conservatively indexed",
38+ env: []string{"TERM=unknown"},
39+ wantMode: TerminalColorIndexed256,
40+ wantSource: TerminalCapabilityUnknown,
41+ },
42+ {
43+ name: "kitty environment behind a multiplexer is not graphics evidence",
44+ env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"},
45+ wantMode: TerminalColorIndexed256,
46+ wantSource: TerminalCapabilityDeclared,
47+ wantApp: TerminalApplicationKitty,
48+ },
49+ }
50+
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ got := DetectTerminalCapabilities(tt.env)
54+ require.Equal(t, tt.wantMode, got.ColorMode)
55+ require.Equal(t, tt.wantSource, got.ColorSource)
56+ require.Equal(t, tt.wantApp, got.Application)
57+ })
58+ }
59+}
@@ -0,0 +1,59 @@
1+package ports
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestDetectTerminalCapabilities(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ env []string
13+ wantMode TerminalColorMode
14+ wantSource TerminalCapabilitySource
15+ wantApp TerminalApplication
16+ }{
17+ {
18+ name: "declared truecolor",
19+ env: []string{"TERM=xterm-256color", "COLORTERM=truecolor"},
20+ wantMode: TerminalColorTrueColor,
21+ wantSource: TerminalCapabilityDeclared,
22+ },
23+ {
24+ name: "kitty signals infer truecolor",
25+ env: []string{"TERM=xterm-kitty", "KITTY_WINDOW_ID=1"},
26+ wantMode: TerminalColorTrueColor,
27+ wantSource: TerminalCapabilityHeuristic,
28+ wantApp: TerminalApplicationKitty,
29+ },
30+ {
31+ name: "256 color terminal remains a constrained attachment",
32+ env: []string{"TERM=xterm-256color"},
33+ wantMode: TerminalColorIndexed256,
34+ wantSource: TerminalCapabilityDeclared,
35+ },
36+ {
37+ name: "unknown terminal is conservatively indexed",
38+ env: []string{"TERM=unknown"},
39+ wantMode: TerminalColorIndexed256,
40+ wantSource: TerminalCapabilityUnknown,
41+ },
42+ {
43+ name: "kitty environment behind a multiplexer is not graphics evidence",
44+ env: []string{"TERM=tmux-256color", "KITTY_WINDOW_ID=1"},
45+ wantMode: TerminalColorIndexed256,
46+ wantSource: TerminalCapabilityDeclared,
47+ wantApp: TerminalApplicationKitty,
48+ },
49+ }
50+
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ got := DetectTerminalCapabilities(tt.env)
54+ require.Equal(t, tt.wantMode, got.ColorMode)
55+ require.Equal(t, tt.wantSource, got.ColorSource)
56+ require.Equal(t, tt.wantApp, got.Application)
57+ })
58+ }
59+}
M internal/usecase/daemon/attachment_transition_publish.go +0 -1
@@ -176,7 +176,6 @@ func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool
176176 req.target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
177177 }
178178 if req.copySourceEnvironment && publication.source != nil && req.target != nil {
179- req.target.terminal = publication.source.terminal
180179 req.target.env = copyEnvironment(publication.source.env)
181180 }
182181 return req.preserveAttachment
@@ -176,7 +176,6 @@ func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool
176 req.target.activateAttachmentViewLocked(req.next, req.targetTabIndex)176 req.target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
177 }177 }
178 if req.copySourceEnvironment && publication.source != nil && req.target != nil {178 if req.copySourceEnvironment && publication.source != nil && req.target != nil {
179- req.target.terminal = publication.source.terminal
180 req.target.env = copyEnvironment(publication.source.env)179 req.target.env = copyEnvironment(publication.source.env)
181 }180 }
182 return req.preserveAttachment181 return req.preserveAttachment
M internal/usecase/daemon/client.go +17 -0
@@ -42,6 +42,7 @@ type attachedClient struct {
4242 // remoteOrigin is attachment-specific presentation metadata supplied by a
4343 // validated picker handoff; it never participates in routing.
4444 remoteOrigin string
45+ terminalCapabilities ports.TerminalCapabilities
4546 navigationCapabilities ports.NavigationCapabilities
4647 startupOverlay ports.StartupOverlay
4748 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
@@ -628,6 +629,8 @@ type attachClientOptions struct {
628629 resumeCapable bool
629630 maxOutputInFlight uint8
630631 remoteOrigin string
632+ terminalCapabilities ports.TerminalCapabilities
633+ capabilitiesSet bool
631634 navigationCapabilities ports.NavigationCapabilities
632635 startupOverlay ports.StartupOverlay
633636 }
@@ -657,6 +660,16 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
657660 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
658661 d.touchMRU(sess)
659662 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
663+ if ac.terminalCapabilities.ColorSource == ports.TerminalCapabilityDeclared && !ac.terminalCapabilities.TrueColor() {
664+ d.publishToast(ac, domain.Notification{
665+ Code: domain.NoticeUser,
666+ Severity: domain.NoticeWarn,
667+ Message: "TrueColor was not detected; rendering with 256 colors.",
668+ Time: d.clock.Now(),
669+ Count: 1,
670+ SessionID: sess.id,
671+ })
672+ }
660673 d.applyHostTheme(sess, ac, themeui.Theme{}, true)
661674 }
662675
@@ -668,6 +681,9 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
668681 if opts.resumeCapable {
669682 resumeToken = d.nextResumeTokenLocked()
670683 }
684+ if !opts.capabilitiesSet {
685+ opts.terminalCapabilities = ports.TerminalCapabilities{ColorMode: ports.TerminalColorTrueColor}
686+ }
671687 output := newOutputStateStream(opts.maxOutputInFlight)
672688 ac := &attachedClient{
673689 tr: tr,
@@ -676,6 +692,7 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
676692 view: attachmentView{windowRows: sz.Rows, windowSet: true},
677693 clientID: opts.clientID,
678694 remoteOrigin: opts.remoteOrigin,
695+ terminalCapabilities: opts.terminalCapabilities,
679696 navigationCapabilities: opts.navigationCapabilities,
680697 startupOverlay: opts.startupOverlay,
681698 resumeCapable: opts.resumeCapable,
@@ -42,6 +42,7 @@ type attachedClient struct {
42 // remoteOrigin is attachment-specific presentation metadata supplied by a42 // remoteOrigin is attachment-specific presentation metadata supplied by a
43 // validated picker handoff; it never participates in routing.43 // validated picker handoff; it never participates in routing.
44 remoteOrigin string44 remoteOrigin string
45+ terminalCapabilities ports.TerminalCapabilities
45 navigationCapabilities ports.NavigationCapabilities46 navigationCapabilities ports.NavigationCapabilities
46 startupOverlay ports.StartupOverlay47 startupOverlay ports.StartupOverlay
47 // connectionGeneration is the wire-facing capability generation. attachmentEffects is48 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
@@ -628,6 +629,8 @@ type attachClientOptions struct {
628 resumeCapable bool629 resumeCapable bool
629 maxOutputInFlight uint8630 maxOutputInFlight uint8
630 remoteOrigin string631 remoteOrigin string
632+ terminalCapabilities ports.TerminalCapabilities
633+ capabilitiesSet bool
631 navigationCapabilities ports.NavigationCapabilities634 navigationCapabilities ports.NavigationCapabilities
632 startupOverlay ports.StartupOverlay635 startupOverlay ports.StartupOverlay
633 }636 }
@@ -657,6 +660,16 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
657 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {660 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
658 d.touchMRU(sess)661 d.touchMRU(sess)
659 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)662 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
663+ if ac.terminalCapabilities.ColorSource == ports.TerminalCapabilityDeclared && !ac.terminalCapabilities.TrueColor() {
664+ d.publishToast(ac, domain.Notification{
665+ Code: domain.NoticeUser,
666+ Severity: domain.NoticeWarn,
667+ Message: "TrueColor was not detected; rendering with 256 colors.",
668+ Time: d.clock.Now(),
669+ Count: 1,
670+ SessionID: sess.id,
671+ })
672+ }
660 d.applyHostTheme(sess, ac, themeui.Theme{}, true)673 d.applyHostTheme(sess, ac, themeui.Theme{}, true)
661 }674 }
662 675
@@ -668,6 +681,9 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
668 if opts.resumeCapable {681 if opts.resumeCapable {
669 resumeToken = d.nextResumeTokenLocked()682 resumeToken = d.nextResumeTokenLocked()
670 }683 }
684+ if !opts.capabilitiesSet {
685+ opts.terminalCapabilities = ports.TerminalCapabilities{ColorMode: ports.TerminalColorTrueColor}
686+ }
671 output := newOutputStateStream(opts.maxOutputInFlight)687 output := newOutputStateStream(opts.maxOutputInFlight)
672 ac := &attachedClient{688 ac := &attachedClient{
673 tr: tr,689 tr: tr,
@@ -676,6 +692,7 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
676 view: attachmentView{windowRows: sz.Rows, windowSet: true},692 view: attachmentView{windowRows: sz.Rows, windowSet: true},
677 clientID: opts.clientID,693 clientID: opts.clientID,
678 remoteOrigin: opts.remoteOrigin,694 remoteOrigin: opts.remoteOrigin,
695+ terminalCapabilities: opts.terminalCapabilities,
679 navigationCapabilities: opts.navigationCapabilities,696 navigationCapabilities: opts.navigationCapabilities,
680 startupOverlay: opts.startupOverlay,697 startupOverlay: opts.startupOverlay,
681 resumeCapable: opts.resumeCapable,698 resumeCapable: opts.resumeCapable,
M internal/usecase/daemon/control.go +2 -2
@@ -480,7 +480,7 @@ func (e controlExec) CreateSessionNamed(name string) error {
480480 return command.ErrInvalidArguments
481481 }
482482 e.sess.mu.Lock()
483- cwd, term, env := e.sess.cwd, e.sess.terminal, copyEnvironment(e.sess.env)
483+ cwd, env := e.sess.cwd, copyEnvironment(e.sess.env)
484484 e.sess.mu.Unlock()
485485 size := e.sess.fullViewportSize()
486486 e.d.mu.Lock()
@@ -491,7 +491,7 @@ func (e controlExec) CreateSessionNamed(name string) error {
491491 if e.d.nameLiveOrStoppedLocked(name) {
492492 return errSessionNameInUse
493493 }
494- _, err := e.d.createSessionLocked(name, false, cwd, size, term, env)
494+ _, err := e.d.createSessionLocked(name, false, cwd, size, env)
495495 return err
496496 }
497497 func (e controlExec) CloseTab() error {
@@ -480,7 +480,7 @@ func (e controlExec) CreateSessionNamed(name string) error {
480 return command.ErrInvalidArguments480 return command.ErrInvalidArguments
481 }481 }
482 e.sess.mu.Lock()482 e.sess.mu.Lock()
483- cwd, term, env := e.sess.cwd, e.sess.terminal, copyEnvironment(e.sess.env)483+ cwd, env := e.sess.cwd, copyEnvironment(e.sess.env)
484 e.sess.mu.Unlock()484 e.sess.mu.Unlock()
485 size := e.sess.fullViewportSize()485 size := e.sess.fullViewportSize()
486 e.d.mu.Lock()486 e.d.mu.Lock()
@@ -491,7 +491,7 @@ func (e controlExec) CreateSessionNamed(name string) error {
491 if e.d.nameLiveOrStoppedLocked(name) {491 if e.d.nameLiveOrStoppedLocked(name) {
492 return errSessionNameInUse492 return errSessionNameInUse
493 }493 }
494- _, err := e.d.createSessionLocked(name, false, cwd, size, term, env)494+ _, err := e.d.createSessionLocked(name, false, cwd, size, env)
495 return err495 return err
496 }496 }
497 func (e controlExec) CloseTab() error {497 func (e controlExec) CloseTab() error {
M internal/usecase/daemon/control_test.go +0 -2
@@ -649,7 +649,6 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
649649 source.mu.Lock()
650650 source.cwd = "/tmp/work"
651651 source.env = []string{"INHERITED=yes"}
652- source.terminal = terminalEnv{TrueColor: true}
653652 source.tabs[0].size = domain.Size{Cols: 118, Rows: 38}
654653 source.mu.Unlock()
655654
@@ -664,7 +663,6 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
664663 require.Empty(t, created.snapshotAttachmentsLocked())
665664 require.Equal(t, "/tmp/work", created.cwd)
666665 require.Equal(t, []string{"INHERITED=yes"}, created.env)
667- require.True(t, created.terminal.TrueColor)
668666 tb := created.tabs[0]
669667 created.mu.Unlock()
670668 tb.mu.Lock()
@@ -649,7 +649,6 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
649 source.mu.Lock()649 source.mu.Lock()
650 source.cwd = "/tmp/work"650 source.cwd = "/tmp/work"
651 source.env = []string{"INHERITED=yes"}651 source.env = []string{"INHERITED=yes"}
652- source.terminal = terminalEnv{TrueColor: true}
653 source.tabs[0].size = domain.Size{Cols: 118, Rows: 38}652 source.tabs[0].size = domain.Size{Cols: 118, Rows: 38}
654 source.mu.Unlock()653 source.mu.Unlock()
655 654
@@ -664,7 +663,6 @@ func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T
664 require.Empty(t, created.snapshotAttachmentsLocked())663 require.Empty(t, created.snapshotAttachmentsLocked())
665 require.Equal(t, "/tmp/work", created.cwd)664 require.Equal(t, "/tmp/work", created.cwd)
666 require.Equal(t, []string{"INHERITED=yes"}, created.env)665 require.Equal(t, []string{"INHERITED=yes"}, created.env)
667- require.True(t, created.terminal.TrueColor)
668 tb := created.tabs[0]666 tb := created.tabs[0]
669 created.mu.Unlock()667 created.mu.Unlock()
670 tb.mu.Lock()668 tb.mu.Lock()
M internal/usecase/daemon/daemon.go +14 -9
@@ -1249,7 +1249,7 @@ func (e *protoErr) Error() string { return e.text }
12491249 // unlocking d.mu before returning, including every error path. It publishes
12501250 // terminal and role state before releasing d.mu, then defers coordinator
12511251 // cleanup so obsolete workers never delay the new handshake.
1252-func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) (*attachedClient, error) {
1252+func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, h ports.Hello) (*attachedClient, error) {
12531253 initialTabIndex := -1
12541254 if h.RemoteTarget != nil {
12551255 var ok bool
@@ -1276,8 +1276,12 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
12761276 if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
12771277 sess.env = copyEnvironment(h.Env)
12781278 }
1279- sess.terminal = term
12801279 sess.mu.Unlock()
1280+ terminalCapabilities := ports.DetectTerminalCapabilities(h.Env)
1281+ if h.TrueColor && !terminalCapabilities.TrueColor() {
1282+ terminalCapabilities.ColorMode = ports.TerminalColorTrueColor
1283+ terminalCapabilities.ColorSource = ports.TerminalCapabilityDeclared
1284+ }
12811285 opts := attachClientOptions{
12821286 clientID: h.ClientID,
12831287 resumeCapable: true,
@@ -1285,6 +1289,8 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
12851289 navigationCapabilities: h.NavigationCapabilities,
12861290 startupOverlay: h.StartupOverlay,
12871291 remoteOrigin: h.RemoteOrigin,
1292+ terminalCapabilities: terminalCapabilities,
1293+ capabilitiesSet: true,
12881294 }
12891295 if opts.remoteOrigin == "" && h.RemoteTarget != nil {
12901296 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin
@@ -1420,7 +1426,6 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
14201426 if !sz.Valid() {
14211427 return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
14221428 }
1423- term := terminalEnv{TrueColor: h.TrueColor}
14241429 if h.Intent == ports.IntentResume && h.ResumeToken == 0 {
14251430 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is required"}
14261431 }
@@ -1503,12 +1508,12 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
15031508 switch h.Intent {
15041509 case ports.IntentEphemeral:
15051510 name := d.allocEphemeralNameLocked()
1506- sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, term, h.Env)
1511+ sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, h.Env)
15071512 if err != nil {
15081513 d.mu.Unlock()
15091514 return nil, nil, err
15101515 }
1511- ac, err := d.finishRouteAttach(sess, tr, sz, term, h, true, true)
1516+ ac, err := d.finishRouteAttach(sess, tr, sz, h, true, true)
15121517 return sess, ac, err
15131518
15141519 case ports.IntentNew:
@@ -1524,12 +1529,12 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
15241529 d.mu.Unlock()
15251530 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
15261531 }
1527- sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, sz, term, h.Env)
1532+ sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, sz, h.Env)
15281533 if err != nil {
15291534 d.mu.Unlock()
15301535 return nil, nil, err
15311536 }
1532- ac, err := d.finishRouteAttach(sess, tr, sz, term, h, true, true)
1537+ ac, err := d.finishRouteAttach(sess, tr, sz, h, true, true)
15331538 return sess, ac, err
15341539
15351540 case ports.IntentAttach:
@@ -1547,14 +1552,14 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
15471552 env = copyEnvironment(d.baseEnv)
15481553 }
15491554 var err error
1550- sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, env, stopped.tabNames)
1555+ sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, env, stopped.tabNames)
15511556 if err != nil {
15521557 d.mu.Unlock()
15531558 return nil, nil, err
15541559 }
15551560 created = true
15561561 }
1557- ac, err := d.finishRouteAttach(sess, tr, sz, term, h, created, false)
1562+ ac, err := d.finishRouteAttach(sess, tr, sz, h, created, false)
15581563 return sess, ac, err
15591564
15601565 default:
@@ -1249,7 +1249,7 @@ func (e *protoErr) Error() string { return e.text }
1249 // unlocking d.mu before returning, including every error path. It publishes1249 // unlocking d.mu before returning, including every error path. It publishes
1250 // terminal and role state before releasing d.mu, then defers coordinator1250 // terminal and role state before releasing d.mu, then defers coordinator
1251 // cleanup so obsolete workers never delay the new handshake.1251 // cleanup so obsolete workers never delay the new handshake.
1252-func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello) (*attachedClient, error) {1252+func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, h ports.Hello) (*attachedClient, error) {
1253 initialTabIndex := -11253 initialTabIndex := -1
1254 if h.RemoteTarget != nil {1254 if h.RemoteTarget != nil {
1255 var ok bool1255 var ok bool
@@ -1276,8 +1276,12 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1276 if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {1276 if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
1277 sess.env = copyEnvironment(h.Env)1277 sess.env = copyEnvironment(h.Env)
1278 }1278 }
1279- sess.terminal = term
1280 sess.mu.Unlock()1279 sess.mu.Unlock()
1280+ terminalCapabilities := ports.DetectTerminalCapabilities(h.Env)
1281+ if h.TrueColor && !terminalCapabilities.TrueColor() {
1282+ terminalCapabilities.ColorMode = ports.TerminalColorTrueColor
1283+ terminalCapabilities.ColorSource = ports.TerminalCapabilityDeclared
1284+ }
1281 opts := attachClientOptions{1285 opts := attachClientOptions{
1282 clientID: h.ClientID,1286 clientID: h.ClientID,
1283 resumeCapable: true,1287 resumeCapable: true,
@@ -1285,6 +1289,8 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1285 navigationCapabilities: h.NavigationCapabilities,1289 navigationCapabilities: h.NavigationCapabilities,
1286 startupOverlay: h.StartupOverlay,1290 startupOverlay: h.StartupOverlay,
1287 remoteOrigin: h.RemoteOrigin,1291 remoteOrigin: h.RemoteOrigin,
1292+ terminalCapabilities: terminalCapabilities,
1293+ capabilitiesSet: true,
1288 }1294 }
1289 if opts.remoteOrigin == "" && h.RemoteTarget != nil {1295 if opts.remoteOrigin == "" && h.RemoteTarget != nil {
1290 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin1296 opts.remoteOrigin = h.RemoteTarget.DisplayOrigin
@@ -1420,7 +1426,6 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1420 if !sz.Valid() {1426 if !sz.Valid() {
1421 return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}1427 return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
1422 }1428 }
1423- term := terminalEnv{TrueColor: h.TrueColor}
1424 if h.Intent == ports.IntentResume && h.ResumeToken == 0 {1429 if h.Intent == ports.IntentResume && h.ResumeToken == 0 {
1425 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is required"}1430 return nil, nil, &protoErr{ports.ErrNoSuchSession, "resume token is required"}
1426 }1431 }
@@ -1503,12 +1508,12 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1503 switch h.Intent {1508 switch h.Intent {
1504 case ports.IntentEphemeral:1509 case ports.IntentEphemeral:
1505 name := d.allocEphemeralNameLocked()1510 name := d.allocEphemeralNameLocked()
1506- sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, term, h.Env)1511+ sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, h.Env)
1507 if err != nil {1512 if err != nil {
1508 d.mu.Unlock()1513 d.mu.Unlock()
1509 return nil, nil, err1514 return nil, nil, err
1510 }1515 }
1511- ac, err := d.finishRouteAttach(sess, tr, sz, term, h, true, true)1516+ ac, err := d.finishRouteAttach(sess, tr, sz, h, true, true)
1512 return sess, ac, err1517 return sess, ac, err
1513 1518
1514 case ports.IntentNew:1519 case ports.IntentNew:
@@ -1524,12 +1529,12 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1524 d.mu.Unlock()1529 d.mu.Unlock()
1525 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}1530 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
1526 }1531 }
1527- sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, sz, term, h.Env)1532+ sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, sz, h.Env)
1528 if err != nil {1533 if err != nil {
1529 d.mu.Unlock()1534 d.mu.Unlock()
1530 return nil, nil, err1535 return nil, nil, err
1531 }1536 }
1532- ac, err := d.finishRouteAttach(sess, tr, sz, term, h, true, true)1537+ ac, err := d.finishRouteAttach(sess, tr, sz, h, true, true)
1533 return sess, ac, err1538 return sess, ac, err
1534 1539
1535 case ports.IntentAttach:1540 case ports.IntentAttach:
@@ -1547,14 +1552,14 @@ func (d *Daemon) routeWithContext(ctx context.Context, h ports.Hello, tr ports.T
1547 env = copyEnvironment(d.baseEnv)1552 env = copyEnvironment(d.baseEnv)
1548 }1553 }
1549 var err error1554 var err error
1550- sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, env, stopped.tabNames)1555+ sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, env, stopped.tabNames)
1551 if err != nil {1556 if err != nil {
1552 d.mu.Unlock()1557 d.mu.Unlock()
1553 return nil, nil, err1558 return nil, nil, err
1554 }1559 }
1555 created = true1560 created = true
1556 }1561 }
1557- ac, err := d.finishRouteAttach(sess, tr, sz, term, h, created, false)1562+ ac, err := d.finishRouteAttach(sess, tr, sz, h, created, false)
1558 return sess, ac, err1563 return sess, ac, err
1559 1564
1560 default:1565 default:
M internal/usecase/daemon/daemon_test.go +6 -2
@@ -68,10 +68,14 @@ func newTestDaemon(t testing.TB, ptys ports.PTYFactory, clk ports.Clock) *Daemon
6868 return newTestDaemonWithCleanup(t, ptys, clk, true)
6969 }
7070
71-func createSessionForTest(d *Daemon, name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
71+type terminalEnv struct {
72+ TrueColor bool
73+}
74+
75+func createSessionForTest(d *Daemon, name string, ephemeral bool, cwd string, sz domain.Size, _ terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
7276 d.mu.Lock()
7377 defer d.mu.Unlock()
74- return d.createSessionLocked(name, ephemeral, cwd, sz, term, env, restoredTabNames...)
78+ return d.createSessionLocked(name, ephemeral, cwd, sz, env, restoredTabNames...)
7579 }
7680
7781 func newTestDaemonWithCleanup(t testing.TB, ptys ports.PTYFactory, clk ports.Clock, registerCleanup bool) *Daemon {
@@ -68,10 +68,14 @@ func newTestDaemon(t testing.TB, ptys ports.PTYFactory, clk ports.Clock) *Daemon
68 return newTestDaemonWithCleanup(t, ptys, clk, true)68 return newTestDaemonWithCleanup(t, ptys, clk, true)
69 }69 }
70 70
71-func createSessionForTest(d *Daemon, name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {71+type terminalEnv struct {
72+ TrueColor bool
73+}
74+
75+func createSessionForTest(d *Daemon, name string, ephemeral bool, cwd string, sz domain.Size, _ terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
72 d.mu.Lock()76 d.mu.Lock()
73 defer d.mu.Unlock()77 defer d.mu.Unlock()
74- return d.createSessionLocked(name, ephemeral, cwd, sz, term, env, restoredTabNames...)78+ return d.createSessionLocked(name, ephemeral, cwd, sz, env, restoredTabNames...)
75 }79 }
76 80
77 func newTestDaemonWithCleanup(t testing.TB, ptys ports.PTYFactory, clk ports.Clock, registerCleanup bool) *Daemon {81 func newTestDaemonWithCleanup(t testing.TB, ptys ports.PTYFactory, clk ports.Clock, registerCleanup bool) *Daemon {
M internal/usecase/daemon/floating.go +2 -2
@@ -350,7 +350,7 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi
350350
351351 func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {
352352 sess.mu.Lock()
353- name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx
353+ name, cwd, sessCtx := sess.name, sess.cwd, sess.ctx
354354 env := copyEnvironment(sess.env)
355355 sess.mu.Unlock()
356356 tb.mu.Lock()
@@ -387,7 +387,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
387387 size: size,
388388 geometry: geometry,
389389 paneStableID: paneStableID,
390- env: childEnvFrom(env, name, tabStableID, paneStableID, term),
390+ env: childEnvFrom(env, name, tabStableID, paneStableID),
391391 command: launch.command,
392392 args: launch.args,
393393 fallback: floatingCommandFallback(cfg.Command, launch.command),
@@ -350,7 +350,7 @@ func (d *Daemon) launchFloating(sess *session, tb *tab, cfg domain.FloatingConfi
350 350
351 func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {351 func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.FloatingConfig, userOpen bool) (floatingLaunchSpec, error) {
352 sess.mu.Lock()352 sess.mu.Lock()
353- name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx353+ name, cwd, sessCtx := sess.name, sess.cwd, sess.ctx
354 env := copyEnvironment(sess.env)354 env := copyEnvironment(sess.env)
355 sess.mu.Unlock()355 sess.mu.Unlock()
356 tb.mu.Lock()356 tb.mu.Lock()
@@ -387,7 +387,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
387 size: size,387 size: size,
388 geometry: geometry,388 geometry: geometry,
389 paneStableID: paneStableID,389 paneStableID: paneStableID,
390- env: childEnvFrom(env, name, tabStableID, paneStableID, term),390+ env: childEnvFrom(env, name, tabStableID, paneStableID),
391 command: launch.command,391 command: launch.command,
392 args: launch.args,392 args: launch.args,
393 fallback: floatingCommandFallback(cfg.Command, launch.command),393 fallback: floatingCommandFallback(cfg.Command, launch.command),
M internal/usecase/daemon/floating_test.go +2 -2
@@ -172,7 +172,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
172172 cwd := t.TempDir()
173173 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
174174 tb.ctx, tb.cancel = context.WithCancel(t.Context())
175- sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, terminal: terminalEnv{TrueColor: true}, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}
175+ sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}
176176 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
177177 d.ensureFloatingWarm(sess, tb)
178178 // Open has started while this goroutine owns tab.mu: an external factory
@@ -196,7 +196,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
196196 floating := tb.floating.pane
197197 tb.mu.Unlock()
198198 require.NotNil(t, floating)
199- require.Equal(t, []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + tb.stableID + ",pane=" + floating.stableID}, openCall.env)
199+ require.Equal(t, []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + tb.stableID + ",pane=" + floating.stableID}, openCall.env)
200200 assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
201201 assertPaneColorScheme(t, floating, false)
202202 require.Equal(t, "/bin/custom-shell", openCall.command)
@@ -172,7 +172,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
172 cwd := t.TempDir()172 cwd := t.TempDir()
173 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})173 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
174 tb.ctx, tb.cancel = context.WithCancel(t.Context())174 tb.ctx, tb.cancel = context.WithCancel(t.Context())
175- sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, terminal: terminalEnv{TrueColor: true}, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}175+ sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}
176 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})176 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
177 d.ensureFloatingWarm(sess, tb)177 d.ensureFloatingWarm(sess, tb)
178 // Open has started while this goroutine owns tab.mu: an external factory178 // Open has started while this goroutine owns tab.mu: an external factory
@@ -196,7 +196,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
196 floating := tb.floating.pane196 floating := tb.floating.pane
197 tb.mu.Unlock()197 tb.mu.Unlock()
198 require.NotNil(t, floating)198 require.NotNil(t, floating)
199- require.Equal(t, []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + tb.stableID + ",pane=" + floating.stableID}, openCall.env)199+ require.Equal(t, []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + tb.stableID + ",pane=" + floating.stableID}, openCall.env)
200 assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)200 assertPaneDefaultColors(t, floating, themeui.BuiltinDark.Foreground, themeui.BuiltinDark.Background)
201 assertPaneColorScheme(t, floating, false)201 assertPaneColorScheme(t, floating, false)
202 require.Equal(t, "/bin/custom-shell", openCall.command)202 require.Equal(t, "/bin/custom-shell", openCall.command)
M internal/usecase/daemon/pane_actions.go +1 -2
@@ -48,7 +48,6 @@ func (d *Daemon) spawnPaneOpAt(
4848
4949 sess.mu.Lock()
5050 name, cwd := sess.name, sess.cwd
51- term := sess.terminal
5251 env := copyEnvironment(sess.env)
5352 sess.mu.Unlock()
5453
@@ -89,7 +88,7 @@ func (d *Daemon) spawnPaneOpAt(
8988 }
9089 launch := d.shellLaunch(env)
9190 lifetime := d.newPaneProcessLifetime(tb.ctx)
92- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
91+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, rectSize(newRect))
9392 if err != nil {
9493 lifetime.abort()
9594 if pty != nil {
@@ -48,7 +48,6 @@ func (d *Daemon) spawnPaneOpAt(
48 48
49 sess.mu.Lock()49 sess.mu.Lock()
50 name, cwd := sess.name, sess.cwd50 name, cwd := sess.name, sess.cwd
51- term := sess.terminal
52 env := copyEnvironment(sess.env)51 env := copyEnvironment(sess.env)
53 sess.mu.Unlock()52 sess.mu.Unlock()
54 53
@@ -89,7 +88,7 @@ func (d *Daemon) spawnPaneOpAt(
89 }88 }
90 launch := d.shellLaunch(env)89 launch := d.shellLaunch(env)
91 lifetime := d.newPaneProcessLifetime(tb.ctx)90 lifetime := d.newPaneProcessLifetime(tb.ctx)
92- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect))91+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, rectSize(newRect))
93 if err != nil {92 if err != nil {
94 lifetime.abort()93 lifetime.abort()
95 if pty != nil {94 if pty != nil {
M internal/usecase/daemon/pane_actions_test.go +2 -9
@@ -961,23 +961,16 @@ func blockingRead(t *testing.T) func([]byte) (int, error) {
961961 func TestSplitPaneUsesExactAuthoritativeEnvironment(t *testing.T) {
962962 for _, tt := range []struct {
963963 name string
964- term terminalEnv
965964 want []string
966965 }{
967966 {
968- name: "truecolor",
969- term: terminalEnv{TrueColor: true},
970- want: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},
971- },
972- {
973- name: "no truecolor",
974- want: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=xterm-256color", "TERM_PROGRAM=vev"},
967+ name: "fixed pane terminal",
968+ want: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},
975969 },
976970 } {
977971 t.Run(tt.name, func(t *testing.T) {
978972 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
979973 d.shellOverride = false
980- sess.terminal = tt.term
981974 sess.env = []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
982975 newPTY := portsmocks.NewMockPTY(t)
983976 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
@@ -961,23 +961,16 @@ func blockingRead(t *testing.T) func([]byte) (int, error) {
961 func TestSplitPaneUsesExactAuthoritativeEnvironment(t *testing.T) {961 func TestSplitPaneUsesExactAuthoritativeEnvironment(t *testing.T) {
962 for _, tt := range []struct {962 for _, tt := range []struct {
963 name string963 name string
964- term terminalEnv
965 want []string964 want []string
966 }{965 }{
967 {966 {
968- name: "truecolor",967+ name: "fixed pane terminal",
969- term: terminalEnv{TrueColor: true},968+ want: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},
970- want: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},
971- },
972- {
973- name: "no truecolor",
974- want: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=xterm-256color", "TERM_PROGRAM=vev"},
975 },969 },
976 } {970 } {
977 t.Run(tt.name, func(t *testing.T) {971 t.Run(tt.name, func(t *testing.T) {
978 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})972 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
979 d.shellOverride = false973 d.shellOverride = false
980- sess.terminal = tt.term
981 sess.env = []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}974 sess.env = []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
982 newPTY := portsmocks.NewMockPTY(t)975 newPTY := portsmocks.NewMockPTY(t)
983 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()976 oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
M internal/usecase/daemon/picker.go +3 -4
@@ -1282,9 +1282,9 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
12821282 return nil, errAttachmentTransition
12831283 }
12841284 from.mu.Lock()
1285- cwd, term, env := d.dirOrHome(current.cwd), from.terminal, copyEnvironment(from.env)
1285+ cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env)
12861286 from.mu.Unlock()
1287- created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, current.tabNames)
1287+ created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), env, current.tabNames)
12881288 targetSess = created
12891289 return created, createErr
12901290 },
@@ -1308,11 +1308,10 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
13081308 from.mu.Unlock()
13091309 return nil, attachmentTransitionResult{}, false, nil
13101310 }
1311- term := from.terminal
13121311 env := copyEnvironment(from.env)
13131312 from.mu.Unlock()
13141313 cwd := d.dirOrHome(stopped.cwd)
1315- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, stopped.tabNames)
1314+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), env, stopped.tabNames)
13161315 if err != nil {
13171316 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
13181317 return nil, attachmentTransitionResult{}, false, err
@@ -1282,9 +1282,9 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1282 return nil, errAttachmentTransition1282 return nil, errAttachmentTransition
1283 }1283 }
1284 from.mu.Lock()1284 from.mu.Lock()
1285- cwd, term, env := d.dirOrHome(current.cwd), from.terminal, copyEnvironment(from.env)1285+ cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env)
1286 from.mu.Unlock()1286 from.mu.Unlock()
1287- created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, current.tabNames)1287+ created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), env, current.tabNames)
1288 targetSess = created1288 targetSess = created
1289 return created, createErr1289 return created, createErr
1290 },1290 },
@@ -1308,11 +1308,10 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1308 from.mu.Unlock()1308 from.mu.Unlock()
1309 return nil, attachmentTransitionResult{}, false, nil1309 return nil, attachmentTransitionResult{}, false, nil
1310 }1310 }
1311- term := from.terminal
1312 env := copyEnvironment(from.env)1311 env := copyEnvironment(from.env)
1313 from.mu.Unlock()1312 from.mu.Unlock()
1314 cwd := d.dirOrHome(stopped.cwd)1313 cwd := d.dirOrHome(stopped.cwd)
1315- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, stopped.tabNames)1314+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), env, stopped.tabNames)
1316 if err != nil {1315 if err != nil {
1317 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)1316 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
1318 return nil, attachmentTransitionResult{}, false, err1317 return nil, attachmentTransitionResult{}, false, err
M internal/usecase/daemon/picker_test.go +1 -4
@@ -1415,11 +1415,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
14151415 d.resumeStoppedAndSwitch(sess, ac, picker.Target{Name: "old", Stopped: true})
14161416 got := ac.currentSession()
14171417 require.NotNil(t, got)
1418- got.mu.Lock()
1419- require.True(t, got.terminal.TrueColor)
1420- got.mu.Unlock()
14211418 require.Len(t, opens, 2)
1422- require.Contains(t, opens[1], "TERM=xterm-direct")
1419+ require.Contains(t, opens[1], "TERM=xterm-256color")
14231420 require.Contains(t, opens[1], "COLORTERM=truecolor")
14241421 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
14251422 require.Eventually(t, func() bool {
@@ -1415,11 +1415,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
1415 d.resumeStoppedAndSwitch(sess, ac, picker.Target{Name: "old", Stopped: true})1415 d.resumeStoppedAndSwitch(sess, ac, picker.Target{Name: "old", Stopped: true})
1416 got := ac.currentSession()1416 got := ac.currentSession()
1417 require.NotNil(t, got)1417 require.NotNil(t, got)
1418- got.mu.Lock()
1419- require.True(t, got.terminal.TrueColor)
1420- got.mu.Unlock()
1421 require.Len(t, opens, 2)1418 require.Len(t, opens, 2)
1422- require.Contains(t, opens[1], "TERM=xterm-direct")1419+ require.Contains(t, opens[1], "TERM=xterm-256color")
1423 require.Contains(t, opens[1], "COLORTERM=truecolor")1420 require.Contains(t, opens[1], "COLORTERM=truecolor")
1424 require.Contains(t, opens[1], "TERM_PROGRAM=vev")1421 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1425 require.Eventually(t, func() bool {1422 require.Eventually(t, func() bool {
M internal/usecase/daemon/remote_attach.go +5 -5
@@ -48,7 +48,7 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
4848 d.mu.Unlock()
4949 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}
5050 }
51- ac, err := d.finishRouteAttach(live, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h, false, false)
51+ ac, err := d.finishRouteAttach(live, tr, h.Size, h, false, false)
5252 return live, ac, err
5353 }
5454
@@ -71,12 +71,12 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
7171 // trusted for this branch.
7272 env := copyEnvironment(d.baseEnv)
7373 cwd := d.dirOrHome(stopped.cwd)
74- sess, err := d.createSessionLockedWithMode(target.SessionName, false, cwd, h.Size, terminalEnv{TrueColor: h.TrueColor}, env, stopped.tabNames)
74+ sess, err := d.createSessionLockedWithMode(target.SessionName, false, cwd, h.Size, env, stopped.tabNames)
7575 if err != nil {
7676 d.mu.Unlock()
7777 return nil, nil, err
7878 }
79- ac, err := d.finishRouteAttach(sess, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h, true, false)
79+ ac, err := d.finishRouteAttach(sess, tr, h.Size, h, true, false)
8080 return sess, ac, err
8181 }
8282
@@ -113,8 +113,8 @@ func (d *Daemon) sendCommittedRouteIdentityForAttachment(token attachmentConnect
113113 return token.sendControl(ports.Frame{Type: ports.MsgCommittedRouteIdentity, Payload: payload})
114114 }
115115
116-func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) {
117- ac, err := d.finishAttach(sess, tr, sz, term, h)
116+func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) {
117+ ac, err := d.finishAttach(sess, tr, sz, h)
118118 if err != nil && routeCreated {
119119 if cleanupErr := d.killSession(sess, ports.ReasonSessionKilled, purge); cleanupErr != nil {
120120 err = errors.Join(err, cleanupErr)
@@ -48,7 +48,7 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
48 d.mu.Unlock()48 d.mu.Unlock()
49 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}49 return nil, nil, &protoErr{ports.ErrNoSuchTarget, "remote tab no longer exists"}
50 }50 }
51- ac, err := d.finishRouteAttach(live, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h, false, false)51+ ac, err := d.finishRouteAttach(live, tr, h.Size, h, false, false)
52 return live, ac, err52 return live, ac, err
53 }53 }
54 54
@@ -71,12 +71,12 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h ports.Hello
71 // trusted for this branch.71 // trusted for this branch.
72 env := copyEnvironment(d.baseEnv)72 env := copyEnvironment(d.baseEnv)
73 cwd := d.dirOrHome(stopped.cwd)73 cwd := d.dirOrHome(stopped.cwd)
74- sess, err := d.createSessionLockedWithMode(target.SessionName, false, cwd, h.Size, terminalEnv{TrueColor: h.TrueColor}, env, stopped.tabNames)74+ sess, err := d.createSessionLockedWithMode(target.SessionName, false, cwd, h.Size, env, stopped.tabNames)
75 if err != nil {75 if err != nil {
76 d.mu.Unlock()76 d.mu.Unlock()
77 return nil, nil, err77 return nil, nil, err
78 }78 }
79- ac, err := d.finishRouteAttach(sess, tr, h.Size, terminalEnv{TrueColor: h.TrueColor}, h, true, false)79+ ac, err := d.finishRouteAttach(sess, tr, h.Size, h, true, false)
80 return sess, ac, err80 return sess, ac, err
81 }81 }
82 82
@@ -113,8 +113,8 @@ func (d *Daemon) sendCommittedRouteIdentityForAttachment(token attachmentConnect
113 return token.sendControl(ports.Frame{Type: ports.MsgCommittedRouteIdentity, Payload: payload})113 return token.sendControl(ports.Frame{Type: ports.MsgCommittedRouteIdentity, Payload: payload})
114 }114 }
115 115
116-func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, term terminalEnv, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) {116+func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) {
117- ac, err := d.finishAttach(sess, tr, sz, term, h)117+ ac, err := d.finishAttach(sess, tr, sz, h)
118 if err != nil && routeCreated {118 if err != nil && routeCreated {
119 if cleanupErr := d.killSession(sess, ports.ReasonSessionKilled, purge); cleanupErr != nil {119 if cleanupErr := d.killSession(sess, ports.ReasonSessionKilled, purge); cleanupErr != nil {
120 err = errors.Join(err, cleanupErr)120 err = errors.Join(err, cleanupErr)
M internal/usecase/daemon/remote_route_test.go +1 -1
@@ -40,7 +40,7 @@ func TestFinishRouteAttachRollsBackCreatedSession(t *testing.T) {
4040 // The caller must hold d.mu on entry; finishRouteAttach releases it on
4141 // both success and error paths before returning.
4242 d.mu.Lock()
43- _, err = d.finishRouteAttach(sess, &closeTrackingTransport{}, defaultSize, terminalEnv{}, hello, true, true)
43+ _, err = d.finishRouteAttach(sess, &closeTrackingTransport{}, defaultSize, hello, true, true)
4444 var protocol *protoErr
4545 require.ErrorAs(t, err, &protocol)
4646 require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
@@ -40,7 +40,7 @@ func TestFinishRouteAttachRollsBackCreatedSession(t *testing.T) {
40 // The caller must hold d.mu on entry; finishRouteAttach releases it on40 // The caller must hold d.mu on entry; finishRouteAttach releases it on
41 // both success and error paths before returning.41 // both success and error paths before returning.
42 d.mu.Lock()42 d.mu.Lock()
43- _, err = d.finishRouteAttach(sess, &closeTrackingTransport{}, defaultSize, terminalEnv{}, hello, true, true)43+ _, err = d.finishRouteAttach(sess, &closeTrackingTransport{}, defaultSize, hello, true, true)
44 var protocol *protoErr44 var protocol *protoErr
45 require.ErrorAs(t, err, &protocol)45 require.ErrorAs(t, err, &protocol)
46 require.Equal(t, ports.ErrNoSuchTarget, protocol.code)46 require.Equal(t, ports.ErrNoSuchTarget, protocol.code)
M internal/usecase/daemon/render_pipeline.go +1 -0
@@ -468,6 +468,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
468468 }
469469 }
470470 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
471+ composed.frame = adaptFrameColors(composed.frame, ac.terminalCapabilities.TrueColor())
471472 var (
472473 preparedANSI *preparedOutput
473474 err error
@@ -468,6 +468,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
468 }468 }
469 }469 }
470 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)470 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
471+ composed.frame = adaptFrameColors(composed.frame, ac.terminalCapabilities.TrueColor())
471 var (472 var (
472 preparedANSI *preparedOutput473 preparedANSI *preparedOutput
473 err error474 err error
M internal/usecase/daemon/resume.go +0 -1
@@ -639,7 +639,6 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
639639 if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
640640 sess.env = copyEnvironment(h.Env)
641641 }
642- sess.terminal = terminalEnv{TrueColor: h.TrueColor}
643642 sess.mu.Unlock()
644643 preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID)
645644 // Resume preparation used sendMu -> d.mu, but freeze/drain must run with
@@ -639,7 +639,6 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
639 if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {639 if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
640 sess.env = copyEnvironment(h.Env)640 sess.env = copyEnvironment(h.Env)
641 }641 }
642- sess.terminal = terminalEnv{TrueColor: h.TrueColor}
643 sess.mu.Unlock()642 sess.mu.Unlock()
644 preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID)643 preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID)
645 // Resume preparation used sendMu -> d.mu, but freeze/drain must run with644 // Resume preparation used sendMu -> d.mu, but freeze/drain must run with
M internal/usecase/daemon/resume_test.go +1 -2
@@ -1607,11 +1607,10 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
16071607 sess.mu.Lock()
16081608 future := sess.tabs[1].focusedPane()
16091609 futureTabID, futurePaneID := sess.tabs[1].stableID, future.stableID
1610- require.True(t, sess.terminal.TrueColor)
16111610 require.Equal(t, resumeHello.Env, sess.env)
16121611 sess.mu.Unlock()
16131612 require.Equal(t, []string{"/bin/sh", "/usr/bin/fish"}, commands)
1614- require.Equal(t, []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + futureTabID + ",pane=" + futurePaneID}, envs[1])
1613+ require.Equal(t, []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + futureTabID + ",pane=" + futurePaneID}, envs[1])
16151614 }
16161615
16171616 func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) {
@@ -1607,11 +1607,10 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
1607 sess.mu.Lock()1607 sess.mu.Lock()
1608 future := sess.tabs[1].focusedPane()1608 future := sess.tabs[1].focusedPane()
1609 futureTabID, futurePaneID := sess.tabs[1].stableID, future.stableID1609 futureTabID, futurePaneID := sess.tabs[1].stableID, future.stableID
1610- require.True(t, sess.terminal.TrueColor)
1611 require.Equal(t, resumeHello.Env, sess.env)1610 require.Equal(t, resumeHello.Env, sess.env)
1612 sess.mu.Unlock()1611 sess.mu.Unlock()
1613 require.Equal(t, []string{"/bin/sh", "/usr/bin/fish"}, commands)1612 require.Equal(t, []string{"/bin/sh", "/usr/bin/fish"}, commands)
1614- require.Equal(t, []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + futureTabID + ",pane=" + futurePaneID}, envs[1])1613+ require.Equal(t, []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + futureTabID + ",pane=" + futurePaneID}, envs[1])
1615 }1614 }
1616 1615
1617 func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) {1616 func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) {
M internal/usecase/daemon/session.go +16 -45
@@ -25,10 +25,6 @@ var (
2525 errSessionNameInUse = errors.New("name already in use")
2626 )
2727
28-type terminalEnv struct {
29- TrueColor bool
30-}
31-
3228 type session struct {
3329 sessionCore
3430
@@ -62,7 +58,6 @@ type session struct {
6258 clipboardQueue []clipboardForward
6359 clipboardWorkerRunning bool
6460 cwd string
65- terminal terminalEnv
6661 // env is the authoritative immutable environment snapshot for future PTY children.
6762 // It is guarded by mu and is always copied on ingress and egress.
6863 env []string
@@ -187,11 +182,11 @@ func (d *Daemon) touchMRU(entry *session) {
187182 entry.mu.Unlock()
188183 }
189184
190-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
191- return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, term, env, restoredTabNames...)
185+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {
186+ return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, env, restoredTabNames...)
192187 }
193188
194-func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
189+func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {
195190 env = copyEnvironment(env)
196191 if _, reserved := d.creating[name]; reserved {
197192 return nil, errSessionNameInUse
@@ -272,7 +267,7 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
272267 }
273268 launch := d.shellLaunch(env)
274269 lifetime := d.newPaneProcessLifetime(d.serveCtx)
275- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
270+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, tbSize)
276271 if err != nil {
277272 lifetime.abort()
278273 if pty != nil {
@@ -321,7 +316,6 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
321316 cancel: cancel,
322317 tabs: tabs,
323318 cwd: cwd,
324- terminal: term,
325319 env: env,
326320 snapshotWake: d.snapshotWake,
327321 }
@@ -430,7 +424,6 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
430424 }
431425 from.mu.Lock()
432426 cwd := from.cwd
433- term := from.terminal
434427 env := copyEnvironment(from.env)
435428 _, attached := from.attachments[ac]
436429 from.mu.Unlock()
@@ -439,7 +432,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
439432 return errors.New("client detached")
440433 }
441434
442- newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env)
435+ newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, env)
443436 d.mu.Unlock()
444437 if err != nil {
445438 return err
@@ -500,10 +493,10 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
500493 return nil, errSessionNameInUse
501494 }
502495 source.mu.Lock()
503- cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)
496+ cwd, env := source.cwd, copyEnvironment(source.env)
504497 source.mu.Unlock()
505498 var createErr error
506- created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.sizeSnapshot(), term, env)
499+ created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.sizeSnapshot(), env)
507500 return created, createErr
508501 },
509502 })
@@ -529,7 +522,7 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie
529522 return errors.New("daemon is shutting down")
530523 }
531524 from.mu.Lock()
532- cwd, term, env := from.cwd, from.terminal, copyEnvironment(from.env)
525+ cwd, env := from.cwd, copyEnvironment(from.env)
533526 _, attached := from.attachments[ac]
534527 from.mu.Unlock()
535528 if !attached {
@@ -537,7 +530,7 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie
537530 return errors.New("client detached")
538531 }
539532 name := d.allocEphemeralNameLocked()
540- newSess, err := d.createSessionLockedWithMode(name, true, cwd, sz, term, env)
533+ newSess, err := d.createSessionLockedWithMode(name, true, cwd, sz, env)
541534 d.mu.Unlock()
542535 if err != nil {
543536 return err
@@ -575,11 +568,11 @@ func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(token attachmentCo
575568 }
576569 source := token.sess
577570 source.mu.Lock()
578- cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)
571+ cwd, env := source.cwd, copyEnvironment(source.env)
579572 source.mu.Unlock()
580573 name := d.allocEphemeralNameLocked()
581574 var createErr error
582- created, createErr = d.createSessionLockedWithMode(name, true, cwd, token.ac.sizeSnapshot(), term, env)
575+ created, createErr = d.createSessionLockedWithMode(name, true, cwd, token.ac.sizeSnapshot(), env)
583576 return created, createErr
584577 },
585578 })
@@ -604,7 +597,6 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
604597 sess.mu.Lock()
605598 name := sess.name
606599 cwd := sess.cwd
607- term := sess.terminal
608600 env := copyEnvironment(sess.env)
609601 attachments := sess.snapshotAttachmentsLocked()
610602 sess.mu.Unlock()
@@ -615,7 +607,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
615607 }
616608 launch := d.shellLaunch(env)
617609 lifetime := d.newPaneProcessLifetime(sess.ctx)
618- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
610+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, tbSize)
619611 if err != nil {
620612 lifetime.abort()
621613 if pty != nil {
@@ -1881,8 +1873,8 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
18811873
18821874 // childEnv retains the daemon-environment helper for daemon-local legacy callers.
18831875 // Interactive PTY launch paths use childEnvFrom with their session snapshot.
1884-func (d *Daemon) childEnv(name, tabStableID, paneStableID string, term terminalEnv) []string {
1885- return childEnvFrom(d.baseEnv, name, tabStableID, paneStableID, term)
1876+func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string {
1877+ return childEnvFrom(d.baseEnv, name, tabStableID, paneStableID)
18861878 }
18871879
18881880 func copyEnvironment(env []string) []string {
@@ -1894,23 +1886,6 @@ func environmentEntry(entry string) (name, value string, ok bool) {
18941886 return name, value, ok
18951887 }
18961888
1897-func terminalEnvFromEnvironment(env []string) terminalEnv {
1898- var term, colorTerm string
1899- for _, entry := range env {
1900- name, value, ok := environmentEntry(entry)
1901- if !ok {
1902- continue
1903- }
1904- switch name {
1905- case "TERM":
1906- term = value
1907- case "COLORTERM":
1908- colorTerm = value
1909- }
1910- }
1911- return terminalEnv{TrueColor: ports.DetectTrueColor(term, colorTerm)}
1912-}
1913-
19141889 const defaultShellCommand = "/bin/sh"
19151890 const defaultShellTitle = "sh"
19161891
@@ -1956,7 +1931,7 @@ func shellFromEnvironment(env []string) string {
19561931
19571932 // childEnvFrom preserves the supplied environment byte-for-byte and in order,
19581933 // except for exact reserved variable names which vev owns.
1959-func childEnvFrom(env []string, name, tabStableID, paneStableID string, term terminalEnv) []string {
1934+func childEnvFrom(env []string, name, tabStableID, paneStableID string) []string {
19601935 out := make([]string, 0, len(env)+4)
19611936 for _, entry := range env {
19621937 key, _, ok := environmentEntry(entry)
@@ -1965,11 +1940,7 @@ func childEnvFrom(env []string, name, tabStableID, paneStableID string, term ter
19651940 }
19661941 out = append(out, entry)
19671942 }
1968- if term.TrueColor {
1969- out = append(out, "TERM=xterm-direct", "COLORTERM=truecolor")
1970- } else {
1971- out = append(out, "TERM=xterm-256color")
1972- }
1943+ out = append(out, "TERM=xterm-256color", "COLORTERM=truecolor")
19731944 return append(out,
19741945 "TERM_PROGRAM=vev",
19751946 "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID,
@@ -25,10 +25,6 @@ var (
25 errSessionNameInUse = errors.New("name already in use")25 errSessionNameInUse = errors.New("name already in use")
26 )26 )
27 27
28-type terminalEnv struct {
29- TrueColor bool
30-}
31-
32 type session struct {28 type session struct {
33 sessionCore29 sessionCore
34 30
@@ -62,7 +58,6 @@ type session struct {
62 clipboardQueue []clipboardForward58 clipboardQueue []clipboardForward
63 clipboardWorkerRunning bool59 clipboardWorkerRunning bool
64 cwd string60 cwd string
65- terminal terminalEnv
66 // env is the authoritative immutable environment snapshot for future PTY children.61 // env is the authoritative immutable environment snapshot for future PTY children.
67 // It is guarded by mu and is always copied on ingress and egress.62 // It is guarded by mu and is always copied on ingress and egress.
68 env []string63 env []string
@@ -187,11 +182,11 @@ func (d *Daemon) touchMRU(entry *session) {
187 entry.mu.Unlock()182 entry.mu.Unlock()
188 }183 }
189 184
190-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {185+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {
191- return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, term, env, restoredTabNames...)186+ return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, env, restoredTabNames...)
192 }187 }
193 188
194-func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {189+func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, env []string, restoredTabNames ...[]string) (*session, error) {
195 env = copyEnvironment(env)190 env = copyEnvironment(env)
196 if _, reserved := d.creating[name]; reserved {191 if _, reserved := d.creating[name]; reserved {
197 return nil, errSessionNameInUse192 return nil, errSessionNameInUse
@@ -272,7 +267,7 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
272 }267 }
273 launch := d.shellLaunch(env)268 launch := d.shellLaunch(env)
274 lifetime := d.newPaneProcessLifetime(d.serveCtx)269 lifetime := d.newPaneProcessLifetime(d.serveCtx)
275- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)270+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, tbSize)
276 if err != nil {271 if err != nil {
277 lifetime.abort()272 lifetime.abort()
278 if pty != nil {273 if pty != nil {
@@ -321,7 +316,6 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
321 cancel: cancel,316 cancel: cancel,
322 tabs: tabs,317 tabs: tabs,
323 cwd: cwd,318 cwd: cwd,
324- terminal: term,
325 env: env,319 env: env,
326 snapshotWake: d.snapshotWake,320 snapshotWake: d.snapshotWake,
327 }321 }
@@ -430,7 +424,6 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
430 }424 }
431 from.mu.Lock()425 from.mu.Lock()
432 cwd := from.cwd426 cwd := from.cwd
433- term := from.terminal
434 env := copyEnvironment(from.env)427 env := copyEnvironment(from.env)
435 _, attached := from.attachments[ac]428 _, attached := from.attachments[ac]
436 from.mu.Unlock()429 from.mu.Unlock()
@@ -439,7 +432,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
439 return errors.New("client detached")432 return errors.New("client detached")
440 }433 }
441 434
442- newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env)435+ newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, env)
443 d.mu.Unlock()436 d.mu.Unlock()
444 if err != nil {437 if err != nil {
445 return err438 return err
@@ -500,10 +493,10 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
500 return nil, errSessionNameInUse493 return nil, errSessionNameInUse
501 }494 }
502 source.mu.Lock()495 source.mu.Lock()
503- cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)496+ cwd, env := source.cwd, copyEnvironment(source.env)
504 source.mu.Unlock()497 source.mu.Unlock()
505 var createErr error498 var createErr error
506- created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.sizeSnapshot(), term, env)499+ created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.sizeSnapshot(), env)
507 return created, createErr500 return created, createErr
508 },501 },
509 })502 })
@@ -529,7 +522,7 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie
529 return errors.New("daemon is shutting down")522 return errors.New("daemon is shutting down")
530 }523 }
531 from.mu.Lock()524 from.mu.Lock()
532- cwd, term, env := from.cwd, from.terminal, copyEnvironment(from.env)525+ cwd, env := from.cwd, copyEnvironment(from.env)
533 _, attached := from.attachments[ac]526 _, attached := from.attachments[ac]
534 from.mu.Unlock()527 from.mu.Unlock()
535 if !attached {528 if !attached {
@@ -537,7 +530,7 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie
537 return errors.New("client detached")530 return errors.New("client detached")
538 }531 }
539 name := d.allocEphemeralNameLocked()532 name := d.allocEphemeralNameLocked()
540- newSess, err := d.createSessionLockedWithMode(name, true, cwd, sz, term, env)533+ newSess, err := d.createSessionLockedWithMode(name, true, cwd, sz, env)
541 d.mu.Unlock()534 d.mu.Unlock()
542 if err != nil {535 if err != nil {
543 return err536 return err
@@ -575,11 +568,11 @@ func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(token attachmentCo
575 }568 }
576 source := token.sess569 source := token.sess
577 source.mu.Lock()570 source.mu.Lock()
578- cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)571+ cwd, env := source.cwd, copyEnvironment(source.env)
579 source.mu.Unlock()572 source.mu.Unlock()
580 name := d.allocEphemeralNameLocked()573 name := d.allocEphemeralNameLocked()
581 var createErr error574 var createErr error
582- created, createErr = d.createSessionLockedWithMode(name, true, cwd, token.ac.sizeSnapshot(), term, env)575+ created, createErr = d.createSessionLockedWithMode(name, true, cwd, token.ac.sizeSnapshot(), env)
583 return created, createErr576 return created, createErr
584 },577 },
585 })578 })
@@ -604,7 +597,6 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
604 sess.mu.Lock()597 sess.mu.Lock()
605 name := sess.name598 name := sess.name
606 cwd := sess.cwd599 cwd := sess.cwd
607- term := sess.terminal
608 env := copyEnvironment(sess.env)600 env := copyEnvironment(sess.env)
609 attachments := sess.snapshotAttachmentsLocked()601 attachments := sess.snapshotAttachmentsLocked()
610 sess.mu.Unlock()602 sess.mu.Unlock()
@@ -615,7 +607,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
615 }607 }
616 launch := d.shellLaunch(env)608 launch := d.shellLaunch(env)
617 lifetime := d.newPaneProcessLifetime(sess.ctx)609 lifetime := d.newPaneProcessLifetime(sess.ctx)
618- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)610+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID), cwd, tbSize)
619 if err != nil {611 if err != nil {
620 lifetime.abort()612 lifetime.abort()
621 if pty != nil {613 if pty != nil {
@@ -1881,8 +1873,8 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
1881 1873
1882 // childEnv retains the daemon-environment helper for daemon-local legacy callers.1874 // childEnv retains the daemon-environment helper for daemon-local legacy callers.
1883 // Interactive PTY launch paths use childEnvFrom with their session snapshot.1875 // Interactive PTY launch paths use childEnvFrom with their session snapshot.
1884-func (d *Daemon) childEnv(name, tabStableID, paneStableID string, term terminalEnv) []string {1876+func (d *Daemon) childEnv(name, tabStableID, paneStableID string) []string {
1885- return childEnvFrom(d.baseEnv, name, tabStableID, paneStableID, term)1877+ return childEnvFrom(d.baseEnv, name, tabStableID, paneStableID)
1886 }1878 }
1887 1879
1888 func copyEnvironment(env []string) []string {1880 func copyEnvironment(env []string) []string {
@@ -1894,23 +1886,6 @@ func environmentEntry(entry string) (name, value string, ok bool) {
1894 return name, value, ok1886 return name, value, ok
1895 }1887 }
1896 1888
1897-func terminalEnvFromEnvironment(env []string) terminalEnv {
1898- var term, colorTerm string
1899- for _, entry := range env {
1900- name, value, ok := environmentEntry(entry)
1901- if !ok {
1902- continue
1903- }
1904- switch name {
1905- case "TERM":
1906- term = value
1907- case "COLORTERM":
1908- colorTerm = value
1909- }
1910- }
1911- return terminalEnv{TrueColor: ports.DetectTrueColor(term, colorTerm)}
1912-}
1913-
1914 const defaultShellCommand = "/bin/sh"1889 const defaultShellCommand = "/bin/sh"
1915 const defaultShellTitle = "sh"1890 const defaultShellTitle = "sh"
1916 1891
@@ -1956,7 +1931,7 @@ func shellFromEnvironment(env []string) string {
1956 1931
1957 // childEnvFrom preserves the supplied environment byte-for-byte and in order,1932 // childEnvFrom preserves the supplied environment byte-for-byte and in order,
1958 // except for exact reserved variable names which vev owns.1933 // except for exact reserved variable names which vev owns.
1959-func childEnvFrom(env []string, name, tabStableID, paneStableID string, term terminalEnv) []string {1934+func childEnvFrom(env []string, name, tabStableID, paneStableID string) []string {
1960 out := make([]string, 0, len(env)+4)1935 out := make([]string, 0, len(env)+4)
1961 for _, entry := range env {1936 for _, entry := range env {
1962 key, _, ok := environmentEntry(entry)1937 key, _, ok := environmentEntry(entry)
@@ -1965,11 +1940,7 @@ func childEnvFrom(env []string, name, tabStableID, paneStableID string, term ter
1965 }1940 }
1966 out = append(out, entry)1941 out = append(out, entry)
1967 }1942 }
1968- if term.TrueColor {1943+ out = append(out, "TERM=xterm-256color", "COLORTERM=truecolor")
1969- out = append(out, "TERM=xterm-direct", "COLORTERM=truecolor")
1970- } else {
1971- out = append(out, "TERM=xterm-256color")
1972- }
1973 return append(out,1944 return append(out,
1974 "TERM_PROGRAM=vev",1945 "TERM_PROGRAM=vev",
1975 "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID,1946 "VEV=session="+escapeVEVComponent(name)+",tab="+tabStableID+",pane="+paneStableID,
M internal/usecase/daemon/session_test.go +16 -18
@@ -1239,7 +1239,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
12391239 func TestChildEnvEscapesLegacySessionName(t *testing.T) {
12401240 d := newTestDaemon(t, nil, stubClock{})
12411241
1242- got := d.childEnv("legacy,name=value", "t_alpha", "p_beta", terminalEnv{TrueColor: true})
1242+ got := d.childEnv("legacy,name=value", "t_alpha", "p_beta")
12431243
12441244 require.Contains(t, got, "VEV=session=legacy%2Cname%3Dvalue,tab=t_alpha,pane=p_beta")
12451245 }
@@ -1285,7 +1285,7 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
12851285 require.NotContains(t, gotEnv, "COLORTERM=old")
12861286 require.NotContains(t, gotEnv, "TERM_PROGRAM=old")
12871287 require.NotContains(t, gotEnv, "VEV=old")
1288- require.Contains(t, gotEnv, "TERM=xterm-direct")
1288+ require.Contains(t, gotEnv, "TERM=xterm-256color")
12891289 require.Contains(t, gotEnv, "COLORTERM=truecolor")
12901290 require.Contains(t, gotEnv, "TERM_PROGRAM=vev")
12911291 require.Contains(t, gotEnv, "VEV=session=work,tab="+tabStableID+",pane="+paneStableID)
@@ -1385,7 +1385,7 @@ func TestChildEnvTrueColorCapability(t *testing.T) {
13851385 term: terminalEnv{TrueColor: true},
13861386 wantContain: []string{
13871387 "KEEP=1",
1388- "TERM=xterm-direct",
1388+ "TERM=xterm-256color",
13891389 "COLORTERM=truecolor",
13901390 "TERM_PROGRAM=vev",
13911391 "VEV=session=work,tab=t_alpha,pane=p_beta",
@@ -1393,11 +1393,10 @@ func TestChildEnvTrueColorCapability(t *testing.T) {
13931393 wantNotContain: []string{"TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"},
13941394 },
13951395 {
1396- name: "omits truecolor when unsupported",
1396+ name: "uses the fixed pane contract",
13971397 baseEnv: []string{"COLORTERM=old"},
1398- term: terminalEnv{},
1399- wantContain: []string{"TERM=xterm-256color", "TERM_PROGRAM=vev"},
1400- wantNotContain: []string{"TERM=xterm-direct", "COLORTERM=truecolor"},
1398+ wantContain: []string{"TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},
1399+ wantNotContain: []string{"TERM=xterm-direct"},
14011400 },
14021401 }
14031402
@@ -1406,7 +1405,7 @@ func TestChildEnvTrueColorCapability(t *testing.T) {
14061405 d := newTestDaemon(t, nil, stubClock{})
14071406 d.baseEnv = tt.baseEnv
14081407
1409- got := d.childEnv("work", "t_alpha", "p_beta", tt.term)
1408+ got := d.childEnv("work", "t_alpha", "p_beta")
14101409
14111410 for _, want := range tt.wantContain {
14121411 require.Contains(t, got, want)
@@ -1452,11 +1451,11 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
14521451 d.sessWg.Wait()
14531452 }()
14541453
1455- require.Contains(t, opens[0], "TERM=xterm-direct")
1454+ require.Contains(t, opens[0], "TERM=xterm-256color")
14561455 require.Contains(t, opens[0], "COLORTERM=truecolor")
14571456 require.Contains(t, opens[0], "TERM_PROGRAM=vev")
14581457 require.NoError(t, d.createTab(sess, ac.size))
1459- require.Contains(t, opens[1], "TERM=xterm-direct")
1458+ require.Contains(t, opens[1], "TERM=xterm-256color")
14601459 require.Contains(t, opens[1], "COLORTERM=truecolor")
14611460 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
14621461 }
@@ -1500,9 +1499,9 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
15001499 }()
15011500
15021501 require.Contains(t, opens[0], "TERM=xterm-256color")
1503- require.NotContains(t, opens[0], "COLORTERM=truecolor")
1502+ require.Contains(t, opens[0], "COLORTERM=truecolor")
15041503 require.NoError(t, d.createTab(sess, ac.size))
1505- require.Contains(t, opens[1], "TERM=xterm-direct")
1504+ require.Contains(t, opens[1], "TERM=xterm-256color")
15061505 require.Contains(t, opens[1], "COLORTERM=truecolor")
15071506 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
15081507 }
@@ -1562,10 +1561,9 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
15621561 require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase")
15631562 ac.sendMu.Unlock()
15641563 got.mu.Lock()
1565- require.True(t, got.terminal.TrueColor)
15661564 got.mu.Unlock()
15671565 require.Len(t, opens, 2)
1568- require.Contains(t, opens[1], "TERM=xterm-direct")
1566+ require.Contains(t, opens[1], "TERM=xterm-256color")
15691567 require.Contains(t, opens[1], "COLORTERM=truecolor")
15701568 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
15711569 _ = d.killSession(got, ports.ReasonSessionKilled, false)
@@ -1617,20 +1615,20 @@ func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) {
16171615 }()
16181616
16191617 require.Equal(t, "/usr/bin/fish", commands[0])
1620- require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])
1618+ require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])
16211619 require.Equal(t, "fish", sess.tabs[0].focusedPaneTitle(false), "the initial tab title identifies the shell requested by its creator")
16221620 require.NoError(t, d.createTab(sess, ac.size))
16231621 require.Equal(t, "/bin/bash", commands[1])
1624- require.Equal(t, []string{"SECRET=second", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1])
1622+ require.Equal(t, []string{"SECRET=second", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1])
16251623 require.Equal(t, "bash", sess.tabs[1].focusedPaneTitle(false), "later tabs use the most recently attached shell")
16261624 }
16271625
16281626 func TestChildEnvFromPreservesNonReservedEntriesVerbatimAndInOrder(t *testing.T) {
16291627 env := []string{"SECRET=a=b=c", "TERM_PROGRAM_extra=keep", "TERM", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "EMPTY="}
1630- got := childEnvFrom(env, "work", "tab", "pane", terminalEnv{})
1628+ got := childEnvFrom(env, "work", "tab", "pane")
16311629 require.Equal(t, []string{
16321630 "SECRET=a=b=c", "TERM_PROGRAM_extra=keep", "TERM", "EMPTY=",
1633- "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane",
1631+ "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane",
16341632 }, got)
16351633 }
16361634
@@ -1239,7 +1239,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
1239 func TestChildEnvEscapesLegacySessionName(t *testing.T) {1239 func TestChildEnvEscapesLegacySessionName(t *testing.T) {
1240 d := newTestDaemon(t, nil, stubClock{})1240 d := newTestDaemon(t, nil, stubClock{})
1241 1241
1242- got := d.childEnv("legacy,name=value", "t_alpha", "p_beta", terminalEnv{TrueColor: true})1242+ got := d.childEnv("legacy,name=value", "t_alpha", "p_beta")
1243 1243
1244 require.Contains(t, got, "VEV=session=legacy%2Cname%3Dvalue,tab=t_alpha,pane=p_beta")1244 require.Contains(t, got, "VEV=session=legacy%2Cname%3Dvalue,tab=t_alpha,pane=p_beta")
1245 }1245 }
@@ -1285,7 +1285,7 @@ func TestNewSessionAssignsStableIDsAndChildEnv(t *testing.T) {
1285 require.NotContains(t, gotEnv, "COLORTERM=old")1285 require.NotContains(t, gotEnv, "COLORTERM=old")
1286 require.NotContains(t, gotEnv, "TERM_PROGRAM=old")1286 require.NotContains(t, gotEnv, "TERM_PROGRAM=old")
1287 require.NotContains(t, gotEnv, "VEV=old")1287 require.NotContains(t, gotEnv, "VEV=old")
1288- require.Contains(t, gotEnv, "TERM=xterm-direct")1288+ require.Contains(t, gotEnv, "TERM=xterm-256color")
1289 require.Contains(t, gotEnv, "COLORTERM=truecolor")1289 require.Contains(t, gotEnv, "COLORTERM=truecolor")
1290 require.Contains(t, gotEnv, "TERM_PROGRAM=vev")1290 require.Contains(t, gotEnv, "TERM_PROGRAM=vev")
1291 require.Contains(t, gotEnv, "VEV=session=work,tab="+tabStableID+",pane="+paneStableID)1291 require.Contains(t, gotEnv, "VEV=session=work,tab="+tabStableID+",pane="+paneStableID)
@@ -1385,7 +1385,7 @@ func TestChildEnvTrueColorCapability(t *testing.T) {
1385 term: terminalEnv{TrueColor: true},1385 term: terminalEnv{TrueColor: true},
1386 wantContain: []string{1386 wantContain: []string{
1387 "KEEP=1",1387 "KEEP=1",
1388- "TERM=xterm-direct",1388+ "TERM=xterm-256color",
1389 "COLORTERM=truecolor",1389 "COLORTERM=truecolor",
1390 "TERM_PROGRAM=vev",1390 "TERM_PROGRAM=vev",
1391 "VEV=session=work,tab=t_alpha,pane=p_beta",1391 "VEV=session=work,tab=t_alpha,pane=p_beta",
@@ -1393,11 +1393,10 @@ func TestChildEnvTrueColorCapability(t *testing.T) {
1393 wantNotContain: []string{"TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"},1393 wantNotContain: []string{"TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"},
1394 },1394 },
1395 {1395 {
1396- name: "omits truecolor when unsupported",1396+ name: "uses the fixed pane contract",
1397 baseEnv: []string{"COLORTERM=old"},1397 baseEnv: []string{"COLORTERM=old"},
1398- term: terminalEnv{},1398+ wantContain: []string{"TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev"},
1399- wantContain: []string{"TERM=xterm-256color", "TERM_PROGRAM=vev"},1399+ wantNotContain: []string{"TERM=xterm-direct"},
1400- wantNotContain: []string{"TERM=xterm-direct", "COLORTERM=truecolor"},
1401 },1400 },
1402 }1401 }
1403 1402
@@ -1406,7 +1405,7 @@ func TestChildEnvTrueColorCapability(t *testing.T) {
1406 d := newTestDaemon(t, nil, stubClock{})1405 d := newTestDaemon(t, nil, stubClock{})
1407 d.baseEnv = tt.baseEnv1406 d.baseEnv = tt.baseEnv
1408 1407
1409- got := d.childEnv("work", "t_alpha", "p_beta", tt.term)1408+ got := d.childEnv("work", "t_alpha", "p_beta")
1410 1409
1411 for _, want := range tt.wantContain {1410 for _, want := range tt.wantContain {
1412 require.Contains(t, got, want)1411 require.Contains(t, got, want)
@@ -1452,11 +1451,11 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1452 d.sessWg.Wait()1451 d.sessWg.Wait()
1453 }()1452 }()
1454 1453
1455- require.Contains(t, opens[0], "TERM=xterm-direct")1454+ require.Contains(t, opens[0], "TERM=xterm-256color")
1456 require.Contains(t, opens[0], "COLORTERM=truecolor")1455 require.Contains(t, opens[0], "COLORTERM=truecolor")
1457 require.Contains(t, opens[0], "TERM_PROGRAM=vev")1456 require.Contains(t, opens[0], "TERM_PROGRAM=vev")
1458 require.NoError(t, d.createTab(sess, ac.size))1457 require.NoError(t, d.createTab(sess, ac.size))
1459- require.Contains(t, opens[1], "TERM=xterm-direct")1458+ require.Contains(t, opens[1], "TERM=xterm-256color")
1460 require.Contains(t, opens[1], "COLORTERM=truecolor")1459 require.Contains(t, opens[1], "COLORTERM=truecolor")
1461 require.Contains(t, opens[1], "TERM_PROGRAM=vev")1460 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1462 }1461 }
@@ -1500,9 +1499,9 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1500 }()1499 }()
1501 1500
1502 require.Contains(t, opens[0], "TERM=xterm-256color")1501 require.Contains(t, opens[0], "TERM=xterm-256color")
1503- require.NotContains(t, opens[0], "COLORTERM=truecolor")1502+ require.Contains(t, opens[0], "COLORTERM=truecolor")
1504 require.NoError(t, d.createTab(sess, ac.size))1503 require.NoError(t, d.createTab(sess, ac.size))
1505- require.Contains(t, opens[1], "TERM=xterm-direct")1504+ require.Contains(t, opens[1], "TERM=xterm-256color")
1506 require.Contains(t, opens[1], "COLORTERM=truecolor")1505 require.Contains(t, opens[1], "COLORTERM=truecolor")
1507 require.Contains(t, opens[1], "TERM_PROGRAM=vev")1506 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1508 }1507 }
@@ -1562,10 +1561,9 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1562 require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase")1561 require.Equal(t, uint64(1), ac.output.next-ac.output.acked, "only the destination first paint may follow the rebase")
1563 ac.sendMu.Unlock()1562 ac.sendMu.Unlock()
1564 got.mu.Lock()1563 got.mu.Lock()
1565- require.True(t, got.terminal.TrueColor)
1566 got.mu.Unlock()1564 got.mu.Unlock()
1567 require.Len(t, opens, 2)1565 require.Len(t, opens, 2)
1568- require.Contains(t, opens[1], "TERM=xterm-direct")1566+ require.Contains(t, opens[1], "TERM=xterm-256color")
1569 require.Contains(t, opens[1], "COLORTERM=truecolor")1567 require.Contains(t, opens[1], "COLORTERM=truecolor")
1570 require.Contains(t, opens[1], "TERM_PROGRAM=vev")1568 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1571 _ = d.killSession(got, ports.ReasonSessionKilled, false)1569 _ = d.killSession(got, ports.ReasonSessionKilled, false)
@@ -1617,20 +1615,20 @@ func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) {
1617 }()1615 }()
1618 1616
1619 require.Equal(t, "/usr/bin/fish", commands[0])1617 require.Equal(t, "/usr/bin/fish", commands[0])
1620- require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])1618+ require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])
1621 require.Equal(t, "fish", sess.tabs[0].focusedPaneTitle(false), "the initial tab title identifies the shell requested by its creator")1619 require.Equal(t, "fish", sess.tabs[0].focusedPaneTitle(false), "the initial tab title identifies the shell requested by its creator")
1622 require.NoError(t, d.createTab(sess, ac.size))1620 require.NoError(t, d.createTab(sess, ac.size))
1623 require.Equal(t, "/bin/bash", commands[1])1621 require.Equal(t, "/bin/bash", commands[1])
1624- require.Equal(t, []string{"SECRET=second", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1])1622+ require.Equal(t, []string{"SECRET=second", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1])
1625 require.Equal(t, "bash", sess.tabs[1].focusedPaneTitle(false), "later tabs use the most recently attached shell")1623 require.Equal(t, "bash", sess.tabs[1].focusedPaneTitle(false), "later tabs use the most recently attached shell")
1626 }1624 }
1627 1625
1628 func TestChildEnvFromPreservesNonReservedEntriesVerbatimAndInOrder(t *testing.T) {1626 func TestChildEnvFromPreservesNonReservedEntriesVerbatimAndInOrder(t *testing.T) {
1629 env := []string{"SECRET=a=b=c", "TERM_PROGRAM_extra=keep", "TERM", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "EMPTY="}1627 env := []string{"SECRET=a=b=c", "TERM_PROGRAM_extra=keep", "TERM", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "EMPTY="}
1630- got := childEnvFrom(env, "work", "tab", "pane", terminalEnv{})1628+ got := childEnvFrom(env, "work", "tab", "pane")
1631 require.Equal(t, []string{1629 require.Equal(t, []string{
1632 "SECRET=a=b=c", "TERM_PROGRAM_extra=keep", "TERM", "EMPTY=",1630 "SECRET=a=b=c", "TERM_PROGRAM_extra=keep", "TERM", "EMPTY=",
1633- "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane",1631+ "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=tab,pane=pane",
1634 }, got)1632 }, got)
1635 }1633 }
1636 1634
M internal/usecase/daemon/snapshot_restore.go +10 -11
@@ -142,7 +142,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session, rep
142142 }
143143 }()
144144
145- sess := d.newRestoredSession(snap, sctx, cancel, opened, terminalEnvFromEnvironment(d.baseEnv))
145+ sess := d.newRestoredSession(snap, sctx, cancel, opened)
146146 // The loaded manifest is the repository head for this name. Future dirty
147147 // checkpoints must continue from it rather than reuse generation one.
148148 sess.snapshotPublishedGeneration = repositoryGeneration
@@ -165,10 +165,9 @@ func (d *Daemon) restoredSessionAlreadyExists(name string) bool {
165165 }
166166
167167 func (d *Daemon) restoreSnapshotTabs(ctx, sctx context.Context, snap snapcodec.Session) ([]*tab, error) {
168- // Restore runs before client Hello. Use the daemon's startup environment,
169- // including its terminal capability, until an attach supplies a fresher one.
168+ // Restore runs before client Hello. Pane terminal identity is stable and does
169+ // not depend on the daemon's startup environment.
170170 restoreEnv := copyEnvironment(d.baseEnv)
171- restoreTerm := terminalEnvFromEnvironment(restoreEnv)
172171 allowlist := d.restoreProcessAllowlistSnapshot()
173172 stoppedTabNames := d.restoredSessionTabNames(snap.Name)
174173 opened := make([]*tab, 0, len(snap.Tabs))
@@ -181,7 +180,7 @@ func (d *Daemon) restoreSnapshotTabs(ctx, sctx context.Context, snap snapcodec.S
181180 if index < len(stoppedTabNames) {
182181 tabName = stoppedTabNames[index]
183182 }
184- tb, err := d.restoreSnapshotTab(ctx, sctx, snap.Name, tabName, tabSnap, restoreEnv, restoreTerm, allowlist)
183+ tb, err := d.restoreSnapshotTab(ctx, sctx, snap.Name, tabName, tabSnap, restoreEnv, allowlist)
185184 if err != nil {
186185 closeRestoredTabs(opened)
187186 return nil, err
@@ -205,7 +204,7 @@ func (d *Daemon) restoredSessionTabNames(name string) []string {
205204 return append([]string(nil), d.stopped[name].tabNames...)
206205 }
207206
208-func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabName string, tabSnap snapcodec.Tab, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}) (*tab, error) {
207+func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabName string, tabSnap snapcodec.Tab, restoreEnv []string, allowlist map[string]struct{}) (*tab, error) {
209208 tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}
210209 if !tbSize.Valid() {
211210 return nil, fmt.Errorf("snapshot: invalid tab size")
@@ -241,7 +240,7 @@ func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabN
241240 tb.closeAllPanes()
242241 return nil, fmt.Errorf("snapshot: missing pane placement")
243242 }
244- p, err := d.restoreSnapshotPane(ctx, sessionName, tabStableID, paneSnap, contentRect, tbSize, restoreEnv, restoreTerm, allowlist, tb.ctx)
243+ p, err := d.restoreSnapshotPane(ctx, sessionName, tabStableID, paneSnap, contentRect, tbSize, restoreEnv, allowlist, tb.ctx)
245244 if err != nil {
246245 tb.closeAllPanes()
247246 return nil, err
@@ -251,7 +250,7 @@ func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabN
251250 return tb, nil
252251 }
253252
254-func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStableID string, paneSnap snapcodec.Pane, contentRect domain.Rect, tabSize domain.Size, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}, tabCtx context.Context) (*pane, error) {
253+func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStableID string, paneSnap snapcodec.Pane, contentRect domain.Rect, tabSize domain.Size, restoreEnv []string, allowlist map[string]struct{}, tabCtx context.Context) (*pane, error) {
255254 paneStableID := paneSnap.StableID
256255 if paneStableID == "" {
257256 var err error
@@ -262,7 +261,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
262261 }
263262 launch := d.shellLaunch(restoreEnv)
264263 lifetime := d.newPaneProcessLifetime(ctx, tabCtx)
265- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))
264+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))
266265 if err != nil {
267266 lifetime.abort()
268267 if pty != nil {
@@ -289,10 +288,10 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
289288 return p, nil
290289 }
291290
292-func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab, term terminalEnv) *session {
291+func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {
293292 // Restored sessions keep ordered tabs; attachment repair deterministically
294293 // selects the first tab instead of restoring an interactive client view.
295- sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, terminal: term, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
294+ sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
296295 // Restored tabs remain private until persistAndRegisterRestoredSession.
297296 // Initialize owners now so registration and reader startup cannot expose an
298297 // ownerless pane.
@@ -142,7 +142,7 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session, rep
142 }142 }
143 }()143 }()
144 144
145- sess := d.newRestoredSession(snap, sctx, cancel, opened, terminalEnvFromEnvironment(d.baseEnv))145+ sess := d.newRestoredSession(snap, sctx, cancel, opened)
146 // The loaded manifest is the repository head for this name. Future dirty146 // The loaded manifest is the repository head for this name. Future dirty
147 // checkpoints must continue from it rather than reuse generation one.147 // checkpoints must continue from it rather than reuse generation one.
148 sess.snapshotPublishedGeneration = repositoryGeneration148 sess.snapshotPublishedGeneration = repositoryGeneration
@@ -165,10 +165,9 @@ func (d *Daemon) restoredSessionAlreadyExists(name string) bool {
165 }165 }
166 166
167 func (d *Daemon) restoreSnapshotTabs(ctx, sctx context.Context, snap snapcodec.Session) ([]*tab, error) {167 func (d *Daemon) restoreSnapshotTabs(ctx, sctx context.Context, snap snapcodec.Session) ([]*tab, error) {
168- // Restore runs before client Hello. Use the daemon's startup environment,168+ // Restore runs before client Hello. Pane terminal identity is stable and does
169- // including its terminal capability, until an attach supplies a fresher one.169+ // not depend on the daemon's startup environment.
170 restoreEnv := copyEnvironment(d.baseEnv)170 restoreEnv := copyEnvironment(d.baseEnv)
171- restoreTerm := terminalEnvFromEnvironment(restoreEnv)
172 allowlist := d.restoreProcessAllowlistSnapshot()171 allowlist := d.restoreProcessAllowlistSnapshot()
173 stoppedTabNames := d.restoredSessionTabNames(snap.Name)172 stoppedTabNames := d.restoredSessionTabNames(snap.Name)
174 opened := make([]*tab, 0, len(snap.Tabs))173 opened := make([]*tab, 0, len(snap.Tabs))
@@ -181,7 +180,7 @@ func (d *Daemon) restoreSnapshotTabs(ctx, sctx context.Context, snap snapcodec.S
181 if index < len(stoppedTabNames) {180 if index < len(stoppedTabNames) {
182 tabName = stoppedTabNames[index]181 tabName = stoppedTabNames[index]
183 }182 }
184- tb, err := d.restoreSnapshotTab(ctx, sctx, snap.Name, tabName, tabSnap, restoreEnv, restoreTerm, allowlist)183+ tb, err := d.restoreSnapshotTab(ctx, sctx, snap.Name, tabName, tabSnap, restoreEnv, allowlist)
185 if err != nil {184 if err != nil {
186 closeRestoredTabs(opened)185 closeRestoredTabs(opened)
187 return nil, err186 return nil, err
@@ -205,7 +204,7 @@ func (d *Daemon) restoredSessionTabNames(name string) []string {
205 return append([]string(nil), d.stopped[name].tabNames...)204 return append([]string(nil), d.stopped[name].tabNames...)
206 }205 }
207 206
208-func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabName string, tabSnap snapcodec.Tab, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}) (*tab, error) {207+func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabName string, tabSnap snapcodec.Tab, restoreEnv []string, allowlist map[string]struct{}) (*tab, error) {
209 tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}208 tbSize := domain.Size{Cols: int(tabSnap.Cols), Rows: int(tabSnap.Rows)}
210 if !tbSize.Valid() {209 if !tbSize.Valid() {
211 return nil, fmt.Errorf("snapshot: invalid tab size")210 return nil, fmt.Errorf("snapshot: invalid tab size")
@@ -241,7 +240,7 @@ func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabN
241 tb.closeAllPanes()240 tb.closeAllPanes()
242 return nil, fmt.Errorf("snapshot: missing pane placement")241 return nil, fmt.Errorf("snapshot: missing pane placement")
243 }242 }
244- p, err := d.restoreSnapshotPane(ctx, sessionName, tabStableID, paneSnap, contentRect, tbSize, restoreEnv, restoreTerm, allowlist, tb.ctx)243+ p, err := d.restoreSnapshotPane(ctx, sessionName, tabStableID, paneSnap, contentRect, tbSize, restoreEnv, allowlist, tb.ctx)
245 if err != nil {244 if err != nil {
246 tb.closeAllPanes()245 tb.closeAllPanes()
247 return nil, err246 return nil, err
@@ -251,7 +250,7 @@ func (d *Daemon) restoreSnapshotTab(ctx, sctx context.Context, sessionName, tabN
251 return tb, nil250 return tb, nil
252 }251 }
253 252
254-func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStableID string, paneSnap snapcodec.Pane, contentRect domain.Rect, tabSize domain.Size, restoreEnv []string, restoreTerm terminalEnv, allowlist map[string]struct{}, tabCtx context.Context) (*pane, error) {253+func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStableID string, paneSnap snapcodec.Pane, contentRect domain.Rect, tabSize domain.Size, restoreEnv []string, allowlist map[string]struct{}, tabCtx context.Context) (*pane, error) {
255 paneStableID := paneSnap.StableID254 paneStableID := paneSnap.StableID
256 if paneStableID == "" {255 if paneStableID == "" {
257 var err error256 var err error
@@ -262,7 +261,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
262 }261 }
263 launch := d.shellLaunch(restoreEnv)262 launch := d.shellLaunch(restoreEnv)
264 lifetime := d.newPaneProcessLifetime(ctx, tabCtx)263 lifetime := d.newPaneProcessLifetime(ctx, tabCtx)
265- pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))264+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))
266 if err != nil {265 if err != nil {
267 lifetime.abort()266 lifetime.abort()
268 if pty != nil {267 if pty != nil {
@@ -289,10 +288,10 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
289 return p, nil288 return p, nil
290 }289 }
291 290
292-func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab, term terminalEnv) *session {291+func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {
293 // Restored sessions keep ordered tabs; attachment repair deterministically292 // Restored sessions keep ordered tabs; attachment repair deterministically
294 // selects the first tab instead of restoring an interactive client view.293 // selects the first tab instead of restoring an interactive client view.
295- sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, terminal: term, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}294+ sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
296 // Restored tabs remain private until persistAndRegisterRestoredSession.295 // Restored tabs remain private until persistAndRegisterRestoredSession.
297 // Initialize owners now so registration and reader startup cannot expose an296 // Initialize owners now so registration and reader startup cannot expose an
298 // ownerless pane.297 // ownerless pane.
M internal/usecase/daemon/snapshot_restore_test.go +3 -3
@@ -232,7 +232,6 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
232232 require.NotNil(t, restored)
233233 require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean")
234234 require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head")
235- require.True(t, restored.terminal.TrueColor, "restored sessions must retain startup terminal capability for future panes")
236235 require.Equal(t, 0, testAttachmentTabIndex(restored))
237236 require.Equal(t, "/snapshot/cwd", restored.cwd)
238237 require.Len(t, restored.tabs, 1)
@@ -321,7 +320,7 @@ func TestSnapshotRestoreUsesDaemonTerminalCapabilities(t *testing.T) {
321320 "XDG_RUNTIME_DIR=/run/user/1000",
322321 "WAYLAND_DISPLAY=wayland-1",
323322 },
324- wantTerm: "TERM=xterm-direct",
323+ wantTerm: "TERM=xterm-256color",
325324 wantColorTerm: "COLORTERM=truecolor",
326325 notWantEntries: []string{"TERM=xterm-kitty"},
327326 },
@@ -334,7 +333,8 @@ func TestSnapshotRestoreUsesDaemonTerminalCapabilities(t *testing.T) {
334333 "WAYLAND_DISPLAY=wayland-1",
335334 },
336335 wantTerm: "TERM=xterm-256color",
337- notWantEntries: []string{"COLORTERM=old", "COLORTERM=truecolor"},
336+ wantColorTerm: "COLORTERM=truecolor",
337+ notWantEntries: []string{"COLORTERM=old"},
338338 },
339339 }
340340
@@ -232,7 +232,6 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
232 require.NotNil(t, restored)232 require.NotNil(t, restored)
233 require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean")233 require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean")
234 require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head")234 require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head")
235- require.True(t, restored.terminal.TrueColor, "restored sessions must retain startup terminal capability for future panes")
236 require.Equal(t, 0, testAttachmentTabIndex(restored))235 require.Equal(t, 0, testAttachmentTabIndex(restored))
237 require.Equal(t, "/snapshot/cwd", restored.cwd)236 require.Equal(t, "/snapshot/cwd", restored.cwd)
238 require.Len(t, restored.tabs, 1)237 require.Len(t, restored.tabs, 1)
@@ -321,7 +320,7 @@ func TestSnapshotRestoreUsesDaemonTerminalCapabilities(t *testing.T) {
321 "XDG_RUNTIME_DIR=/run/user/1000",320 "XDG_RUNTIME_DIR=/run/user/1000",
322 "WAYLAND_DISPLAY=wayland-1",321 "WAYLAND_DISPLAY=wayland-1",
323 },322 },
324- wantTerm: "TERM=xterm-direct",323+ wantTerm: "TERM=xterm-256color",
325 wantColorTerm: "COLORTERM=truecolor",324 wantColorTerm: "COLORTERM=truecolor",
326 notWantEntries: []string{"TERM=xterm-kitty"},325 notWantEntries: []string{"TERM=xterm-kitty"},
327 },326 },
@@ -334,7 +333,8 @@ func TestSnapshotRestoreUsesDaemonTerminalCapabilities(t *testing.T) {
334 "WAYLAND_DISPLAY=wayland-1",333 "WAYLAND_DISPLAY=wayland-1",
335 },334 },
336 wantTerm: "TERM=xterm-256color",335 wantTerm: "TERM=xterm-256color",
337- notWantEntries: []string{"COLORTERM=old", "COLORTERM=truecolor"},336+ wantColorTerm: "COLORTERM=truecolor",
337+ notWantEntries: []string{"COLORTERM=old"},
338 },338 },
339 }339 }
340 340
A internal/usecase/daemon/terminal_color.go +36 -0
@@ -0,0 +1,36 @@
1+package daemon
2+
3+import renderer "github.com/bnema/vev-vt"
4+
5+// adaptFrameColors preserves the composed frame for capable attachments and
6+// converts direct colors to the xterm 256-color cube for constrained ones.
7+func adaptFrameColors(frame renderer.Frame, trueColor bool) renderer.Frame {
8+ if trueColor {
9+ return frame
10+ }
11+ adapted := frame.Clone()
12+ for i := range adapted.Cells {
13+ style := &adapted.Cells[i].Style
14+ if style.HasForegroundRGB {
15+ style.Foreground = xterm256Color(style.ForegroundRGB)
16+ style.HasForegroundRGB = false
17+ }
18+ if style.HasBackgroundRGB {
19+ style.Background = xterm256Color(style.BackgroundRGB)
20+ style.HasBackgroundRGB = false
21+ }
22+ if style.HasUnderlineColorRGB {
23+ style.UnderlineColor = xterm256Color(style.UnderlineColorRGB)
24+ style.HasUnderlineColorRGB = false
25+ style.HasUnderlineColor = true
26+ }
27+ }
28+ return adapted
29+}
30+
31+func xterm256Color(rgb renderer.RGB) int {
32+ red := (int(rgb.R)*5 + 127) / 255
33+ green := (int(rgb.G)*5 + 127) / 255
34+ blue := (int(rgb.B)*5 + 127) / 255
35+ return 16 + 36*red + 6*green + blue
36+}
@@ -0,0 +1,36 @@
1+package daemon
2+
3+import renderer "github.com/bnema/vev-vt"
4+
5+// adaptFrameColors preserves the composed frame for capable attachments and
6+// converts direct colors to the xterm 256-color cube for constrained ones.
7+func adaptFrameColors(frame renderer.Frame, trueColor bool) renderer.Frame {
8+ if trueColor {
9+ return frame
10+ }
11+ adapted := frame.Clone()
12+ for i := range adapted.Cells {
13+ style := &adapted.Cells[i].Style
14+ if style.HasForegroundRGB {
15+ style.Foreground = xterm256Color(style.ForegroundRGB)
16+ style.HasForegroundRGB = false
17+ }
18+ if style.HasBackgroundRGB {
19+ style.Background = xterm256Color(style.BackgroundRGB)
20+ style.HasBackgroundRGB = false
21+ }
22+ if style.HasUnderlineColorRGB {
23+ style.UnderlineColor = xterm256Color(style.UnderlineColorRGB)
24+ style.HasUnderlineColorRGB = false
25+ style.HasUnderlineColor = true
26+ }
27+ }
28+ return adapted
29+}
30+
31+func xterm256Color(rgb renderer.RGB) int {
32+ red := (int(rgb.R)*5 + 127) / 255
33+ green := (int(rgb.G)*5 + 127) / 255
34+ blue := (int(rgb.B)*5 + 127) / 255
35+ return 16 + 36*red + 6*green + blue
36+}
A internal/usecase/daemon/terminal_color_test.go +29 -0
@@ -0,0 +1,29 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestAdaptFrameColors(t *testing.T) {
11+ frame := renderer.NewFrame(1, 1)
12+ frame.Cells[0].Style.HasForegroundRGB = true
13+ frame.Cells[0].Style.ForegroundRGB = renderer.RGB{R: 255, G: 0, B: 0}
14+ frame.Cells[0].Style.HasBackgroundRGB = true
15+ frame.Cells[0].Style.BackgroundRGB = renderer.RGB{R: 0, G: 255, B: 0}
16+ frame.Cells[0].Style.HasUnderlineColor = true
17+ frame.Cells[0].Style.HasUnderlineColorRGB = true
18+ frame.Cells[0].Style.UnderlineColorRGB = renderer.RGB{R: 0, G: 0, B: 255}
19+
20+ got := adaptFrameColors(frame, false)
21+
22+ require.False(t, got.Cells[0].Style.HasForegroundRGB)
23+ require.Equal(t, 196, got.Cells[0].Style.Foreground)
24+ require.False(t, got.Cells[0].Style.HasBackgroundRGB)
25+ require.Equal(t, 46, got.Cells[0].Style.Background)
26+ require.False(t, got.Cells[0].Style.HasUnderlineColorRGB)
27+ require.Equal(t, 21, got.Cells[0].Style.UnderlineColor)
28+ require.True(t, frame.Cells[0].Style.HasForegroundRGB, "adaptation must not mutate composed state")
29+}
@@ -0,0 +1,29 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestAdaptFrameColors(t *testing.T) {
11+ frame := renderer.NewFrame(1, 1)
12+ frame.Cells[0].Style.HasForegroundRGB = true
13+ frame.Cells[0].Style.ForegroundRGB = renderer.RGB{R: 255, G: 0, B: 0}
14+ frame.Cells[0].Style.HasBackgroundRGB = true
15+ frame.Cells[0].Style.BackgroundRGB = renderer.RGB{R: 0, G: 255, B: 0}
16+ frame.Cells[0].Style.HasUnderlineColor = true
17+ frame.Cells[0].Style.HasUnderlineColorRGB = true
18+ frame.Cells[0].Style.UnderlineColorRGB = renderer.RGB{R: 0, G: 0, B: 255}
19+
20+ got := adaptFrameColors(frame, false)
21+
22+ require.False(t, got.Cells[0].Style.HasForegroundRGB)
23+ require.Equal(t, 196, got.Cells[0].Style.Foreground)
24+ require.False(t, got.Cells[0].Style.HasBackgroundRGB)
25+ require.Equal(t, 46, got.Cells[0].Style.Background)
26+ require.False(t, got.Cells[0].Style.HasUnderlineColorRGB)
27+ require.Equal(t, 21, got.Cells[0].Style.UnderlineColor)
28+ require.True(t, frame.Cells[0].Style.HasForegroundRGB, "adaptation must not mutate composed state")
29+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close