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 Unverified
B5690EEEBB952194The key is unknown
8 files · +292 -9
M docs/terminal.md +37 -1
| @@ -2,10 +2,46 @@ | |||
| 2 | 2 | ||
| 3 | 3 | ## 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 | 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 | 45 | ## Kitty graphics | |
| 10 | 46 | ||
| 11 | 47 | Kitty graphics support is attachment-local and explicitly declared only after a | |
| @@ -2,10 +2,46 @@ | |||||
| 2 | 2 | ||||
| 3 | ## Color | 3 | ## 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 graphics | 45 | ## Kitty graphics | ||
| 10 | 46 | ||||
| 11 | Kitty graphics support is attachment-local and explicitly declared only after a | 47 | 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{} | |||
| 34 | 34 | func NewFactory() *Factory { return &Factory{} } | |
| 35 | 35 | ||
| 36 | 36 | // 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. | |
| 42 | 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 | 47 | if err := ctx.Err(); err != nil { | |
| 44 | 48 | return nil, err | |
| 45 | 49 | } | |
| @@ -75,7 +79,7 @@ func (Factory) Open(ctx context.Context, command string, args []string, env []st | |||
| 75 | 79 | ||
| 76 | 80 | cmd := exec.CommandContext(ctx, command, args...) | |
| 77 | 81 | cmd.Env = env | |
| 78 | - | cmd.Dir = platform.DirOrHome(dir) | |
| 82 | + | cmd.Dir = dir | |
| 79 | 83 | cmd.Stdin = slave | |
| 80 | 84 | cmd.Stdout = slave | |
| 81 | 85 | 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-terminal | 36 | // 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 | 37 | + | // and returns the master side as a ports.PTY. env supplies the child environment |
| 38 | - | // verbatim (nil means inherit the current process environment); the caller | 38 | + | // (nil means inherit the current process environment). TERM is selected from |
| 39 | - | // decides TERM and friends. geometry sets the terminal window geometry before | 39 | + | // the host's available terminfo entries; other entries are preserved. geometry |
| 40 | - | // the child starts, so the child observes the correct dimensions on its first | 40 | + | // 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, err | 48 | 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 = env | 81 | cmd.Env = env | ||
| 78 | - | cmd.Dir = platform.DirOrHome(dir) | 82 | + | cmd.Dir = dir |
| 79 | cmd.Stdin = slave | 83 | cmd.Stdin = slave | ||
| 80 | cmd.Stdout = slave | 84 | cmd.Stdout = slave | ||
| 81 | cmd.Stderr = slave | 85 | 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. | |||
| 340 | 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 | 341 | require.NoError(t, tr.Send(wire.Frame{Type: wire.MsgInput, Payload: wire.MarshalInput(protocol.Input{Data: []byte(command)})})) | |
| 342 | 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 | 346 | for _, want := range []string{ | |
| 344 | 347 | "VEV_TEST_ENV=" + wantTestEnv, | |
| 345 | 348 | "SHELL=" + filepath.Base(wantShell), | |
| 346 | 349 | "XDG_RUNTIME_DIR=" + wantRuntimeDir, | |
| 347 | 350 | "WAYLAND_DISPLAY=" + wantWayland, | |
| 348 | - | "TERM=xterm-256color", | |
| 349 | 351 | "COLORTERM=truecolor", | |
| 350 | 352 | "TERM_PROGRAM=vev", | |
| 351 | 353 | "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 { | |||
| 2039 | 2039 | } | |
| 2040 | 2040 | ||
| 2041 | 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 | 2044 | func childEnvFrom(env []string, name, tabStableID, paneStableID string) []string { | |
| 2044 | 2045 | out := make([]string, 0, len(env)+4) | |
| 2045 | 2046 | 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 | + | } | |||