detached 394b69a

fix(terminal): address truecolor review feedback

commit
394b69a9721573437968ff1a4a773c212cd2144e
author
brice · · 2026-07-06 18:51 UTC
parent
47168d8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +77 -13
M README.md +2 -2
@@ -133,9 +133,9 @@ Host *
133133
134134 ## Terminal color
135135
136-When the attaching client reports truecolor support, vev advertises panes as `TERM=xterm-direct` and exports `COLORTERM=truecolor` to child processes. Otherwise, panes use the conservative `TERM=xterm-256color` and do not receive `COLORTERM`. This works for local and remote attach because the client capability is carried inside vev's protocol rather than relying on SSH `SendEnv`/`AcceptEnv`.
136+When the attaching client reports truecolor support, vev advertises newly spawned panes as `TERM=xterm-direct` and exports `COLORTERM=truecolor` to child processes. Otherwise, panes use the conservative `TERM=xterm-256color` and do not receive `COLORTERM`. This works for local and remote attach because the client capability is carried inside vev's protocol rather than relying on SSH `SendEnv`/`AcceptEnv`.
137137
138-Applications that use terminfo can detect direct color from `xterm-direct`; applications that use the common environment convention can use `COLORTERM=truecolor` when it is present.
138+Applications that use terminfo can detect direct color from `xterm-direct`; applications that use the common environment convention can use `COLORTERM=truecolor` when it is present. Panes restored from a saved snapshot start conservatively before any client attaches; after attach or resume, newly spawned panes use that client's reported capability.
139139
140140 ## Keys
141141
@@ -133,9 +133,9 @@ Host *
133 133
134 ## Terminal color134 ## Terminal color
135 135
136-When the attaching client reports truecolor support, vev advertises panes as `TERM=xterm-direct` and exports `COLORTERM=truecolor` to child processes. Otherwise, panes use the conservative `TERM=xterm-256color` and do not receive `COLORTERM`. This works for local and remote attach because the client capability is carried inside vev's protocol rather than relying on SSH `SendEnv`/`AcceptEnv`.136+When the attaching client reports truecolor support, vev advertises newly spawned panes as `TERM=xterm-direct` and exports `COLORTERM=truecolor` to child processes. Otherwise, panes use the conservative `TERM=xterm-256color` and do not receive `COLORTERM`. This works for local and remote attach because the client capability is carried inside vev's protocol rather than relying on SSH `SendEnv`/`AcceptEnv`.
137 137
138-Applications that use terminfo can detect direct color from `xterm-direct`; applications that use the common environment convention can use `COLORTERM=truecolor` when it is present.138+Applications that use terminfo can detect direct color from `xterm-direct`; applications that use the common environment convention can use `COLORTERM=truecolor` when it is present. Panes restored from a saved snapshot start conservatively before any client attaches; after attach or resume, newly spawned panes use that client's reported capability.
139 139
140 ## Keys140 ## Keys
141 141
M internal/app/run.go +14 -8
@@ -450,6 +450,19 @@ func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
450450 return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff)
451451 }
452452
453+func detachedLocalHello(name, cwd string) ports.Hello {
454+ termEnv := os.Getenv("TERM")
455+ return ports.Hello{
456+ Version: ports.ProtocolVersion,
457+ Intent: ports.IntentNew,
458+ Name: name,
459+ Size: domain.Size{Cols: 80, Rows: 24},
460+ TermEnv: termEnv,
461+ Cwd: cwd,
462+ TrueColor: client.DetectTrueColor(termEnv, os.Getenv("COLORTERM")),
463+ }
464+}
465+
453466 func createDetachedLocalSession(ctx context.Context, name string) error {
454467 transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
455468 if err != nil {
@@ -461,14 +474,7 @@ func createDetachedLocalSession(ctx context.Context, name string) error {
461474 if err != nil {
462475 cwd = ""
463476 }
464- hello := ports.Hello{
465- Version: ports.ProtocolVersion,
466- Intent: ports.IntentNew,
467- Name: name,
468- Size: domain.Size{Cols: 80, Rows: 24},
469- TermEnv: os.Getenv("TERM"),
470- Cwd: cwd,
471- }
477+ hello := detachedLocalHello(name, cwd)
472478 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
473479 return fmt.Errorf("vev: creating detached session: %w", err)
474480 }
@@ -450,6 +450,19 @@ func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
450 return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff)450 return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff)
451 }451 }
452 452
453+func detachedLocalHello(name, cwd string) ports.Hello {
454+ termEnv := os.Getenv("TERM")
455+ return ports.Hello{
456+ Version: ports.ProtocolVersion,
457+ Intent: ports.IntentNew,
458+ Name: name,
459+ Size: domain.Size{Cols: 80, Rows: 24},
460+ TermEnv: termEnv,
461+ Cwd: cwd,
462+ TrueColor: client.DetectTrueColor(termEnv, os.Getenv("COLORTERM")),
463+ }
464+}
465+
453 func createDetachedLocalSession(ctx context.Context, name string) error {466 func createDetachedLocalSession(ctx context.Context, name string) error {
454 transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)467 transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
455 if err != nil {468 if err != nil {
@@ -461,14 +474,7 @@ func createDetachedLocalSession(ctx context.Context, name string) error {
461 if err != nil {474 if err != nil {
462 cwd = ""475 cwd = ""
463 }476 }
464- hello := ports.Hello{477+ hello := detachedLocalHello(name, cwd)
465- Version: ports.ProtocolVersion,
466- Intent: ports.IntentNew,
467- Name: name,
468- Size: domain.Size{Cols: 80, Rows: 24},
469- TermEnv: os.Getenv("TERM"),
470- Cwd: cwd,
471- }
472 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {478 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
473 return fmt.Errorf("vev: creating detached session: %w", err)479 return fmt.Errorf("vev: creating detached session: %w", err)
474 }480 }
M internal/app/run_test.go +14 -0
@@ -18,6 +18,7 @@ import (
1818 "github.com/bnema/vev/internal/ports"
1919 "github.com/bnema/vev/internal/usecase/client"
2020 "github.com/bnema/vev/internal/usecase/confirm"
21+ "github.com/stretchr/testify/require"
2122 )
2223
2324 func TestParseArgs(t *testing.T) {
@@ -411,6 +412,19 @@ func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
411412 }
412413 }
413414
415+func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) {
416+ t.Setenv("TERM", "xterm-direct")
417+ t.Setenv("COLORTERM", "")
418+
419+ hello := detachedLocalHello("scratch", "/tmp/work")
420+
421+ require.Equal(t, ports.IntentNew, hello.Intent)
422+ require.Equal(t, "scratch", hello.Name)
423+ require.Equal(t, "xterm-direct", hello.TermEnv)
424+ require.Equal(t, "/tmp/work", hello.Cwd)
425+ require.True(t, hello.TrueColor)
426+}
427+
414428 type namedDialer struct{ name string }
415429
416430 func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
@@ -18,6 +18,7 @@ import (
18 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
19 "github.com/bnema/vev/internal/usecase/client"19 "github.com/bnema/vev/internal/usecase/client"
20 "github.com/bnema/vev/internal/usecase/confirm"20 "github.com/bnema/vev/internal/usecase/confirm"
21+ "github.com/stretchr/testify/require"
21 )22 )
22 23
23 func TestParseArgs(t *testing.T) {24 func TestParseArgs(t *testing.T) {
@@ -411,6 +412,19 @@ func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
411 }412 }
412 }413 }
413 414
415+func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) {
416+ t.Setenv("TERM", "xterm-direct")
417+ t.Setenv("COLORTERM", "")
418+
419+ hello := detachedLocalHello("scratch", "/tmp/work")
420+
421+ require.Equal(t, ports.IntentNew, hello.Intent)
422+ require.Equal(t, "scratch", hello.Name)
423+ require.Equal(t, "xterm-direct", hello.TermEnv)
424+ require.Equal(t, "/tmp/work", hello.Cwd)
425+ require.True(t, hello.TrueColor)
426+}
427+
414 type namedDialer struct{ name string }428 type namedDialer struct{ name string }
415 429
416 func (d namedDialer) Dial(context.Context) (ports.Transport, error) {430 func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
M internal/usecase/client/client.go +3 -2
@@ -332,7 +332,8 @@ type attachResult struct {
332332 err error
333333 }
334334
335-func detectTrueColor(termEnv, colorTerm string) bool {
335+// DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
336+func DetectTrueColor(termEnv, colorTerm string) bool {
336337 switch strings.ToLower(strings.TrimSpace(colorTerm)) {
337338 case "truecolor", "24bit":
338339 return true
@@ -354,7 +355,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
354355 }
355356 termEnv := os.Getenv("TERM")
356357 colorTerm := os.Getenv("COLORTERM")
357- trueColor := detectTrueColor(termEnv, colorTerm)
358+ trueColor := DetectTrueColor(termEnv, colorTerm)
358359 hello := ports.Hello{
359360 Version: ports.ProtocolVersion,
360361 Intent: intent,
@@ -332,7 +332,8 @@ type attachResult struct {
332 err error332 err error
333 }333 }
334 334
335-func detectTrueColor(termEnv, colorTerm string) bool {335+// DetectTrueColor reports whether TERM/COLORTERM advertise direct color support.
336+func DetectTrueColor(termEnv, colorTerm string) bool {
336 switch strings.ToLower(strings.TrimSpace(colorTerm)) {337 switch strings.ToLower(strings.TrimSpace(colorTerm)) {
337 case "truecolor", "24bit":338 case "truecolor", "24bit":
338 return true339 return true
@@ -354,7 +355,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
354 }355 }
355 termEnv := os.Getenv("TERM")356 termEnv := os.Getenv("TERM")
356 colorTerm := os.Getenv("COLORTERM")357 colorTerm := os.Getenv("COLORTERM")
357- trueColor := detectTrueColor(termEnv, colorTerm)358+ trueColor := DetectTrueColor(termEnv, colorTerm)
358 hello := ports.Hello{359 hello := ports.Hello{
359 Version: ports.ProtocolVersion,360 Version: ports.ProtocolVersion,
360 Intent: intent,361 Intent: intent,
M internal/usecase/client/truecolor_test.go +1 -1
@@ -25,7 +25,7 @@ func TestDetectTrueColor(t *testing.T) {
2525
2626 for _, tt := range tests {
2727 t.Run(tt.name, func(t *testing.T) {
28- require.Equal(t, tt.want, detectTrueColor(tt.termEnv, tt.colorTerm))
28+ require.Equal(t, tt.want, DetectTrueColor(tt.termEnv, tt.colorTerm))
2929 })
3030 }
3131 }
@@ -25,7 +25,7 @@ func TestDetectTrueColor(t *testing.T) {
25 25
26 for _, tt := range tests {26 for _, tt := range tests {
27 t.Run(tt.name, func(t *testing.T) {27 t.Run(tt.name, func(t *testing.T) {
28- require.Equal(t, tt.want, detectTrueColor(tt.termEnv, tt.colorTerm))28+ require.Equal(t, tt.want, DetectTrueColor(tt.termEnv, tt.colorTerm))
29 })29 })
30 }30 }
31 }31 }
M internal/usecase/daemon/session_test.go +43 -0
@@ -1091,6 +1091,49 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
10911091 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
10921092 }
10931093
1094+func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1095+ sz := domain.Size{Cols: 80, Rows: 24}
1096+ p1, release1 := newBlockingPTY(t)
1097+ defer release1()
1098+ p2, release2 := newBlockingPTY(t)
1099+ defer release2()
1100+
1101+ var opens [][]string
1102+ f := portsmocks.NewMockPTYFactory(t)
1103+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1104+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1105+ opens = append(opens, append([]string(nil), env...))
1106+ if len(opens) == 1 {
1107+ return p1, nil
1108+ }
1109+ return p2, nil
1110+ },
1111+ ).Twice()
1112+
1113+ d := newTestDaemon(t, f, stubClock{})
1114+ tr1 := portsmocks.NewMockTransport(t)
1115+ tr1.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1116+ tr1.EXPECT().Close().Return(nil).Maybe()
1117+ tr2 := portsmocks.NewMockTransport(t)
1118+ tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1119+ tr2.EXPECT().Close().Return(nil).Maybe()
1120+ sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: false}, tr1)
1121+ require.NoError(t, err)
1122+ _, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz, TrueColor: true}, tr2)
1123+ require.NoError(t, err)
1124+ defer func() {
1125+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
1126+ d.sessWg.Wait()
1127+ }()
1128+
1129+ require.Contains(t, opens[0], "TERM=xterm-256color")
1130+ require.NotContains(t, opens[0], "COLORTERM=truecolor")
1131+ require.NoError(t, d.createTab(sess, ac.size))
1132+ require.Contains(t, opens[1], "TERM=xterm-direct")
1133+ require.Contains(t, opens[1], "COLORTERM=truecolor")
1134+ require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1135+}
1136+
10941137 func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
10951138 sz := domain.Size{Cols: 80, Rows: 24}
10961139 p1, release1 := newBlockingPTY(t)
@@ -1091,6 +1091,49 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1091 require.Contains(t, opens[1], "TERM_PROGRAM=vev")1091 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1092 }1092 }
1093 1093
1094+func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1095+ sz := domain.Size{Cols: 80, Rows: 24}
1096+ p1, release1 := newBlockingPTY(t)
1097+ defer release1()
1098+ p2, release2 := newBlockingPTY(t)
1099+ defer release2()
1100+
1101+ var opens [][]string
1102+ f := portsmocks.NewMockPTYFactory(t)
1103+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1104+ func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1105+ opens = append(opens, append([]string(nil), env...))
1106+ if len(opens) == 1 {
1107+ return p1, nil
1108+ }
1109+ return p2, nil
1110+ },
1111+ ).Twice()
1112+
1113+ d := newTestDaemon(t, f, stubClock{})
1114+ tr1 := portsmocks.NewMockTransport(t)
1115+ tr1.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1116+ tr1.EXPECT().Close().Return(nil).Maybe()
1117+ tr2 := portsmocks.NewMockTransport(t)
1118+ tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1119+ tr2.EXPECT().Close().Return(nil).Maybe()
1120+ sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: false}, tr1)
1121+ require.NoError(t, err)
1122+ _, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz, TrueColor: true}, tr2)
1123+ require.NoError(t, err)
1124+ defer func() {
1125+ _ = d.killSession(sess, ports.ReasonServerShutdown, false)
1126+ d.sessWg.Wait()
1127+ }()
1128+
1129+ require.Contains(t, opens[0], "TERM=xterm-256color")
1130+ require.NotContains(t, opens[0], "COLORTERM=truecolor")
1131+ require.NoError(t, d.createTab(sess, ac.size))
1132+ require.Contains(t, opens[1], "TERM=xterm-direct")
1133+ require.Contains(t, opens[1], "COLORTERM=truecolor")
1134+ require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1135+}
1136+
1094 func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {1137 func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1095 sz := domain.Size{Cols: 80, Rows: 24}1138 sz := domain.Size{Cols: 80, Rows: 24}
1096 p1, release1 := newBlockingPTY(t)1139 p1, release1 := newBlockingPTY(t)

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close