detached 845b093

feat(terminal): select host-available xterm-direct for panes (#261)

* feat(terminal): select host-available xterm-direct for panes

* test(pty): compare working directory identity across symlinks
commit
845b09349c5e5cd091a91ef322e568a1cb3ee2db
author
bnema · · 2026-09-06 20:09 UTC
committer
GitHub · · 2026-09-06 20:09 UTC
parent
d587eb9
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
8 files · +292 -9
M docs/terminal.md +37 -1
@@ -2,10 +2,46 @@
22
33 ## Color
44
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.
5+Panes receive `TERM=xterm-direct` when the pane host can look up that terminfo entry, otherwise `TERM=xterm-256color`. Both profiles receive `COLORTERM=truecolor` and `TERM_PROGRAM=vev`. The profile describes vev's virtual terminal, not the terminal attached outside vev.
6+
7+Before each PTY launch, vev runs `infocmp -x xterm-direct` on the daemon's host with the child's environment and working directory. This honors terminfo search settings such as `TERMINFO`, `TERMINFO_DIRS`, and `HOME`. The helper is resolved through the daemon's `PATH`. A missing helper, failed lookup, or one-second probe timeout selects the 256-color compatibility profile. Existing panes keep their original environment; newly opened or restored panes check again.
8+
9+`xterm-direct` advertises 16,777,216 colors through terminfo and uses direct RGB color setters. With `xterm-256color`, applications need to recognize `COLORTERM=truecolor` or otherwise enable RGB output; `tput colors` reports only the entry's 256 indexed colors.
610
711 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.
812
13+### Installing the direct-color entry
14+
15+Check availability on the machine where the pane runs, including each remote vev host:
16+
17+```sh
18+infocmp -x xterm-direct
19+tput -T xterm-direct colors
20+# Expected color count: 16777216
21+```
22+
23+On Arch Linux and CachyOS, the `ncurses` package supplies the entry and the `infocmp`, `tput`, and `tic` tools:
24+
25+```sh
26+sudo pacman -S ncurses
27+```
28+
29+Other distributions may package extended terminfo entries separately. Install their terminfo database and ncurses utilities, then repeat the checks above.
30+
31+For a user-local installation, export the entry on a machine that has it:
32+
33+```sh
34+infocmp -x xterm-direct > xterm-direct.terminfo
35+```
36+
37+Copy the file to the destination and compile it there:
38+
39+```sh
40+tic -x -o ~/.terminfo xterm-direct.terminfo
41+```
42+
43+Open a new pane to use the installed entry. The daemon also needs `infocmp` available in its `PATH`; restart the daemon if that search path needs to change. SSH destinations and containers entered from a pane need their own entry if they inherit `TERM=xterm-direct`. Installation on the pane host does not install it inside those environments.
44+
945 ## Kitty graphics
1046
1147 Kitty graphics support is attachment-local and explicitly declared only after a
@@ -2,10 +2,46 @@
2 2
3 ## Color3 ## Color
4 4
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.5+Panes receive `TERM=xterm-direct` when the pane host can look up that terminfo entry, otherwise `TERM=xterm-256color`. Both profiles receive `COLORTERM=truecolor` and `TERM_PROGRAM=vev`. The profile describes vev's virtual terminal, not the terminal attached outside vev.
6+
7+Before each PTY launch, vev runs `infocmp -x xterm-direct` on the daemon's host with the child's environment and working directory. This honors terminfo search settings such as `TERMINFO`, `TERMINFO_DIRS`, and `HOME`. The helper is resolved through the daemon's `PATH`. A missing helper, failed lookup, or one-second probe timeout selects the 256-color compatibility profile. Existing panes keep their original environment; newly opened or restored panes check again.
8+
9+`xterm-direct` advertises 16,777,216 colors through terminfo and uses direct RGB color setters. With `xterm-256color`, applications need to recognize `COLORTERM=truecolor` or otherwise enable RGB output; `tput colors` reports only the entry's 256 indexed colors.
6 10
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.11 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.
8 12
13+### Installing the direct-color entry
14+
15+Check availability on the machine where the pane runs, including each remote vev host:
16+
17+```sh
18+infocmp -x xterm-direct
19+tput -T xterm-direct colors
20+# Expected color count: 16777216
21+```
22+
23+On Arch Linux and CachyOS, the `ncurses` package supplies the entry and the `infocmp`, `tput`, and `tic` tools:
24+
25+```sh
26+sudo pacman -S ncurses
27+```
28+
29+Other distributions may package extended terminfo entries separately. Install their terminfo database and ncurses utilities, then repeat the checks above.
30+
31+For a user-local installation, export the entry on a machine that has it:
32+
33+```sh
34+infocmp -x xterm-direct > xterm-direct.terminfo
35+```
36+
37+Copy the file to the destination and compile it there:
38+
39+```sh
40+tic -x -o ~/.terminfo xterm-direct.terminfo
41+```
42+
43+Open a new pane to use the installed entry. The daemon also needs `infocmp` available in its `PATH`; restart the daemon if that search path needs to change. SSH destinations and containers entered from a pane need their own entry if they inherit `TERM=xterm-direct`. Installation on the pane host does not install it inside those environments.
44+
9 ## Kitty graphics45 ## Kitty graphics
10 46
11 Kitty graphics support is attachment-local and explicitly declared only after a47 Kitty graphics support is attachment-local and explicitly declared only after a
M internal/adapters/pty/pty_unix.go +10 -6
@@ -34,12 +34,16 @@ type Factory struct{}
3434 func NewFactory() *Factory { return &Factory{} }
3535
3636 // Open spawns command with args attached to a freshly allocated pseudo-terminal
37-// and returns the master side as a ports.PTY. env is passed to the child
38-// verbatim (nil means inherit the current process environment); the caller
39-// decides TERM and friends. geometry sets the terminal window geometry before
40-// the child starts, so the child observes the correct dimensions on its first
41-// query.
37+// and returns the master side as a ports.PTY. env supplies the child environment
38+// (nil means inherit the current process environment). TERM is selected from
39+// the host's available terminfo entries; other entries are preserved. geometry
40+// sets the terminal window geometry before the child's first query.
4241 func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, geometry domain.Geometry) (ports.PTY, error) {
42+ if err := ctx.Err(); err != nil {
43+ return nil, err
44+ }
45+ dir = platform.DirOrHome(dir)
46+ env = paneEnvironment(ctx, env, dir)
4347 if err := ctx.Err(); err != nil {
4448 return nil, err
4549 }
@@ -75,7 +79,7 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st
7579
7680 cmd := exec.CommandContext(ctx, command, args...)
7781 cmd.Env = env
78- cmd.Dir = platform.DirOrHome(dir)
82+ cmd.Dir = dir
7983 cmd.Stdin = slave
8084 cmd.Stdout = slave
8185 cmd.Stderr = slave
@@ -34,12 +34,16 @@ type Factory struct{}
34 func NewFactory() *Factory { return &Factory{} }34 func NewFactory() *Factory { return &Factory{} }
35 35
36 // Open spawns command with args attached to a freshly allocated pseudo-terminal36 // Open spawns command with args attached to a freshly allocated pseudo-terminal
37-// and returns the master side as a ports.PTY. env is passed to the child37+// and returns the master side as a ports.PTY. env supplies the child environment
38-// verbatim (nil means inherit the current process environment); the caller38+// (nil means inherit the current process environment). TERM is selected from
39-// decides TERM and friends. geometry sets the terminal window geometry before39+// the host's available terminfo entries; other entries are preserved. geometry
40-// the child starts, so the child observes the correct dimensions on its first40+// sets the terminal window geometry before the child's first query.
41-// query.
42 func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, geometry domain.Geometry) (ports.PTY, error) {41 func (Factory) Open(ctx context.Context, command string, args []string, env []string, dir string, geometry domain.Geometry) (ports.PTY, error) {
42+ if err := ctx.Err(); err != nil {
43+ return nil, err
44+ }
45+ dir = platform.DirOrHome(dir)
46+ env = paneEnvironment(ctx, env, dir)
43 if err := ctx.Err(); err != nil {47 if err := ctx.Err(); err != nil {
44 return nil, err48 return nil, err
45 }49 }
@@ -75,7 +79,7 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st
75 79
76 cmd := exec.CommandContext(ctx, command, args...)80 cmd := exec.CommandContext(ctx, command, args...)
77 cmd.Env = env81 cmd.Env = env
78- cmd.Dir = platform.DirOrHome(dir)82+ cmd.Dir = dir
79 cmd.Stdin = slave83 cmd.Stdin = slave
80 cmd.Stdout = slave84 cmd.Stdout = slave
81 cmd.Stderr = slave85 cmd.Stderr = slave
A internal/adapters/pty/terminfo_linux_test.go +35 -0
@@ -0,0 +1,35 @@
1+//go:build linux
2+
3+package pty_test
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestOpenSelectsPaneTerminal(t *testing.T) {
16+ for _, tt := range []struct {
17+ name string
18+ exit string
19+ want string
20+ }{
21+ {name: "direct", exit: "0", want: "xterm-direct"},
22+ {name: "fallback", exit: "1", want: "xterm-256color"},
23+ } {
24+ t.Run(tt.name, func(t *testing.T) {
25+ bin := t.TempDir()
26+ t.Setenv("PATH", bin)
27+ require.NoError(t, os.WriteFile(filepath.Join(bin, "infocmp"), []byte("#!/bin/sh\nexit "+tt.exit+"\n"), 0o700))
28+ env := []string{"TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev"}
29+ p, err := newFactory().Open(t.Context(), "/bin/sh", []string{"-c", `printf '%s|%s|%s' "$TERM" "$COLORTERM" "$TERM_PROGRAM"`}, env, t.TempDir(), domain.Geometry{})
30+ require.NoError(t, err)
31+ t.Cleanup(func() { _ = p.Close() })
32+ require.Equal(t, tt.want+"|truecolor|vev", string(readAll(t, p, 5*time.Second)))
33+ })
34+ }
35+}
@@ -0,0 +1,35 @@
1+//go:build linux
2+
3+package pty_test
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestOpenSelectsPaneTerminal(t *testing.T) {
16+ for _, tt := range []struct {
17+ name string
18+ exit string
19+ want string
20+ }{
21+ {name: "direct", exit: "0", want: "xterm-direct"},
22+ {name: "fallback", exit: "1", want: "xterm-256color"},
23+ } {
24+ t.Run(tt.name, func(t *testing.T) {
25+ bin := t.TempDir()
26+ t.Setenv("PATH", bin)
27+ require.NoError(t, os.WriteFile(filepath.Join(bin, "infocmp"), []byte("#!/bin/sh\nexit "+tt.exit+"\n"), 0o700))
28+ env := []string{"TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev"}
29+ p, err := newFactory().Open(t.Context(), "/bin/sh", []string{"-c", `printf '%s|%s|%s' "$TERM" "$COLORTERM" "$TERM_PROGRAM"`}, env, t.TempDir(), domain.Geometry{})
30+ require.NoError(t, err)
31+ t.Cleanup(func() { _ = p.Close() })
32+ require.Equal(t, tt.want+"|truecolor|vev", string(readAll(t, p, 5*time.Second)))
33+ })
34+ }
35+}
A internal/adapters/pty/terminfo_unix.go +40 -0
@@ -0,0 +1,40 @@
1+//go:build linux || darwin
2+
3+package pty
4+
5+import (
6+ "context"
7+ "os"
8+ "os/exec"
9+ "strings"
10+ "time"
11+)
12+
13+const terminfoProbeTimeout = time.Second
14+
15+// paneEnvironment selects a terminal description on the PTY host. Resolve the
16+// helper using the daemon's PATH, but look up terminfo using the child's search
17+// environment and directory. Never mutate the session's environment snapshot.
18+func paneEnvironment(ctx context.Context, env []string, dir string) []string {
19+ if env == nil {
20+ env = os.Environ()
21+ }
22+ term := "xterm-256color"
23+ probeCtx, cancel := context.WithTimeout(ctx, terminfoProbeTimeout)
24+ defer cancel()
25+ cmd := exec.CommandContext(probeCtx, "infocmp", "-x", "xterm-direct")
26+ cmd.Env = env
27+ cmd.Dir = dir
28+ cmd.WaitDelay = terminfoProbeTimeout
29+ if cmd.Run() == nil {
30+ term = "xterm-direct"
31+ }
32+
33+ out := make([]string, 0, len(env)+1)
34+ for _, entry := range env {
35+ if !strings.HasPrefix(entry, "TERM=") {
36+ out = append(out, entry)
37+ }
38+ }
39+ return append(out, "TERM="+term)
40+}
@@ -0,0 +1,40 @@
1+//go:build linux || darwin
2+
3+package pty
4+
5+import (
6+ "context"
7+ "os"
8+ "os/exec"
9+ "strings"
10+ "time"
11+)
12+
13+const terminfoProbeTimeout = time.Second
14+
15+// paneEnvironment selects a terminal description on the PTY host. Resolve the
16+// helper using the daemon's PATH, but look up terminfo using the child's search
17+// environment and directory. Never mutate the session's environment snapshot.
18+func paneEnvironment(ctx context.Context, env []string, dir string) []string {
19+ if env == nil {
20+ env = os.Environ()
21+ }
22+ term := "xterm-256color"
23+ probeCtx, cancel := context.WithTimeout(ctx, terminfoProbeTimeout)
24+ defer cancel()
25+ cmd := exec.CommandContext(probeCtx, "infocmp", "-x", "xterm-direct")
26+ cmd.Env = env
27+ cmd.Dir = dir
28+ cmd.WaitDelay = terminfoProbeTimeout
29+ if cmd.Run() == nil {
30+ term = "xterm-direct"
31+ }
32+
33+ out := make([]string, 0, len(env)+1)
34+ for _, entry := range env {
35+ if !strings.HasPrefix(entry, "TERM=") {
36+ out = append(out, entry)
37+ }
38+ }
39+ return append(out, "TERM="+term)
40+}
A internal/adapters/pty/terminfo_unix_test.go +80 -0
@@ -0,0 +1,80 @@
1+//go:build linux || darwin
2+
3+package pty
4+
5+import (
6+ "context"
7+ "os"
8+ "path/filepath"
9+ "slices"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestPaneEnvironment(t *testing.T) {
18+ for _, tt := range []struct {
19+ name string
20+ helper string
21+ want string
22+ symlink bool
23+ }{
24+ {name: "available", helper: "exit 0", want: "xterm-direct"},
25+ {name: "symlinked working directory", helper: "exit 0", want: "xterm-direct", symlink: true},
26+ {name: "missing entry", helper: "exit 1", want: "xterm-256color"},
27+ {name: "missing helper", want: "xterm-256color"},
28+ {name: "hung helper", helper: "exec /bin/sleep 60", want: "xterm-256color"},
29+ } {
30+ t.Run(tt.name, func(t *testing.T) {
31+ bin := t.TempDir()
32+ dir := t.TempDir()
33+ if tt.symlink {
34+ link := filepath.Join(t.TempDir(), "pane-cwd")
35+ require.NoError(t, os.Symlink(dir, link))
36+ dir = link
37+ }
38+ t.Setenv("PATH", bin)
39+ if tt.helper != "" {
40+ // The helper must use the pane's lookup environment and working
41+ // directory, but must not be resolved through the pane's PATH.
42+ // Compare directory identity: shells can resolve symlinked temp
43+ // paths (including macOS's /var) when initializing PWD.
44+ script := "#!/bin/sh\n" +
45+ "[ \"$1\" = -x ] && [ \"$2\" = xterm-direct ] && [ \"$#\" = 2 ] || exit 2\n" +
46+ "[ \"$HOME\" = /pane-home ] && [ \"$TERMINFO\" = relative-db ] && [ \"$TERMINFO_DIRS\" = /pane-db: ] || exit 3\n" +
47+ "[ . -ef \"$EXPECTED_DIR\" ] || exit 4\n" + tt.helper + "\n"
48+ require.NoError(t, os.WriteFile(filepath.Join(bin, "infocmp"), []byte(script), 0o700))
49+ }
50+ env := []string{"TERM=outer", "KEEP=a=b", "TERM=duplicate", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "HOME=/pane-home", "TERMINFO=relative-db", "TERMINFO_DIRS=/pane-db:", "PATH=/not-the-daemon-path", "EXPECTED_DIR=" + dir}
51+ before := slices.Clone(env)
52+ got := paneEnvironment(t.Context(), env, dir)
53+ require.Equal(t, before, env)
54+ want := append(slices.Clone(env[1:2]), env[3:]...)
55+ want = append(want, "TERM="+tt.want)
56+ require.Equal(t, want, got)
57+ })
58+ }
59+}
60+
61+func TestPaneEnvironmentNilInherits(t *testing.T) {
62+ t.Setenv("PATH", t.TempDir())
63+ t.Setenv("TERM", "outer")
64+ t.Setenv("PANE_ENV_TEST", "inherited")
65+ env := paneEnvironment(t.Context(), nil, t.TempDir())
66+ require.Contains(t, env, "PANE_ENV_TEST=inherited")
67+ require.Contains(t, env, "TERM=xterm-256color")
68+ require.NotContains(t, env, "TERM=outer")
69+}
70+
71+func TestOpenCancelledDuringTerminfoProbe(t *testing.T) {
72+ bin := t.TempDir()
73+ t.Setenv("PATH", bin)
74+ require.NoError(t, os.WriteFile(filepath.Join(bin, "infocmp"), []byte("#!/bin/sh\nexec /bin/sleep 60\n"), 0o700))
75+ ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
76+ defer cancel()
77+ p, err := NewFactory().Open(ctx, "/bin/sh", []string{"-c", "exit 0"}, nil, t.TempDir(), domain.Geometry{})
78+ require.ErrorIs(t, err, context.DeadlineExceeded)
79+ require.Nil(t, p)
80+}
@@ -0,0 +1,80 @@
1+//go:build linux || darwin
2+
3+package pty
4+
5+import (
6+ "context"
7+ "os"
8+ "path/filepath"
9+ "slices"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestPaneEnvironment(t *testing.T) {
18+ for _, tt := range []struct {
19+ name string
20+ helper string
21+ want string
22+ symlink bool
23+ }{
24+ {name: "available", helper: "exit 0", want: "xterm-direct"},
25+ {name: "symlinked working directory", helper: "exit 0", want: "xterm-direct", symlink: true},
26+ {name: "missing entry", helper: "exit 1", want: "xterm-256color"},
27+ {name: "missing helper", want: "xterm-256color"},
28+ {name: "hung helper", helper: "exec /bin/sleep 60", want: "xterm-256color"},
29+ } {
30+ t.Run(tt.name, func(t *testing.T) {
31+ bin := t.TempDir()
32+ dir := t.TempDir()
33+ if tt.symlink {
34+ link := filepath.Join(t.TempDir(), "pane-cwd")
35+ require.NoError(t, os.Symlink(dir, link))
36+ dir = link
37+ }
38+ t.Setenv("PATH", bin)
39+ if tt.helper != "" {
40+ // The helper must use the pane's lookup environment and working
41+ // directory, but must not be resolved through the pane's PATH.
42+ // Compare directory identity: shells can resolve symlinked temp
43+ // paths (including macOS's /var) when initializing PWD.
44+ script := "#!/bin/sh\n" +
45+ "[ \"$1\" = -x ] && [ \"$2\" = xterm-direct ] && [ \"$#\" = 2 ] || exit 2\n" +
46+ "[ \"$HOME\" = /pane-home ] && [ \"$TERMINFO\" = relative-db ] && [ \"$TERMINFO_DIRS\" = /pane-db: ] || exit 3\n" +
47+ "[ . -ef \"$EXPECTED_DIR\" ] || exit 4\n" + tt.helper + "\n"
48+ require.NoError(t, os.WriteFile(filepath.Join(bin, "infocmp"), []byte(script), 0o700))
49+ }
50+ env := []string{"TERM=outer", "KEEP=a=b", "TERM=duplicate", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "HOME=/pane-home", "TERMINFO=relative-db", "TERMINFO_DIRS=/pane-db:", "PATH=/not-the-daemon-path", "EXPECTED_DIR=" + dir}
51+ before := slices.Clone(env)
52+ got := paneEnvironment(t.Context(), env, dir)
53+ require.Equal(t, before, env)
54+ want := append(slices.Clone(env[1:2]), env[3:]...)
55+ want = append(want, "TERM="+tt.want)
56+ require.Equal(t, want, got)
57+ })
58+ }
59+}
60+
61+func TestPaneEnvironmentNilInherits(t *testing.T) {
62+ t.Setenv("PATH", t.TempDir())
63+ t.Setenv("TERM", "outer")
64+ t.Setenv("PANE_ENV_TEST", "inherited")
65+ env := paneEnvironment(t.Context(), nil, t.TempDir())
66+ require.Contains(t, env, "PANE_ENV_TEST=inherited")
67+ require.Contains(t, env, "TERM=xterm-256color")
68+ require.NotContains(t, env, "TERM=outer")
69+}
70+
71+func TestOpenCancelledDuringTerminfoProbe(t *testing.T) {
72+ bin := t.TempDir()
73+ t.Setenv("PATH", bin)
74+ require.NoError(t, os.WriteFile(filepath.Join(bin, "infocmp"), []byte("#!/bin/sh\nexec /bin/sleep 60\n"), 0o700))
75+ ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
76+ defer cancel()
77+ p, err := NewFactory().Open(ctx, "/bin/sh", []string{"-c", "exit 0"}, nil, t.TempDir(), domain.Geometry{})
78+ require.ErrorIs(t, err, context.DeadlineExceeded)
79+ require.Nil(t, p)
80+}
M internal/app/daemon_integration_test.go +3 -1
@@ -340,12 +340,14 @@ func assertChildEnvironment(t *testing.T, tr wire.Transport, p *pump, sz domain.
340340 command := "printf '\\033[2J\\033[H'; printf 'VEV_TEST_ENV=%s SHELL=%s XDG_RUNTIME_DIR=%s WAYLAND_DISPLAY=%s TERM=%s COLORTERM=%s TERM_PROGRAM=%s VEV_PREFIX=%.24s\\n' \"$VEV_TEST_ENV\" \"${SHELL##*/}\" \"$XDG_RUNTIME_DIR\" \"$WAYLAND_DISPLAY\" \"$TERM\" \"$COLORTERM\" \"$TERM_PROGRAM\" \"$VEV\"\n"
341341 require.NoError(t, tr.Send(wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(protocol.Input{Data: []byte(command)})}))
342342 text := awaitScreenText(t, p, sz, "TERM_PROGRAM=vev")
343+ // The concrete PTY adapter selects the profile from host terminfo. Exact
344+ // direct/fallback behavior is covered by the adapter's hermetic tests.
345+ require.Regexp(t, `TERM=xterm-(direct|256color)\s`, text)
343346 for _, want := range []string{
344347 "VEV_TEST_ENV=" + wantTestEnv,
345348 "SHELL=" + filepath.Base(wantShell),
346349 "XDG_RUNTIME_DIR=" + wantRuntimeDir,
347350 "WAYLAND_DISPLAY=" + wantWayland,
348- "TERM=xterm-256color",
349351 "COLORTERM=truecolor",
350352 "TERM_PROGRAM=vev",
351353 "VEV_PREFIX=session=environment,tab=",
@@ -340,12 +340,14 @@ func assertChildEnvironment(t *testing.T, tr wire.Transport, p *pump, sz domain.
340 command := "printf '\\033[2J\\033[H'; printf 'VEV_TEST_ENV=%s SHELL=%s XDG_RUNTIME_DIR=%s WAYLAND_DISPLAY=%s TERM=%s COLORTERM=%s TERM_PROGRAM=%s VEV_PREFIX=%.24s\\n' \"$VEV_TEST_ENV\" \"${SHELL##*/}\" \"$XDG_RUNTIME_DIR\" \"$WAYLAND_DISPLAY\" \"$TERM\" \"$COLORTERM\" \"$TERM_PROGRAM\" \"$VEV\"\n"340 command := "printf '\\033[2J\\033[H'; printf 'VEV_TEST_ENV=%s SHELL=%s XDG_RUNTIME_DIR=%s WAYLAND_DISPLAY=%s TERM=%s COLORTERM=%s TERM_PROGRAM=%s VEV_PREFIX=%.24s\\n' \"$VEV_TEST_ENV\" \"${SHELL##*/}\" \"$XDG_RUNTIME_DIR\" \"$WAYLAND_DISPLAY\" \"$TERM\" \"$COLORTERM\" \"$TERM_PROGRAM\" \"$VEV\"\n"
341 require.NoError(t, tr.Send(wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(protocol.Input{Data: []byte(command)})}))341 require.NoError(t, tr.Send(wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(protocol.Input{Data: []byte(command)})}))
342 text := awaitScreenText(t, p, sz, "TERM_PROGRAM=vev")342 text := awaitScreenText(t, p, sz, "TERM_PROGRAM=vev")
343+ // The concrete PTY adapter selects the profile from host terminfo. Exact
344+ // direct/fallback behavior is covered by the adapter's hermetic tests.
345+ require.Regexp(t, `TERM=xterm-(direct|256color)\s`, text)
343 for _, want := range []string{346 for _, want := range []string{
344 "VEV_TEST_ENV=" + wantTestEnv,347 "VEV_TEST_ENV=" + wantTestEnv,
345 "SHELL=" + filepath.Base(wantShell),348 "SHELL=" + filepath.Base(wantShell),
346 "XDG_RUNTIME_DIR=" + wantRuntimeDir,349 "XDG_RUNTIME_DIR=" + wantRuntimeDir,
347 "WAYLAND_DISPLAY=" + wantWayland,350 "WAYLAND_DISPLAY=" + wantWayland,
348- "TERM=xterm-256color",
349 "COLORTERM=truecolor",351 "COLORTERM=truecolor",
350 "TERM_PROGRAM=vev",352 "TERM_PROGRAM=vev",
351 "VEV_PREFIX=session=environment,tab=",353 "VEV_PREFIX=session=environment,tab=",
M internal/usecase/daemon/session.go +2 -1
@@ -2039,7 +2039,8 @@ func shellFromEnvironment(env []string) string {
20392039 }
20402040
20412041 // childEnvFrom preserves the supplied environment byte-for-byte and in order,
2042-// except for exact reserved variable names which vev owns.
2042+// except for exact reserved variable names which vev owns. TERM starts with the
2043+// compatibility default; the PTY adapter selects the host-available profile.
20432044 func childEnvFrom(env []string, name, tabStableID, paneStableID string) []string {
20442045 out := make([]string, 0, len(env)+4)
20452046 for _, entry := range env {
@@ -2039,7 +2039,8 @@ func shellFromEnvironment(env []string) string {
2039 }2039 }
2040 2040
2041 // childEnvFrom preserves the supplied environment byte-for-byte and in order,2041 // childEnvFrom preserves the supplied environment byte-for-byte and in order,
2042-// except for exact reserved variable names which vev owns.2042+// except for exact reserved variable names which vev owns. TERM starts with the
2043+// compatibility default; the PTY adapter selects the host-available profile.
2043 func childEnvFrom(env []string, name, tabStableID, paneStableID string) []string {2044 func childEnvFrom(env []string, name, tabStableID, paneStableID string) []string {
2044 out := make([]string, 0, len(env)+4)2045 out := make([]string, 0, len(env)+4)
2045 for _, entry := range env {2046 for _, entry := range env {
A internal/usecase/daemon/terminal_direct_test.go +85 -0
@@ -0,0 +1,85 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "os/exec"
6+ "strconv"
7+ "testing"
8+
9+ vt "github.com/bnema/vev-vt"
10+ renderer "github.com/bnema/vev-vt/ansi"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// ncurses xterm-direct reserves 0..7 for ANSI colors and encodes larger
15+// indices as packed RGB, with an empty colorspace field in colon-form SGR.
16+func TestXtermDirectColorContract(t *testing.T) {
17+ for _, value := range []int{0, 1, 7, 8, 0x123456, 0xffffff} {
18+ t.Run(strconv.Itoa(value), func(t *testing.T) {
19+ fg, bg := xtermDirectSetters(value)
20+ assertXtermDirectColors(t, fg+bg, value)
21+ })
22+ }
23+}
24+
25+func TestInstalledXtermDirectColorContract(t *testing.T) {
26+ if _, err := exec.LookPath("tput"); err != nil {
27+ t.Skip("tput is not installed")
28+ }
29+ colors, err := exec.CommandContext(t.Context(), "tput", "-T", "xterm-direct", "colors").Output()
30+ if err != nil {
31+ t.Skip("xterm-direct is not installed")
32+ }
33+ require.Equal(t, "16777216\n", string(colors))
34+ for _, value := range []int{0, 7, 8, 0x123456, 0xffffff} {
35+ t.Run(strconv.Itoa(value), func(t *testing.T) {
36+ fg, err := exec.CommandContext(t.Context(), "tput", "-T", "xterm-direct", "setaf", strconv.Itoa(value)).Output()
37+ require.NoError(t, err)
38+ bg, err := exec.CommandContext(t.Context(), "tput", "-T", "xterm-direct", "setab", strconv.Itoa(value)).Output()
39+ require.NoError(t, err)
40+ assertXtermDirectColors(t, string(fg)+string(bg), value)
41+ })
42+ }
43+}
44+
45+func xtermDirectSetters(value int) (string, string) {
46+ if value < 8 {
47+ return fmt.Sprintf("\x1b[%dm", 30+value), fmt.Sprintf("\x1b[%dm", 40+value)
48+ }
49+ components := fmt.Sprintf("2::%d:%d:%d", value>>16, value>>8&255, value&255)
50+ return "\x1b[38:" + components + "m", "\x1b[48:" + components + "m"
51+}
52+
53+func assertXtermDirectColors(t *testing.T, setters string, value int) {
54+ t.Helper()
55+ screen := vt.NewScreen(3, 1)
56+ screen.Write([]byte(setters + "X\x1b[39;49mY"))
57+ cell := screen.Cell(0, 0)
58+ if value < 8 {
59+ require.False(t, cell.Style.HasForegroundRGB)
60+ require.False(t, cell.Style.HasBackgroundRGB)
61+ require.EqualValues(t, value, cell.Style.Foreground)
62+ require.EqualValues(t, value, cell.Style.Background)
63+ } else {
64+ want := renderer.RGB{R: uint8(value >> 16), G: uint8(value >> 8), B: uint8(value)}
65+ require.True(t, cell.Style.HasForegroundRGB)
66+ require.True(t, cell.Style.HasBackgroundRGB)
67+ require.Equal(t, want, cell.Style.ForegroundRGB)
68+ require.Equal(t, want, cell.Style.BackgroundRGB)
69+ }
70+ reset := screen.Cell(1, 0).Style
71+ require.False(t, reset.HasForegroundRGB)
72+ require.False(t, reset.HasBackgroundRGB)
73+ require.EqualValues(t, -1, reset.Foreground)
74+ require.EqualValues(t, -1, reset.Background)
75+
76+ // Exercise the actual attachment output path, not just the VT parser.
77+ frame := renderer.NewFrame(3, 1)
78+ frame.Set(0, 0, cell)
79+ frame.Set(1, 0, screen.Cell(1, 0))
80+ prepared, err := newOutputStateStream().prepare(frame, nil, true)
81+ require.NoError(t, err)
82+ display := vt.NewScreen(3, 1)
83+ display.Write(prepared.data)
84+ require.Equal(t, cell, display.Cell(0, 0))
85+}
@@ -0,0 +1,85 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "os/exec"
6+ "strconv"
7+ "testing"
8+
9+ vt "github.com/bnema/vev-vt"
10+ renderer "github.com/bnema/vev-vt/ansi"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+// ncurses xterm-direct reserves 0..7 for ANSI colors and encodes larger
15+// indices as packed RGB, with an empty colorspace field in colon-form SGR.
16+func TestXtermDirectColorContract(t *testing.T) {
17+ for _, value := range []int{0, 1, 7, 8, 0x123456, 0xffffff} {
18+ t.Run(strconv.Itoa(value), func(t *testing.T) {
19+ fg, bg := xtermDirectSetters(value)
20+ assertXtermDirectColors(t, fg+bg, value)
21+ })
22+ }
23+}
24+
25+func TestInstalledXtermDirectColorContract(t *testing.T) {
26+ if _, err := exec.LookPath("tput"); err != nil {
27+ t.Skip("tput is not installed")
28+ }
29+ colors, err := exec.CommandContext(t.Context(), "tput", "-T", "xterm-direct", "colors").Output()
30+ if err != nil {
31+ t.Skip("xterm-direct is not installed")
32+ }
33+ require.Equal(t, "16777216\n", string(colors))
34+ for _, value := range []int{0, 7, 8, 0x123456, 0xffffff} {
35+ t.Run(strconv.Itoa(value), func(t *testing.T) {
36+ fg, err := exec.CommandContext(t.Context(), "tput", "-T", "xterm-direct", "setaf", strconv.Itoa(value)).Output()
37+ require.NoError(t, err)
38+ bg, err := exec.CommandContext(t.Context(), "tput", "-T", "xterm-direct", "setab", strconv.Itoa(value)).Output()
39+ require.NoError(t, err)
40+ assertXtermDirectColors(t, string(fg)+string(bg), value)
41+ })
42+ }
43+}
44+
45+func xtermDirectSetters(value int) (string, string) {
46+ if value < 8 {
47+ return fmt.Sprintf("\x1b[%dm", 30+value), fmt.Sprintf("\x1b[%dm", 40+value)
48+ }
49+ components := fmt.Sprintf("2::%d:%d:%d", value>>16, value>>8&255, value&255)
50+ return "\x1b[38:" + components + "m", "\x1b[48:" + components + "m"
51+}
52+
53+func assertXtermDirectColors(t *testing.T, setters string, value int) {
54+ t.Helper()
55+ screen := vt.NewScreen(3, 1)
56+ screen.Write([]byte(setters + "X\x1b[39;49mY"))
57+ cell := screen.Cell(0, 0)
58+ if value < 8 {
59+ require.False(t, cell.Style.HasForegroundRGB)
60+ require.False(t, cell.Style.HasBackgroundRGB)
61+ require.EqualValues(t, value, cell.Style.Foreground)
62+ require.EqualValues(t, value, cell.Style.Background)
63+ } else {
64+ want := renderer.RGB{R: uint8(value >> 16), G: uint8(value >> 8), B: uint8(value)}
65+ require.True(t, cell.Style.HasForegroundRGB)
66+ require.True(t, cell.Style.HasBackgroundRGB)
67+ require.Equal(t, want, cell.Style.ForegroundRGB)
68+ require.Equal(t, want, cell.Style.BackgroundRGB)
69+ }
70+ reset := screen.Cell(1, 0).Style
71+ require.False(t, reset.HasForegroundRGB)
72+ require.False(t, reset.HasBackgroundRGB)
73+ require.EqualValues(t, -1, reset.Foreground)
74+ require.EqualValues(t, -1, reset.Background)
75+
76+ // Exercise the actual attachment output path, not just the VT parser.
77+ frame := renderer.NewFrame(3, 1)
78+ frame.Set(0, 0, cell)
79+ frame.Set(1, 0, screen.Cell(1, 0))
80+ prepared, err := newOutputStateStream().prepare(frame, nil, true)
81+ require.NoError(t, err)
82+ display := vt.NewScreen(3, 1)
83+ display.Write(prepared.data)
84+ require.Equal(t, cell, display.Cell(0, 0))
85+}

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