detached 0e2903b

fix(vt): address branch review defects

commit
0e2903b537ad3c8ec1bc1c47224f0db805710740
author
brice · · 2026-07-02 18:06 UTC
parent
e7f30ce
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +726 -206
A README.md +74 -0
@@ -0,0 +1,74 @@
1+# vev
2+
3+A terminal multiplexer for Linux, written in Go with no runtime dependencies beyond `golang.org/x/sys` and `golang.org/x/term`. Sessions run in a per-user daemon; the client is a thin pipe. Rendering happens server-side with damage tracking, so only minimal diffs cross the socket (or the SSH link).
4+
5+## Install
6+
7+```sh
8+go install github.com/bnema/vev@latest
9+```
10+
11+Or from a checkout: `go build -o vev .`
12+
13+## Usage
14+
15+```
16+vev attach to (or create) an ephemeral session
17+vev new <name> create and attach to a named session
18+vev attach <name> attach to an existing named session (alias: a)
19+vev attach user@host[:session] attach to a remote vev daemon over SSH
20+vev ls list sessions
21+vev kill <name> kill a named session
22+vev --help show help
23+vev --version show version
24+```
25+
26+Ephemeral sessions get a number (0, 1, 2...) and die when you detach. Named sessions survive detach and live until killed or their last window closes. The daemon starts on first use and exits when no sessions remain.
27+
28+Remote attach runs `ssh user@host vev _stdio ...` under the hood, so vev must be installed on the remote host and reachable via your normal SSH config. Each remote attach opens one ssh connection. To make reconnects near-instant, enable OpenSSH connection reuse for your hosts in `~/.ssh/config`:
29+
30+```
31+Host *
32+ ControlMaster auto
33+ ControlPath ~/.ssh/cm-%r@%h:%p
34+ ControlPersist 10m
35+```
36+
37+Omitting `:session` opens an ephemeral session on the remote.
38+
39+## Keys
40+
41+All bindings use the Alt modifier directly, no prefix key.
42+
43+| Key | Action |
44+|---|---|
45+| Alt+c | new window |
46+| Alt+n / Alt+p | next / previous window |
47+| Alt+1 .. Alt+9 | select window by number |
48+| Alt+x | close window (last window ends the session) |
49+| Alt+d | detach |
50+| Alt+r | promote the current ephemeral session to named |
51+| Alt+u | enter copy mode |
52+
53+## Copy mode
54+
55+Copy mode freezes a view over the scrollback while the program keeps running underneath. The status bar shows `[COPY]` and the cursor position.
56+
57+| Key | Action |
58+|---|---|
59+| j / k, Up / Down | move one line |
60+| PgUp / PgDn | move one page |
61+| g / G | jump to top / bottom |
62+| Space | toggle line selection |
63+| y or Enter | copy selection and exit |
64+| q, Esc, Ctrl-C | exit without copying |
65+
66+Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH.
67+
68+## Development
69+
70+```sh
71+go test ./... -race
72+```
73+
74+`pkg/` (VT emulator, renderer) never imports `internal/`; a boundary test enforces this. Format Go code with `goimports`. Benchmark notes live in `docs/performance.md`.
@@ -0,0 +1,74 @@
1+# vev
2+
3+A terminal multiplexer for Linux, written in Go with no runtime dependencies beyond `golang.org/x/sys` and `golang.org/x/term`. Sessions run in a per-user daemon; the client is a thin pipe. Rendering happens server-side with damage tracking, so only minimal diffs cross the socket (or the SSH link).
4+
5+## Install
6+
7+```sh
8+go install github.com/bnema/vev@latest
9+```
10+
11+Or from a checkout: `go build -o vev .`
12+
13+## Usage
14+
15+```
16+vev attach to (or create) an ephemeral session
17+vev new <name> create and attach to a named session
18+vev attach <name> attach to an existing named session (alias: a)
19+vev attach user@host[:session] attach to a remote vev daemon over SSH
20+vev ls list sessions
21+vev kill <name> kill a named session
22+vev --help show help
23+vev --version show version
24+```
25+
26+Ephemeral sessions get a number (0, 1, 2...) and die when you detach. Named sessions survive detach and live until killed or their last window closes. The daemon starts on first use and exits when no sessions remain.
27+
28+Remote attach runs `ssh user@host vev _stdio ...` under the hood, so vev must be installed on the remote host and reachable via your normal SSH config. Each remote attach opens one ssh connection. To make reconnects near-instant, enable OpenSSH connection reuse for your hosts in `~/.ssh/config`:
29+
30+```
31+Host *
32+ ControlMaster auto
33+ ControlPath ~/.ssh/cm-%r@%h:%p
34+ ControlPersist 10m
35+```
36+
37+Omitting `:session` opens an ephemeral session on the remote.
38+
39+## Keys
40+
41+All bindings use the Alt modifier directly, no prefix key.
42+
43+| Key | Action |
44+|---|---|
45+| Alt+c | new window |
46+| Alt+n / Alt+p | next / previous window |
47+| Alt+1 .. Alt+9 | select window by number |
48+| Alt+x | close window (last window ends the session) |
49+| Alt+d | detach |
50+| Alt+r | promote the current ephemeral session to named |
51+| Alt+u | enter copy mode |
52+
53+## Copy mode
54+
55+Copy mode freezes a view over the scrollback while the program keeps running underneath. The status bar shows `[COPY]` and the cursor position.
56+
57+| Key | Action |
58+|---|---|
59+| j / k, Up / Down | move one line |
60+| PgUp / PgDn | move one page |
61+| g / G | jump to top / bottom |
62+| Space | toggle line selection |
63+| y or Enter | copy selection and exit |
64+| q, Esc, Ctrl-C | exit without copying |
65+
66+Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH.
67+
68+## Development
69+
70+```sh
71+go test ./... -race
72+```
73+
74+`pkg/` (VT emulator, renderer) never imports `internal/`; a boundary test enforces this. Format Go code with `goimports`. Benchmark notes live in `docs/performance.md`.
M internal/adapters/sshstdio/transport.go +34 -10
@@ -5,6 +5,7 @@
55 package sshstdio
66
77 import (
8+ "bytes"
89 "encoding/binary"
910 "errors"
1011 "fmt"
@@ -12,13 +13,15 @@ import (
1213 "os/exec"
1314 "strings"
1415 "sync"
16+ "time"
1517
1618 "github.com/bnema/vev/internal/ports"
1719 )
1820
1921 const (
20- maxFrameLen = 16 << 20
21- frameHeaderLen = 4
22+ maxFrameLen = 16 << 20
23+ frameHeaderLen = 4
24+ sshCloseTimeout = 3 * time.Second
2225 )
2326
2427 var (
@@ -91,6 +94,32 @@ func (t *transport) Recv() (ports.Frame, error) {
9194
9295 func (t *transport) Close() error { return t.close() }
9396
97+func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration) closeFunc {
98+ return func() error {
99+ _ = stdin.Close()
100+
101+ waitErr := make(chan error, 1)
102+ go func() { waitErr <- cmd.Wait() }()
103+
104+ var err error
105+ select {
106+ case err = <-waitErr:
107+ case <-time.After(timeout):
108+ _ = cmd.Process.Kill()
109+ err = <-waitErr
110+ }
111+ if err == nil {
112+ return nil
113+ }
114+
115+ stderrText := strings.TrimSpace(stderr.String())
116+ if stderrText != "" {
117+ return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)
118+ }
119+ return fmt.Errorf("sshstdio: ssh exited: %w", err)
120+ }
121+}
122+
94123 // CommandSpec is the exact ssh argv vev will execute locally.
95124 type CommandSpec struct {
96125 Path string
@@ -127,16 +156,11 @@ func Dial(target, session string) (ports.Transport, error) {
127156 if err != nil {
128157 return nil, fmt.Errorf("sshstdio: stdout pipe: %w", err)
129158 }
159+ var stderr bytes.Buffer
160+ cmd.Stderr = &stderr
130161 if err := cmd.Start(); err != nil {
131162 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)
132163 }
133164
134- return NewTransport(stdout, stdin, func() error {
135- _ = stdin.Close()
136- err := cmd.Wait()
137- if err != nil {
138- return fmt.Errorf("sshstdio: ssh exited: %w", err)
139- }
140- return nil
141- }), nil
165+ return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout)), nil
142166 }
@@ -5,6 +5,7 @@
5 package sshstdio5 package sshstdio
6 6
7 import (7 import (
8+ "bytes"
8 "encoding/binary"9 "encoding/binary"
9 "errors"10 "errors"
10 "fmt"11 "fmt"
@@ -12,13 +13,15 @@ import (
12 "os/exec"13 "os/exec"
13 "strings"14 "strings"
14 "sync"15 "sync"
16+ "time"
15 17
16 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
17 )19 )
18 20
19 const (21 const (
20- maxFrameLen = 16 << 2022+ maxFrameLen = 16 << 20
21- frameHeaderLen = 423+ frameHeaderLen = 4
24+ sshCloseTimeout = 3 * time.Second
22 )25 )
23 26
24 var (27 var (
@@ -91,6 +94,32 @@ func (t *transport) Recv() (ports.Frame, error) {
91 94
92 func (t *transport) Close() error { return t.close() }95 func (t *transport) Close() error { return t.close() }
93 96
97+func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration) closeFunc {
98+ return func() error {
99+ _ = stdin.Close()
100+
101+ waitErr := make(chan error, 1)
102+ go func() { waitErr <- cmd.Wait() }()
103+
104+ var err error
105+ select {
106+ case err = <-waitErr:
107+ case <-time.After(timeout):
108+ _ = cmd.Process.Kill()
109+ err = <-waitErr
110+ }
111+ if err == nil {
112+ return nil
113+ }
114+
115+ stderrText := strings.TrimSpace(stderr.String())
116+ if stderrText != "" {
117+ return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)
118+ }
119+ return fmt.Errorf("sshstdio: ssh exited: %w", err)
120+ }
121+}
122+
94 // CommandSpec is the exact ssh argv vev will execute locally.123 // CommandSpec is the exact ssh argv vev will execute locally.
95 type CommandSpec struct {124 type CommandSpec struct {
96 Path string125 Path string
@@ -127,16 +156,11 @@ func Dial(target, session string) (ports.Transport, error) {
127 if err != nil {156 if err != nil {
128 return nil, fmt.Errorf("sshstdio: stdout pipe: %w", err)157 return nil, fmt.Errorf("sshstdio: stdout pipe: %w", err)
129 }158 }
159+ var stderr bytes.Buffer
160+ cmd.Stderr = &stderr
130 if err := cmd.Start(); err != nil {161 if err := cmd.Start(); err != nil {
131 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)162 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)
132 }163 }
133 164
134- return NewTransport(stdout, stdin, func() error {165+ return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout)), nil
135- _ = stdin.Close()
136- err := cmd.Wait()
137- if err != nil {
138- return fmt.Errorf("sshstdio: ssh exited: %w", err)
139- }
140- return nil
141- }), nil
142 }166 }
M internal/adapters/sshstdio/transport_test.go +60 -0
@@ -1,10 +1,13 @@
11 package sshstdio
22
33 import (
4+ "bytes"
45 "errors"
56 "io"
7+ "os/exec"
68 "strings"
79 "testing"
10+ "time"
811
912 "github.com/bnema/vev/internal/ports"
1013 )
@@ -98,3 +101,60 @@ func TestTransportRejectsZeroLengthFrame(t *testing.T) {
98101 t.Fatalf("Recv error = %v, want ErrZeroLengthFrame", err)
99102 }
100103 }
104+
105+func TestProcessCloser(t *testing.T) {
106+ tests := []struct {
107+ name string
108+ cmd *exec.Cmd
109+ timeout time.Duration
110+ wantErrs []string
111+ wantBound bool
112+ }{
113+ {
114+ name: "wedged process is killed after timeout and reaped",
115+ cmd: exec.Command("sleep", "30"),
116+ timeout: 50 * time.Millisecond,
117+ wantErrs: []string{"sshstdio: ssh exited:"},
118+ wantBound: true,
119+ },
120+ {
121+ name: "stderr from failing shell command is included in error",
122+ cmd: exec.Command("sh", "-c", "echo kaboom >&2; exit 7"),
123+ timeout: time.Second,
124+ wantErrs: []string{"sshstdio: ssh exited:", "kaboom"},
125+ },
126+ }
127+
128+ for _, tt := range tests {
129+ t.Run(tt.name, func(t *testing.T) {
130+ stdin, err := tt.cmd.StdinPipe()
131+ if err != nil {
132+ t.Fatalf("StdinPipe: %v", err)
133+ }
134+ var stderr bytes.Buffer
135+ tt.cmd.Stderr = &stderr
136+ if err := tt.cmd.Start(); err != nil {
137+ t.Fatalf("Start: %v", err)
138+ }
139+
140+ started := time.Now()
141+ err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout)()
142+ elapsed := time.Since(started)
143+
144+ if err == nil {
145+ t.Fatalf("Close error = nil, want error containing %q", tt.wantErrs)
146+ }
147+ for _, want := range tt.wantErrs {
148+ if !strings.Contains(err.Error(), want) {
149+ t.Fatalf("Close error = %q, want substring %q", err.Error(), want)
150+ }
151+ }
152+ if tt.wantBound && elapsed > time.Second {
153+ t.Fatalf("Close took %s, want bounded below 1s", elapsed)
154+ }
155+ if tt.cmd.ProcessState == nil {
156+ t.Fatalf("ProcessState is nil, want child reaped")
157+ }
158+ })
159+ }
160+}
@@ -1,10 +1,13 @@
1 package sshstdio1 package sshstdio
2 2
3 import (3 import (
4+ "bytes"
4 "errors"5 "errors"
5 "io"6 "io"
7+ "os/exec"
6 "strings"8 "strings"
7 "testing"9 "testing"
10+ "time"
8 11
9 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
10 )13 )
@@ -98,3 +101,60 @@ func TestTransportRejectsZeroLengthFrame(t *testing.T) {
98 t.Fatalf("Recv error = %v, want ErrZeroLengthFrame", err)101 t.Fatalf("Recv error = %v, want ErrZeroLengthFrame", err)
99 }102 }
100 }103 }
104+
105+func TestProcessCloser(t *testing.T) {
106+ tests := []struct {
107+ name string
108+ cmd *exec.Cmd
109+ timeout time.Duration
110+ wantErrs []string
111+ wantBound bool
112+ }{
113+ {
114+ name: "wedged process is killed after timeout and reaped",
115+ cmd: exec.Command("sleep", "30"),
116+ timeout: 50 * time.Millisecond,
117+ wantErrs: []string{"sshstdio: ssh exited:"},
118+ wantBound: true,
119+ },
120+ {
121+ name: "stderr from failing shell command is included in error",
122+ cmd: exec.Command("sh", "-c", "echo kaboom >&2; exit 7"),
123+ timeout: time.Second,
124+ wantErrs: []string{"sshstdio: ssh exited:", "kaboom"},
125+ },
126+ }
127+
128+ for _, tt := range tests {
129+ t.Run(tt.name, func(t *testing.T) {
130+ stdin, err := tt.cmd.StdinPipe()
131+ if err != nil {
132+ t.Fatalf("StdinPipe: %v", err)
133+ }
134+ var stderr bytes.Buffer
135+ tt.cmd.Stderr = &stderr
136+ if err := tt.cmd.Start(); err != nil {
137+ t.Fatalf("Start: %v", err)
138+ }
139+
140+ started := time.Now()
141+ err = newProcessCloser(tt.cmd, stdin, &stderr, tt.timeout)()
142+ elapsed := time.Since(started)
143+
144+ if err == nil {
145+ t.Fatalf("Close error = nil, want error containing %q", tt.wantErrs)
146+ }
147+ for _, want := range tt.wantErrs {
148+ if !strings.Contains(err.Error(), want) {
149+ t.Fatalf("Close error = %q, want substring %q", err.Error(), want)
150+ }
151+ }
152+ if tt.wantBound && elapsed > time.Second {
153+ t.Fatalf("Close took %s, want bounded below 1s", elapsed)
154+ }
155+ if tt.cmd.ProcessState == nil {
156+ t.Fatalf("ProcessState is nil, want child reaped")
157+ }
158+ })
159+ }
160+}
M internal/usecase/daemon/daemon.go +72 -16
@@ -49,9 +49,10 @@ import (
4949 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
5050 // sustained floods step toward the maximum to reduce frame/syscall pressure.
5151 const (
52- minDebounceInterval = 2 * time.Millisecond
53- maxDebounceInterval = 16 * time.Millisecond
54- debounceStep = 2 * time.Millisecond
52+ minDebounceInterval = 2 * time.Millisecond
53+ maxDebounceInterval = 16 * time.Millisecond
54+ debounceStep = 2 * time.Millisecond
55+ maxSyncUpdateDuration = 500 * time.Millisecond
5556 )
5657
5758 // debounceInterval is kept as a test/back-compat alias for the initial delay.
@@ -135,11 +136,12 @@ type session struct {
135136 // already pending"), never by blocking the reader.
136137 type window struct {
137138 pty ports.PTY
138- mu sync.Mutex // guards screen and every attached renderer's shadow
139+ mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
139140 screen *vt.Screen
140141 scrollback *scopy.Scrollback
141142 dirty chan struct{}
142143 flush chan struct{}
144+ syncGen uint64
143145 size domain.Size
144146 ctx context.Context
145147 cancel context.CancelFunc
@@ -151,13 +153,14 @@ type window struct {
151153 // scheduler and the connection handler — so the transport's single-writer
152154 // contract holds and Draw→Send stays atomic (correct output ordering).
153155 type attachedClient struct {
154- tr ports.Transport
155- rend *renderer.Renderer
156- size domain.Size
157- keys *keys.Router
158- copyMu sync.Mutex
159- copyMode *scopy.Mode
160- sendMu sync.Mutex
156+ tr ports.Transport
157+ rend *renderer.Renderer
158+ size domain.Size
159+ keys *keys.Router
160+ copyMu sync.Mutex
161+ copyMode *scopy.Mode
162+ copyPending []byte
163+ sendMu sync.Mutex
161164 }
162165
163166 func (ac *attachedClient) copyModeActive() bool {
@@ -700,9 +703,6 @@ func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
700703 d.handleCopyInput(sess, ac, data)
701704 return
702705 }
703- if ac.keys == nil {
704- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})
705- }
706706 ac.keys.Route(data)
707707 }
708708
@@ -729,10 +729,18 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte)
729729 win.mu.Lock()
730730 ac.copyMu.Lock()
731731 if ac.copyMode == nil {
732+ ac.copyPending = nil
732733 ac.copyMu.Unlock()
733734 win.mu.Unlock()
734735 return
735736 }
737+ if len(ac.copyPending) > 0 {
738+ combined := make([]byte, 0, len(ac.copyPending)+len(data))
739+ combined = append(combined, ac.copyPending...)
740+ combined = append(combined, data...)
741+ data = combined
742+ ac.copyPending = nil
743+ }
736744 snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
737745 changed := false
738746 copyOut := false
@@ -759,12 +767,17 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte)
759767 exit = true
760768 case 'q', 0x03, 0x1b:
761769 if data[i] == 0x1b {
762- consumed, ok := routeCopyEscape(ac.copyMode, snap, data[i:])
770+ tail := data[i:]
771+ consumed, ok := routeCopyEscape(ac.copyMode, snap, tail)
763772 if ok {
764773 i += consumed - 1
765774 changed = true
766775 continue
767776 }
777+ if isCopyEscapePrefix(tail) {
778+ ac.copyPending = append(ac.copyPending[:0], tail...)
779+ break
780+ }
768781 }
769782 exit = true
770783 }
@@ -779,7 +792,7 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte)
779792 ac.copyMu.Unlock()
780793 win.mu.Unlock()
781794
782- if copyOut {
795+ if copyOut && text != "" {
783796 for _, chunk := range scopy.OSC52(text) {
784797 if err := ac.send(frameOutput(chunk)); err != nil {
785798 d.detachOnSendError(sess, ac)
@@ -820,6 +833,11 @@ func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool
820833 return 0, false
821834 }
822835
836+func isCopyEscapePrefix(data []byte) bool {
837+ return len(data) == 2 && data[0] == 0x1b && data[1] == '[' ||
838+ len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
839+}
840+
823841 type daemonKeyHandler struct {
824842 d *Daemon
825843 sess *session
@@ -951,6 +969,10 @@ func signal(ch chan struct{}) {
951969 func (d *Daemon) ptyReader(sess *session, win *window) {
952970 defer d.sessWg.Done()
953971 buf := make([]byte, ptyReadBufSize)
972+ var resp []byte
973+ win.mu.Lock()
974+ win.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
975+ win.mu.Unlock()
954976 for {
955977 n, err := win.pty.Read(buf)
956978 if n > 0 {
@@ -960,6 +982,21 @@ func (d *Daemon) ptyReader(sess *session, win *window) {
960982 win.screen.Write(data)
961983 isSyncing := win.screen.SyncUpdateActive()
962984 win.mu.Unlock()
985+ if len(resp) > 0 {
986+ if _, writeErr := win.pty.Write(resp); writeErr != nil {
987+ d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
988+ }
989+ resp = resp[:0]
990+ }
991+ if wasSyncing != isSyncing {
992+ win.mu.Lock()
993+ win.syncGen++
994+ gen := win.syncGen
995+ win.mu.Unlock()
996+ if isSyncing {
997+ go d.syncWatchdog(win, gen)
998+ }
999+ }
9631000 if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
9641001 signal(win.flush)
9651002 continue
@@ -1047,6 +1084,25 @@ func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
10471084 return delay
10481085 }
10491086
1087+func (d *Daemon) syncWatchdog(win *window, gen uint64) {
1088+ timer := d.clock.NewTimer(maxSyncUpdateDuration)
1089+ select {
1090+ case <-windowDone(win):
1091+ timer.Stop()
1092+ return
1093+ case <-timer.C():
1094+ }
1095+
1096+ win.mu.Lock()
1097+ if win.syncGen != gen || !win.screen.SyncUpdateActive() {
1098+ win.mu.Unlock()
1099+ return
1100+ }
1101+ win.screen.ForceSyncEnd()
1102+ win.mu.Unlock()
1103+ signal(win.flush)
1104+}
1105+
10501106 func windowDone(win *window) <-chan struct{} {
10511107 if win.ctx == nil {
10521108 return nil
@@ -49,9 +49,10 @@ import (
49 // Scheduler debounce bounds. Idle updates use the minimum for low latency;49 // Scheduler debounce bounds. Idle updates use the minimum for low latency;
50 // sustained floods step toward the maximum to reduce frame/syscall pressure.50 // sustained floods step toward the maximum to reduce frame/syscall pressure.
51 const (51 const (
52- minDebounceInterval = 2 * time.Millisecond52+ minDebounceInterval = 2 * time.Millisecond
53- maxDebounceInterval = 16 * time.Millisecond53+ maxDebounceInterval = 16 * time.Millisecond
54- debounceStep = 2 * time.Millisecond54+ debounceStep = 2 * time.Millisecond
55+ maxSyncUpdateDuration = 500 * time.Millisecond
55 )56 )
56 57
57 // debounceInterval is kept as a test/back-compat alias for the initial delay.58 // debounceInterval is kept as a test/back-compat alias for the initial delay.
@@ -135,11 +136,12 @@ type session struct {
135 // already pending"), never by blocking the reader.136 // already pending"), never by blocking the reader.
136 type window struct {137 type window struct {
137 pty ports.PTY138 pty ports.PTY
138- mu sync.Mutex // guards screen and every attached renderer's shadow139+ mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
139 screen *vt.Screen140 screen *vt.Screen
140 scrollback *scopy.Scrollback141 scrollback *scopy.Scrollback
141 dirty chan struct{}142 dirty chan struct{}
142 flush chan struct{}143 flush chan struct{}
144+ syncGen uint64
143 size domain.Size145 size domain.Size
144 ctx context.Context146 ctx context.Context
145 cancel context.CancelFunc147 cancel context.CancelFunc
@@ -151,13 +153,14 @@ type window struct {
151 // scheduler and the connection handler — so the transport's single-writer153 // scheduler and the connection handler — so the transport's single-writer
152 // contract holds and Draw→Send stays atomic (correct output ordering).154 // contract holds and Draw→Send stays atomic (correct output ordering).
153 type attachedClient struct {155 type attachedClient struct {
154- tr ports.Transport156+ tr ports.Transport
155- rend *renderer.Renderer157+ rend *renderer.Renderer
156- size domain.Size158+ size domain.Size
157- keys *keys.Router159+ keys *keys.Router
158- copyMu sync.Mutex160+ copyMu sync.Mutex
159- copyMode *scopy.Mode161+ copyMode *scopy.Mode
160- sendMu sync.Mutex162+ copyPending []byte
163+ sendMu sync.Mutex
161 }164 }
162 165
163 func (ac *attachedClient) copyModeActive() bool {166 func (ac *attachedClient) copyModeActive() bool {
@@ -700,9 +703,6 @@ func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
700 d.handleCopyInput(sess, ac, data)703 d.handleCopyInput(sess, ac, data)
701 return704 return
702 }705 }
703- if ac.keys == nil {
704- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})
705- }
706 ac.keys.Route(data)706 ac.keys.Route(data)
707 }707 }
708 708
@@ -729,10 +729,18 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte)
729 win.mu.Lock()729 win.mu.Lock()
730 ac.copyMu.Lock()730 ac.copyMu.Lock()
731 if ac.copyMode == nil {731 if ac.copyMode == nil {
732+ ac.copyPending = nil
732 ac.copyMu.Unlock()733 ac.copyMu.Unlock()
733 win.mu.Unlock()734 win.mu.Unlock()
734 return735 return
735 }736 }
737+ if len(ac.copyPending) > 0 {
738+ combined := make([]byte, 0, len(ac.copyPending)+len(data))
739+ combined = append(combined, ac.copyPending...)
740+ combined = append(combined, data...)
741+ data = combined
742+ ac.copyPending = nil
743+ }
736 snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)744 snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
737 changed := false745 changed := false
738 copyOut := false746 copyOut := false
@@ -759,12 +767,17 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte)
759 exit = true767 exit = true
760 case 'q', 0x03, 0x1b:768 case 'q', 0x03, 0x1b:
761 if data[i] == 0x1b {769 if data[i] == 0x1b {
762- consumed, ok := routeCopyEscape(ac.copyMode, snap, data[i:])770+ tail := data[i:]
771+ consumed, ok := routeCopyEscape(ac.copyMode, snap, tail)
763 if ok {772 if ok {
764 i += consumed - 1773 i += consumed - 1
765 changed = true774 changed = true
766 continue775 continue
767 }776 }
777+ if isCopyEscapePrefix(tail) {
778+ ac.copyPending = append(ac.copyPending[:0], tail...)
779+ break
780+ }
768 }781 }
769 exit = true782 exit = true
770 }783 }
@@ -779,7 +792,7 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte)
779 ac.copyMu.Unlock()792 ac.copyMu.Unlock()
780 win.mu.Unlock()793 win.mu.Unlock()
781 794
782- if copyOut {795+ if copyOut && text != "" {
783 for _, chunk := range scopy.OSC52(text) {796 for _, chunk := range scopy.OSC52(text) {
784 if err := ac.send(frameOutput(chunk)); err != nil {797 if err := ac.send(frameOutput(chunk)); err != nil {
785 d.detachOnSendError(sess, ac)798 d.detachOnSendError(sess, ac)
@@ -820,6 +833,11 @@ func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool
820 return 0, false833 return 0, false
821 }834 }
822 835
836+func isCopyEscapePrefix(data []byte) bool {
837+ return len(data) == 2 && data[0] == 0x1b && data[1] == '[' ||
838+ len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
839+}
840+
823 type daemonKeyHandler struct {841 type daemonKeyHandler struct {
824 d *Daemon842 d *Daemon
825 sess *session843 sess *session
@@ -951,6 +969,10 @@ func signal(ch chan struct{}) {
951 func (d *Daemon) ptyReader(sess *session, win *window) {969 func (d *Daemon) ptyReader(sess *session, win *window) {
952 defer d.sessWg.Done()970 defer d.sessWg.Done()
953 buf := make([]byte, ptyReadBufSize)971 buf := make([]byte, ptyReadBufSize)
972+ var resp []byte
973+ win.mu.Lock()
974+ win.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
975+ win.mu.Unlock()
954 for {976 for {
955 n, err := win.pty.Read(buf)977 n, err := win.pty.Read(buf)
956 if n > 0 {978 if n > 0 {
@@ -960,6 +982,21 @@ func (d *Daemon) ptyReader(sess *session, win *window) {
960 win.screen.Write(data)982 win.screen.Write(data)
961 isSyncing := win.screen.SyncUpdateActive()983 isSyncing := win.screen.SyncUpdateActive()
962 win.mu.Unlock()984 win.mu.Unlock()
985+ if len(resp) > 0 {
986+ if _, writeErr := win.pty.Write(resp); writeErr != nil {
987+ d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
988+ }
989+ resp = resp[:0]
990+ }
991+ if wasSyncing != isSyncing {
992+ win.mu.Lock()
993+ win.syncGen++
994+ gen := win.syncGen
995+ win.mu.Unlock()
996+ if isSyncing {
997+ go d.syncWatchdog(win, gen)
998+ }
999+ }
963 if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {1000 if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
964 signal(win.flush)1001 signal(win.flush)
965 continue1002 continue
@@ -1047,6 +1084,25 @@ func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
1047 return delay1084 return delay
1048 }1085 }
1049 1086
1087+func (d *Daemon) syncWatchdog(win *window, gen uint64) {
1088+ timer := d.clock.NewTimer(maxSyncUpdateDuration)
1089+ select {
1090+ case <-windowDone(win):
1091+ timer.Stop()
1092+ return
1093+ case <-timer.C():
1094+ }
1095+
1096+ win.mu.Lock()
1097+ if win.syncGen != gen || !win.screen.SyncUpdateActive() {
1098+ win.mu.Unlock()
1099+ return
1100+ }
1101+ win.screen.ForceSyncEnd()
1102+ win.mu.Unlock()
1103+ signal(win.flush)
1104+}
1105+
1050 func windowDone(win *window) <-chan struct{} {1106 func windowDone(win *window) <-chan struct{} {
1051 if win.ctx == nil {1107 if win.ctx == nil {
1052 return nil1108 return nil
M internal/usecase/daemon/daemon_test.go +297 -2
@@ -3,7 +3,9 @@ package daemon
33 import (
44 "bytes"
55 "context"
6+ "encoding/base64"
67 "errors"
8+ "fmt"
79 "io"
810 "log/slog"
911 "strings"
@@ -19,6 +21,7 @@ import (
1921 "github.com/bnema/vev/internal/ports"
2022 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2123 scopy "github.com/bnema/vev/internal/usecase/copy"
24+ "github.com/bnema/vev/internal/usecase/keys"
2225 "github.com/bnema/vev/pkg/renderer"
2326 "github.com/bnema/vev/pkg/vt"
2427 )
@@ -41,15 +44,31 @@ func (stubTimer) Stop() bool { return true }
4144
4245 type signalClock struct {
4346 called chan struct{}
47+ timers chan *signalTimer
4448 once sync.Once
4549 }
4650
4751 func (c *signalClock) Now() time.Time { return time.Time{} }
4852 func (c *signalClock) NewTimer(time.Duration) ports.Timer {
49- c.once.Do(func() { close(c.called) })
50- return stubTimer{}
53+ if c.called != nil {
54+ c.once.Do(func() { close(c.called) })
55+ }
56+ if c.timers == nil {
57+ return stubTimer{}
58+ }
59+ t := &signalTimer{ch: make(chan time.Time, 1)}
60+ c.timers <- t
61+ return t
5162 }
5263
64+type signalTimer struct {
65+ ch chan time.Time
66+}
67+
68+func (t *signalTimer) C() <-chan time.Time { return t.ch }
69+func (t *signalTimer) Reset(time.Duration) bool { return false }
70+func (t *signalTimer) Stop() bool { return true }
71+
5372 func newTestDaemon(t *testing.T, ptys ports.PTYFactory, clk ports.Clock) *Daemon {
5473 t.Helper()
5574 d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil)))
@@ -161,6 +180,14 @@ func frameInput(data []byte) ports.Frame {
161180 return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})}
162181 }
163182
183+func screenLineText(s *vt.Screen, y int) string {
184+ out := make([]rune, s.Frame.Width)
185+ for x := range s.Frame.Width {
186+ out[x] = s.Frame.At(x, y).Rune
187+ }
188+ return string(out)
189+}
190+
164191 // awaitFrame waits for the next frame of type typ on ch, failing on timeout.
165192 func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Frame {
166193 t.Helper()
@@ -226,6 +253,7 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
226253 windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel})
227254 }
228255 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac}
256+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})
229257 d.sessions[sess.id] = sess
230258 t.Cleanup(cancel)
231259 return d, sess, ac, sends
@@ -339,6 +367,76 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
339367 }
340368 }
341369
370+func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
371+ p := portsmocks.NewMockPTY(t)
372+ reads := make(chan []byte, 64)
373+ readDone := make(chan struct{})
374+ var closeOnce sync.Once
375+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
376+ select {
377+ case data := <-reads:
378+ return copy(b, data), nil
379+ case <-readDone:
380+ return 0, io.EOF
381+ }
382+ }).Maybe()
383+ p.EXPECT().Write(mock.Anything).Return(0, errors.New("unexpected PTY write")).Maybe()
384+ p.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
385+ p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()
386+ p.EXPECT().Pid().Return(4242).Maybe()
387+
388+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
389+ tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))
390+ var hg sync.WaitGroup
391+ hg.Go(func() { d.handleConn(tr) })
392+ awaitFrame(t, sends, ports.MsgWelcome)
393+ awaitFrame(t, sends, ports.MsgOutput)
394+
395+ for i := range 12 {
396+ reads <- fmt.Appendf(nil, "line-%02d\r\n", i)
397+ }
398+ require.Eventually(t, func() bool {
399+ sess := firstSession(d)
400+ if sess == nil {
401+ return false
402+ }
403+ win := sess.activeWindow()
404+ if win == nil {
405+ return false
406+ }
407+ win.mu.Lock()
408+ defer win.mu.Unlock()
409+ return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12
410+ }, 2*time.Second, 5*time.Millisecond)
411+
412+ sess := firstSession(d)
413+ require.NotNil(t, sess)
414+ ac := sess.client
415+ require.NotNil(t, ac)
416+ d.handleInput(sess, ac, []byte("\x1bu"))
417+ awaitFrame(t, sends, ports.MsgOutput)
418+ d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
419+
420+ var payload string
421+ require.Eventually(t, func() bool {
422+ out := awaitFrame(t, sends, ports.MsgOutput)
423+ msg, err := ports.UnmarshalOutput(out.Payload)
424+ require.NoError(t, err)
425+ payload = string(msg.Data)
426+ return strings.HasPrefix(payload, "\x1b]52;c;")
427+ }, 2*time.Second, 5*time.Millisecond)
428+ require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
429+ require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload)
430+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
431+ require.NoError(t, err)
432+ require.True(t, strings.HasPrefix(string(decoded), "line-00"), "decoded yank = %q", string(decoded))
433+
434+ releaseConn()
435+ closeOnce.Do(func() { close(readDone) })
436+ hg.Wait()
437+ d.sessWg.Wait()
438+}
439+
342440 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
343441 p, _ := newBlockingPTY(t)
344442 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -360,6 +458,75 @@ func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
360458 }
361459 }
362460
461+func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
462+ cases := []struct {
463+ name string
464+ firstInput []byte
465+ secondInput []byte
466+ wantActive bool
467+ }{
468+ {name: "split up arrow", firstInput: []byte("\x1b["), secondInput: []byte("A"), wantActive: true},
469+ {name: "split page down", firstInput: []byte("\x1b[6"), secondInput: []byte("~"), wantActive: true},
470+ {name: "lone escape exits", firstInput: []byte("\x1b"), wantActive: false},
471+ }
472+ for _, tc := range cases {
473+ t.Run(tc.name, func(t *testing.T) {
474+ p, _ := newBlockingPTY(t)
475+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
476+ sess.windows[0].scrollback = scopy.NewScrollback(4)
477+ sess.windows[0].scrollback.Append(testRow("old1 "))
478+ sess.windows[0].scrollback.Append(testRow("old2 "))
479+ sess.windows[0].screen.Write([]byte("live"))
480+
481+ d.enterCopyMode(sess, ac)
482+ awaitFrame(t, sends, ports.MsgOutput)
483+ d.handleInput(sess, ac, tc.firstInput)
484+ if tc.secondInput != nil {
485+ d.handleInput(sess, ac, tc.secondInput)
486+ }
487+
488+ if gotActive := ac.copyMode != nil; gotActive != tc.wantActive {
489+ t.Fatalf("copy mode active = %v, want %v", gotActive, tc.wantActive)
490+ }
491+ })
492+ }
493+}
494+
495+func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
496+ cases := []struct {
497+ name string
498+ input []byte
499+ }{
500+ {name: "yank", input: []byte("y")},
501+ {name: "enter", input: []byte("\r")},
502+ }
503+ for _, tc := range cases {
504+ t.Run(tc.name, func(t *testing.T) {
505+ p, _ := newBlockingPTY(t)
506+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
507+ sess.windows[0].scrollback = scopy.NewScrollback(4)
508+ sess.windows[0].screen.Write([]byte("live"))
509+
510+ d.enterCopyMode(sess, ac)
511+ awaitFrame(t, sends, ports.MsgOutput)
512+ d.handleInput(sess, ac, tc.input)
513+
514+ if ac.copyMode != nil {
515+ t.Fatal("copy mode still active after empty yank")
516+ }
517+ out := awaitFrame(t, sends, ports.MsgOutput)
518+ msg, err := ports.UnmarshalOutput(out.Payload)
519+ require.NoError(t, err)
520+ if strings.Contains(string(msg.Data), "\x1b]52;") {
521+ t.Fatalf("empty yank emitted OSC52 clipboard clear: %q", string(msg.Data))
522+ }
523+ if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
524+ t.Fatalf("empty yank repaint = %q, want live full repaint", string(msg.Data))
525+ }
526+ })
527+ }
528+}
529+
363530 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
364531 p, _ := newBlockingPTY(t)
365532 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -1421,6 +1588,46 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T)
14211588 d.sessWg.Wait()
14221589 }
14231590
1591+func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1592+ d := newTestDaemon(t, nil, stubClock{})
1593+ p := portsmocks.NewMockPTY(t)
1594+ chunks := [][]byte{[]byte("\x1b[6n")}
1595+ writes := make(chan []byte, 1)
1596+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1597+ if len(chunks) == 0 {
1598+ return 0, io.EOF
1599+ }
1600+ n := copy(buf, chunks[0])
1601+ chunks = chunks[1:]
1602+ return n, nil
1603+ })
1604+ p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
1605+ writes <- append([]byte(nil), b...)
1606+ return len(b), nil
1607+ }).Once()
1608+ p.EXPECT().Close().Return(nil).Maybe()
1609+
1610+ tr, sends := newCapturingTransport(t)
1611+ sctx, cancel := context.WithCancel(context.Background())
1612+ win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1613+ sess := &session{id: "query", name: "query", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}}
1614+ d.sessions[sess.id] = sess
1615+ d.sessWg.Add(1)
1616+ d.ptyReader(sess, win)
1617+
1618+ select {
1619+ case got := <-writes:
1620+ require.Equal(t, []byte("\x1b[1;1R"), got)
1621+ case <-time.After(time.Second):
1622+ t.Fatal("timed out waiting for PTY response write")
1623+ }
1624+ select {
1625+ case f := <-sends:
1626+ require.NotEqual(t, ports.MsgOutput, f.Type)
1627+ default:
1628+ }
1629+}
1630+
14241631 func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testing.T) {
14251632 d := newTestDaemon(t, nil, stubClock{})
14261633 p := portsmocks.NewMockPTY(t)
@@ -1486,3 +1693,91 @@ func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.
14861693 t.Fatal("sync end did not request immediate flush")
14871694 }
14881695 }
1696+
1697+func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
1698+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
1699+ d := newTestDaemon(t, nil, clk)
1700+ p := portsmocks.NewMockPTY(t)
1701+ release := make(chan struct{})
1702+ read := 0
1703+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1704+ if read == 0 {
1705+ read++
1706+ return copy(buf, []byte("\x1b[?2026habandoned")), nil
1707+ }
1708+ <-release
1709+ return 0, io.EOF
1710+ })
1711+ p.EXPECT().Close().Return(nil).Maybe()
1712+
1713+ tr, sends := newCapturingTransport(t)
1714+ sctx, cancel := context.WithCancel(context.Background())
1715+ win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1716+ sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}}
1717+ d.sessions[sess.id] = sess
1718+ d.sessWg.Add(2)
1719+ go d.scheduler(sess, win)
1720+ go d.ptyReader(sess, win)
1721+
1722+ timer := <-clk.timers
1723+ timer.ch <- time.Now()
1724+ awaitFrame(t, sends, ports.MsgOutput)
1725+
1726+ win.mu.Lock()
1727+ active := win.screen.SyncUpdateActive()
1728+ got := screenLineText(win.screen, 0)
1729+ win.mu.Unlock()
1730+ require.False(t, active)
1731+ require.Contains(t, got, "abandoned")
1732+
1733+ close(release)
1734+ cancel()
1735+ d.sessWg.Wait()
1736+}
1737+
1738+func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
1739+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
1740+ d := newTestDaemon(t, nil, clk)
1741+ p := portsmocks.NewMockPTY(t)
1742+ release := make(chan struct{})
1743+ chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
1744+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1745+ if len(chunks) > 0 {
1746+ n := copy(buf, chunks[0])
1747+ chunks = chunks[1:]
1748+ return n, nil
1749+ }
1750+ <-release
1751+ return 0, io.EOF
1752+ })
1753+ p.EXPECT().Close().Return(nil).Maybe()
1754+
1755+ tr, sends := newCapturingTransport(t)
1756+ sctx, cancel := context.WithCancel(context.Background())
1757+ win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1758+ sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}}
1759+ d.sessions[sess.id] = sess
1760+ d.sessWg.Add(2)
1761+ go d.scheduler(sess, win)
1762+ go d.ptyReader(sess, win)
1763+
1764+ timer := <-clk.timers
1765+ awaitFrame(t, sends, ports.MsgOutput)
1766+ timer.ch <- time.Now()
1767+
1768+ select {
1769+ case f := <-sends:
1770+ if f.Type == ports.MsgOutput {
1771+ t.Fatal("stale synchronized update watchdog produced an extra render")
1772+ }
1773+ case <-time.After(50 * time.Millisecond):
1774+ }
1775+ win.mu.Lock()
1776+ active := win.screen.SyncUpdateActive()
1777+ win.mu.Unlock()
1778+ require.False(t, active)
1779+
1780+ close(release)
1781+ cancel()
1782+ d.sessWg.Wait()
1783+}
@@ -3,7 +3,9 @@ package daemon
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "encoding/base64"
6 "errors"7 "errors"
8+ "fmt"
7 "io"9 "io"
8 "log/slog"10 "log/slog"
9 "strings"11 "strings"
@@ -19,6 +21,7 @@ import (
19 "github.com/bnema/vev/internal/ports"21 "github.com/bnema/vev/internal/ports"
20 portsmocks "github.com/bnema/vev/internal/ports/mocks"22 portsmocks "github.com/bnema/vev/internal/ports/mocks"
21 scopy "github.com/bnema/vev/internal/usecase/copy"23 scopy "github.com/bnema/vev/internal/usecase/copy"
24+ "github.com/bnema/vev/internal/usecase/keys"
22 "github.com/bnema/vev/pkg/renderer"25 "github.com/bnema/vev/pkg/renderer"
23 "github.com/bnema/vev/pkg/vt"26 "github.com/bnema/vev/pkg/vt"
24 )27 )
@@ -41,15 +44,31 @@ func (stubTimer) Stop() bool { return true }
41 44
42 type signalClock struct {45 type signalClock struct {
43 called chan struct{}46 called chan struct{}
47+ timers chan *signalTimer
44 once sync.Once48 once sync.Once
45 }49 }
46 50
47 func (c *signalClock) Now() time.Time { return time.Time{} }51 func (c *signalClock) Now() time.Time { return time.Time{} }
48 func (c *signalClock) NewTimer(time.Duration) ports.Timer {52 func (c *signalClock) NewTimer(time.Duration) ports.Timer {
49- c.once.Do(func() { close(c.called) })53+ if c.called != nil {
50- return stubTimer{}54+ c.once.Do(func() { close(c.called) })
55+ }
56+ if c.timers == nil {
57+ return stubTimer{}
58+ }
59+ t := &signalTimer{ch: make(chan time.Time, 1)}
60+ c.timers <- t
61+ return t
51 }62 }
52 63
64+type signalTimer struct {
65+ ch chan time.Time
66+}
67+
68+func (t *signalTimer) C() <-chan time.Time { return t.ch }
69+func (t *signalTimer) Reset(time.Duration) bool { return false }
70+func (t *signalTimer) Stop() bool { return true }
71+
53 func newTestDaemon(t *testing.T, ptys ports.PTYFactory, clk ports.Clock) *Daemon {72 func newTestDaemon(t *testing.T, ptys ports.PTYFactory, clk ports.Clock) *Daemon {
54 t.Helper()73 t.Helper()
55 d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil)))74 d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil)))
@@ -161,6 +180,14 @@ func frameInput(data []byte) ports.Frame {
161 return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})}180 return ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: data})}
162 }181 }
163 182
183+func screenLineText(s *vt.Screen, y int) string {
184+ out := make([]rune, s.Frame.Width)
185+ for x := range s.Frame.Width {
186+ out[x] = s.Frame.At(x, y).Rune
187+ }
188+ return string(out)
189+}
190+
164 // awaitFrame waits for the next frame of type typ on ch, failing on timeout.191 // awaitFrame waits for the next frame of type typ on ch, failing on timeout.
165 func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Frame {192 func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Frame {
166 t.Helper()193 t.Helper()
@@ -226,6 +253,7 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
226 windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel})253 windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel})
227 }254 }
228 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac}255 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac}
256+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})
229 d.sessions[sess.id] = sess257 d.sessions[sess.id] = sess
230 t.Cleanup(cancel)258 t.Cleanup(cancel)
231 return d, sess, ac, sends259 return d, sess, ac, sends
@@ -339,6 +367,76 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
339 }367 }
340 }368 }
341 369
370+func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
371+ p := portsmocks.NewMockPTY(t)
372+ reads := make(chan []byte, 64)
373+ readDone := make(chan struct{})
374+ var closeOnce sync.Once
375+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
376+ select {
377+ case data := <-reads:
378+ return copy(b, data), nil
379+ case <-readDone:
380+ return 0, io.EOF
381+ }
382+ }).Maybe()
383+ p.EXPECT().Write(mock.Anything).Return(0, errors.New("unexpected PTY write")).Maybe()
384+ p.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
385+ p.EXPECT().Close().RunAndReturn(func() error { closeOnce.Do(func() { close(readDone) }); return nil }).Maybe()
386+ p.EXPECT().Pid().Return(4242).Maybe()
387+
388+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
389+ tr, sends, releaseConn := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 16, Rows: 5}))
390+ var hg sync.WaitGroup
391+ hg.Go(func() { d.handleConn(tr) })
392+ awaitFrame(t, sends, ports.MsgWelcome)
393+ awaitFrame(t, sends, ports.MsgOutput)
394+
395+ for i := range 12 {
396+ reads <- fmt.Appendf(nil, "line-%02d\r\n", i)
397+ }
398+ require.Eventually(t, func() bool {
399+ sess := firstSession(d)
400+ if sess == nil {
401+ return false
402+ }
403+ win := sess.activeWindow()
404+ if win == nil {
405+ return false
406+ }
407+ win.mu.Lock()
408+ defer win.mu.Unlock()
409+ return len(scopy.NewSnapshot(win.scrollback, win.screen.Frame).Rows) >= 12
410+ }, 2*time.Second, 5*time.Millisecond)
411+
412+ sess := firstSession(d)
413+ require.NotNil(t, sess)
414+ ac := sess.client
415+ require.NotNil(t, ac)
416+ d.handleInput(sess, ac, []byte("\x1bu"))
417+ awaitFrame(t, sends, ports.MsgOutput)
418+ d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'})
419+
420+ var payload string
421+ require.Eventually(t, func() bool {
422+ out := awaitFrame(t, sends, ports.MsgOutput)
423+ msg, err := ports.UnmarshalOutput(out.Payload)
424+ require.NoError(t, err)
425+ payload = string(msg.Data)
426+ return strings.HasPrefix(payload, "\x1b]52;c;")
427+ }, 2*time.Second, 5*time.Millisecond)
428+ require.True(t, strings.HasPrefix(payload, "\x1b]52;c;"), "OSC52 payload = %q", payload)
429+ require.True(t, strings.HasSuffix(payload, "\a"), "OSC52 payload = %q", payload)
430+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(payload, "\x1b]52;c;"), "\a"))
431+ require.NoError(t, err)
432+ require.True(t, strings.HasPrefix(string(decoded), "line-00"), "decoded yank = %q", string(decoded))
433+
434+ releaseConn()
435+ closeOnce.Do(func() { close(readDone) })
436+ hg.Wait()
437+ d.sessWg.Wait()
438+}
439+
342 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {440 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
343 p, _ := newBlockingPTY(t)441 p, _ := newBlockingPTY(t)
344 d, sess, ac, sends := newManualSessionWithPTYs(t, p)442 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -360,6 +458,75 @@ func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
360 }458 }
361 }459 }
362 460
461+func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
462+ cases := []struct {
463+ name string
464+ firstInput []byte
465+ secondInput []byte
466+ wantActive bool
467+ }{
468+ {name: "split up arrow", firstInput: []byte("\x1b["), secondInput: []byte("A"), wantActive: true},
469+ {name: "split page down", firstInput: []byte("\x1b[6"), secondInput: []byte("~"), wantActive: true},
470+ {name: "lone escape exits", firstInput: []byte("\x1b"), wantActive: false},
471+ }
472+ for _, tc := range cases {
473+ t.Run(tc.name, func(t *testing.T) {
474+ p, _ := newBlockingPTY(t)
475+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
476+ sess.windows[0].scrollback = scopy.NewScrollback(4)
477+ sess.windows[0].scrollback.Append(testRow("old1 "))
478+ sess.windows[0].scrollback.Append(testRow("old2 "))
479+ sess.windows[0].screen.Write([]byte("live"))
480+
481+ d.enterCopyMode(sess, ac)
482+ awaitFrame(t, sends, ports.MsgOutput)
483+ d.handleInput(sess, ac, tc.firstInput)
484+ if tc.secondInput != nil {
485+ d.handleInput(sess, ac, tc.secondInput)
486+ }
487+
488+ if gotActive := ac.copyMode != nil; gotActive != tc.wantActive {
489+ t.Fatalf("copy mode active = %v, want %v", gotActive, tc.wantActive)
490+ }
491+ })
492+ }
493+}
494+
495+func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
496+ cases := []struct {
497+ name string
498+ input []byte
499+ }{
500+ {name: "yank", input: []byte("y")},
501+ {name: "enter", input: []byte("\r")},
502+ }
503+ for _, tc := range cases {
504+ t.Run(tc.name, func(t *testing.T) {
505+ p, _ := newBlockingPTY(t)
506+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
507+ sess.windows[0].scrollback = scopy.NewScrollback(4)
508+ sess.windows[0].screen.Write([]byte("live"))
509+
510+ d.enterCopyMode(sess, ac)
511+ awaitFrame(t, sends, ports.MsgOutput)
512+ d.handleInput(sess, ac, tc.input)
513+
514+ if ac.copyMode != nil {
515+ t.Fatal("copy mode still active after empty yank")
516+ }
517+ out := awaitFrame(t, sends, ports.MsgOutput)
518+ msg, err := ports.UnmarshalOutput(out.Payload)
519+ require.NoError(t, err)
520+ if strings.Contains(string(msg.Data), "\x1b]52;") {
521+ t.Fatalf("empty yank emitted OSC52 clipboard clear: %q", string(msg.Data))
522+ }
523+ if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
524+ t.Fatalf("empty yank repaint = %q, want live full repaint", string(msg.Data))
525+ }
526+ })
527+ }
528+}
529+
363 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {530 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
364 p, _ := newBlockingPTY(t)531 p, _ := newBlockingPTY(t)
365 d, sess, ac, sends := newManualSessionWithPTYs(t, p)532 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -1421,6 +1588,46 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T)
1421 d.sessWg.Wait()1588 d.sessWg.Wait()
1422 }1589 }
1423 1590
1591+func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1592+ d := newTestDaemon(t, nil, stubClock{})
1593+ p := portsmocks.NewMockPTY(t)
1594+ chunks := [][]byte{[]byte("\x1b[6n")}
1595+ writes := make(chan []byte, 1)
1596+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1597+ if len(chunks) == 0 {
1598+ return 0, io.EOF
1599+ }
1600+ n := copy(buf, chunks[0])
1601+ chunks = chunks[1:]
1602+ return n, nil
1603+ })
1604+ p.EXPECT().Write(mock.Anything).RunAndReturn(func(b []byte) (int, error) {
1605+ writes <- append([]byte(nil), b...)
1606+ return len(b), nil
1607+ }).Once()
1608+ p.EXPECT().Close().Return(nil).Maybe()
1609+
1610+ tr, sends := newCapturingTransport(t)
1611+ sctx, cancel := context.WithCancel(context.Background())
1612+ win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1613+ sess := &session{id: "query", name: "query", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}}
1614+ d.sessions[sess.id] = sess
1615+ d.sessWg.Add(1)
1616+ d.ptyReader(sess, win)
1617+
1618+ select {
1619+ case got := <-writes:
1620+ require.Equal(t, []byte("\x1b[1;1R"), got)
1621+ case <-time.After(time.Second):
1622+ t.Fatal("timed out waiting for PTY response write")
1623+ }
1624+ select {
1625+ case f := <-sends:
1626+ require.NotEqual(t, ports.MsgOutput, f.Type)
1627+ default:
1628+ }
1629+}
1630+
1424 func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testing.T) {1631 func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testing.T) {
1425 d := newTestDaemon(t, nil, stubClock{})1632 d := newTestDaemon(t, nil, stubClock{})
1426 p := portsmocks.NewMockPTY(t)1633 p := portsmocks.NewMockPTY(t)
@@ -1486,3 +1693,91 @@ func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.
1486 t.Fatal("sync end did not request immediate flush")1693 t.Fatal("sync end did not request immediate flush")
1487 }1694 }
1488 }1695 }
1696+
1697+func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
1698+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
1699+ d := newTestDaemon(t, nil, clk)
1700+ p := portsmocks.NewMockPTY(t)
1701+ release := make(chan struct{})
1702+ read := 0
1703+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1704+ if read == 0 {
1705+ read++
1706+ return copy(buf, []byte("\x1b[?2026habandoned")), nil
1707+ }
1708+ <-release
1709+ return 0, io.EOF
1710+ })
1711+ p.EXPECT().Close().Return(nil).Maybe()
1712+
1713+ tr, sends := newCapturingTransport(t)
1714+ sctx, cancel := context.WithCancel(context.Background())
1715+ win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1716+ sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}}
1717+ d.sessions[sess.id] = sess
1718+ d.sessWg.Add(2)
1719+ go d.scheduler(sess, win)
1720+ go d.ptyReader(sess, win)
1721+
1722+ timer := <-clk.timers
1723+ timer.ch <- time.Now()
1724+ awaitFrame(t, sends, ports.MsgOutput)
1725+
1726+ win.mu.Lock()
1727+ active := win.screen.SyncUpdateActive()
1728+ got := screenLineText(win.screen, 0)
1729+ win.mu.Unlock()
1730+ require.False(t, active)
1731+ require.Contains(t, got, "abandoned")
1732+
1733+ close(release)
1734+ cancel()
1735+ d.sessWg.Wait()
1736+}
1737+
1738+func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
1739+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
1740+ d := newTestDaemon(t, nil, clk)
1741+ p := portsmocks.NewMockPTY(t)
1742+ release := make(chan struct{})
1743+ chunks := [][]byte{[]byte("\x1b[?2026hhello"), []byte(" world\x1b[?2026l")}
1744+ p.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) {
1745+ if len(chunks) > 0 {
1746+ n := copy(buf, chunks[0])
1747+ chunks = chunks[1:]
1748+ return n, nil
1749+ }
1750+ <-release
1751+ return 0, io.EOF
1752+ })
1753+ p.EXPECT().Close().Return(nil).Maybe()
1754+
1755+ tr, sends := newCapturingTransport(t)
1756+ sctx, cancel := context.WithCancel(context.Background())
1757+ win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1758+ sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}}
1759+ d.sessions[sess.id] = sess
1760+ d.sessWg.Add(2)
1761+ go d.scheduler(sess, win)
1762+ go d.ptyReader(sess, win)
1763+
1764+ timer := <-clk.timers
1765+ awaitFrame(t, sends, ports.MsgOutput)
1766+ timer.ch <- time.Now()
1767+
1768+ select {
1769+ case f := <-sends:
1770+ if f.Type == ports.MsgOutput {
1771+ t.Fatal("stale synchronized update watchdog produced an extra render")
1772+ }
1773+ case <-time.After(50 * time.Millisecond):
1774+ }
1775+ win.mu.Lock()
1776+ active := win.screen.SyncUpdateActive()
1777+ win.mu.Unlock()
1778+ require.False(t, active)
1779+
1780+ close(release)
1781+ cancel()
1782+ d.sessWg.Wait()
1783+}
M internal/usecase/keys/router.go +0 -10
@@ -78,16 +78,6 @@ func (r *Router) Route(data []byte) {
7878 r.route(data)
7979 }
8080
81-// PartialInputSuffixLen reports the suffix length that must be retained for a
82-// later read. The current protocol only has one partial suffix: a lone trailing
83-// ESC. This mirrors the YMUX partial-input pattern in a deliberately small form.
84-func PartialInputSuffixLen(data []byte) int {
85- if len(data) > 0 && data[len(data)-1] == ESC {
86- return 1
87- }
88- return 0
89-}
90-
9181 func (r *Router) route(data []byte) {
9282 buf := make([]byte, 0, len(data))
9383 flush := func() {
@@ -78,16 +78,6 @@ func (r *Router) Route(data []byte) {
78 r.route(data)78 r.route(data)
79 }79 }
80 80
81-// PartialInputSuffixLen reports the suffix length that must be retained for a
82-// later read. The current protocol only has one partial suffix: a lone trailing
83-// ESC. This mirrors the YMUX partial-input pattern in a deliberately small form.
84-func PartialInputSuffixLen(data []byte) int {
85- if len(data) > 0 && data[len(data)-1] == ESC {
86- return 1
87- }
88- return 0
89-}
90-
91 func (r *Router) route(data []byte) {81 func (r *Router) route(data []byte) {
92 buf := make([]byte, 0, len(data))82 buf := make([]byte, 0, len(data))
93 flush := func() {83 flush := func() {
M internal/usecase/keys/router_test.go +41 -5
@@ -121,6 +121,47 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
121121 require.Empty(t, h.forwards)
122122 }
123123
124+func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {
125+ cases := []struct {
126+ name string
127+ secondInput []byte
128+ want []byte
129+ }{
130+ {name: "CSI up", secondInput: []byte("[A"), want: []byte{ESC, '[', 'A'}},
131+ {name: "SS3 function key", secondInput: []byte("OP"), want: []byte{ESC, 'O', 'P'}},
132+ }
133+
134+ for _, tc := range cases {
135+ t.Run(tc.name, func(t *testing.T) {
136+ clk := &fakeClock{}
137+ h := &captureHandler{}
138+ r := NewRouter(clk, h)
139+ r.Route([]byte{ESC})
140+ require.Len(t, clk.timers, 1)
141+ require.Empty(t, h.forwards)
142+
143+ r.Route(tc.secondInput)
144+
145+ require.True(t, clk.timers[0].stopped)
146+ require.Empty(t, h.actions)
147+ require.Equal(t, [][]byte{tc.want[:2], tc.want[2:]}, h.forwards)
148+ })
149+ }
150+}
151+
152+func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
153+ clk := &fakeClock{}
154+ h := &captureHandler{}
155+ r := NewRouter(clk, h)
156+ r.Route([]byte{ESC})
157+
158+ r.Route([]byte{'[', 'A', ESC, 'c'})
159+
160+ require.True(t, clk.timers[0].stopped)
161+ require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards)
162+ require.Equal(t, []Action{ActionCreateWindow}, h.actions)
163+}
164+
124165 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
125166 clk := &fakeClock{}
126167 h := &captureHandler{}
@@ -167,8 +208,3 @@ func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) {
167208 require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)
168209 require.Empty(t, h.actions)
169210 }
170-
171-func TestPartialInputSuffixLen(t *testing.T) {
172- require.Equal(t, 1, PartialInputSuffixLen([]byte("abc\x1b")))
173- require.Equal(t, 0, PartialInputSuffixLen([]byte("abc")))
174-}
@@ -121,6 +121,47 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
121 require.Empty(t, h.forwards)121 require.Empty(t, h.forwards)
122 }122 }
123 123
124+func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {
125+ cases := []struct {
126+ name string
127+ secondInput []byte
128+ want []byte
129+ }{
130+ {name: "CSI up", secondInput: []byte("[A"), want: []byte{ESC, '[', 'A'}},
131+ {name: "SS3 function key", secondInput: []byte("OP"), want: []byte{ESC, 'O', 'P'}},
132+ }
133+
134+ for _, tc := range cases {
135+ t.Run(tc.name, func(t *testing.T) {
136+ clk := &fakeClock{}
137+ h := &captureHandler{}
138+ r := NewRouter(clk, h)
139+ r.Route([]byte{ESC})
140+ require.Len(t, clk.timers, 1)
141+ require.Empty(t, h.forwards)
142+
143+ r.Route(tc.secondInput)
144+
145+ require.True(t, clk.timers[0].stopped)
146+ require.Empty(t, h.actions)
147+ require.Equal(t, [][]byte{tc.want[:2], tc.want[2:]}, h.forwards)
148+ })
149+ }
150+}
151+
152+func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
153+ clk := &fakeClock{}
154+ h := &captureHandler{}
155+ r := NewRouter(clk, h)
156+ r.Route([]byte{ESC})
157+
158+ r.Route([]byte{'[', 'A', ESC, 'c'})
159+
160+ require.True(t, clk.timers[0].stopped)
161+ require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards)
162+ require.Equal(t, []Action{ActionCreateWindow}, h.actions)
163+}
164+
124 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {165 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
125 clk := &fakeClock{}166 clk := &fakeClock{}
126 h := &captureHandler{}167 h := &captureHandler{}
@@ -167,8 +208,3 @@ func TestRouterFlushesPendingESCBeforeOtherByte(t *testing.T) {
167 require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)208 require.Equal(t, [][]byte{{ESC}, []byte("z")}, h.forwards)
168 require.Empty(t, h.actions)209 require.Empty(t, h.actions)
169 }210 }
170-
171-func TestPartialInputSuffixLen(t *testing.T) {
172- require.Equal(t, 1, PartialInputSuffixLen([]byte("abc\x1b")))
173- require.Equal(t, 0, PartialInputSuffixLen([]byte("abc")))
174-}
M pkg/renderer/renderer_test.go +0 -100
@@ -2,10 +2,8 @@ package renderer
22
33 import (
44 "bytes"
5- "context"
65 "strings"
76 "testing"
8- "time"
97 )
108
119 // markFrame writes uniform printable content so each row is distinguishable.
@@ -507,104 +505,6 @@ func TestPartialDamage(t *testing.T) {
507505 }
508506 }
509507
510-// ---------------------------------------------------------------------------
511-// Scheduler coalescing
512-// ---------------------------------------------------------------------------
513-
514-func TestScheduler(t *testing.T) {
515- tests := []struct {
516- name string
517- run func(t *testing.T)
518- }{
519- {
520- name: "coalesces bursts of requests into a single frame",
521- run: func(t *testing.T) {
522- s := NewScheduler(30 * time.Millisecond)
523- ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
524- defer cancel()
525-
526- frames := s.Run(ctx)
527-
528- // Fire several requests before the timer fires.
529- s.Request()
530- s.Request()
531- s.Request()
532-
533- // Expect exactly one frame.
534- select {
535- case _, ok := <-frames:
536- if !ok {
537- t.Fatal("channel closed unexpectedly")
538- }
539- case <-ctx.Done():
540- t.Fatal("timeout waiting for frame")
541- }
542-
543- // No second frame should arrive without another request.
544- select {
545- case <-frames:
546- t.Fatal("unexpected second frame")
547- case <-time.After(80 * time.Millisecond):
548- // good – coalesced.
549- }
550- },
551- },
552- {
553- name: "delivers a frame per request without spurious extras",
554- run: func(t *testing.T) {
555- s := NewScheduler(10 * time.Millisecond)
556- ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
557- defer cancel()
558-
559- frames := s.Run(ctx)
560-
561- s.Request()
562- <-frames // consume first
563-
564- s.Request()
565- <-frames // consume second
566-
567- // No pending request – should not deliver another frame.
568- select {
569- case <-frames:
570- t.Fatal("unexpected frame without request")
571- case <-time.After(40 * time.Millisecond):
572- // good
573- }
574- },
575- },
576- {
577- name: "closes frames channel on context cancellation",
578- run: func(t *testing.T) {
579- s := NewScheduler(50 * time.Millisecond)
580- ctx, cancel := context.WithCancel(context.Background())
581-
582- frames := s.Run(ctx)
583- s.Request()
584-
585- // Cancel before the timer fires.
586- cancel()
587-
588- // Channel should close without ever delivering a frame.
589- select {
590- case _, ok := <-frames:
591- if ok {
592- // A frame may have been emitted before cancellation took effect;
593- // that is acceptable. What matters is that the goroutine exits.
594- _ = ok
595- }
596- case <-time.After(200 * time.Millisecond):
597- // Channel didn't close – goroutine may be stuck.
598- }
599- },
600- },
601- }
602-
603- for _, tt := range tests {
604- t.Run(tt.name, tt.run)
605- }
606-}
607-
608508 // ---------------------------------------------------------------------------
609509 // Reset
610510 // ---------------------------------------------------------------------------
@@ -2,10 +2,8 @@ package renderer
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5- "context"
6 "strings"5 "strings"
7 "testing"6 "testing"
8- "time"
9 )7 )
10 8
11 // markFrame writes uniform printable content so each row is distinguishable.9 // markFrame writes uniform printable content so each row is distinguishable.
@@ -507,104 +505,6 @@ func TestPartialDamage(t *testing.T) {
507 }505 }
508 }506 }
509 507
510-// ---------------------------------------------------------------------------
511-// Scheduler coalescing
512-// ---------------------------------------------------------------------------
513-
514-func TestScheduler(t *testing.T) {
515- tests := []struct {
516- name string
517- run func(t *testing.T)
518- }{
519- {
520- name: "coalesces bursts of requests into a single frame",
521- run: func(t *testing.T) {
522- s := NewScheduler(30 * time.Millisecond)
523- ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
524- defer cancel()
525-
526- frames := s.Run(ctx)
527-
528- // Fire several requests before the timer fires.
529- s.Request()
530- s.Request()
531- s.Request()
532-
533- // Expect exactly one frame.
534- select {
535- case _, ok := <-frames:
536- if !ok {
537- t.Fatal("channel closed unexpectedly")
538- }
539- case <-ctx.Done():
540- t.Fatal("timeout waiting for frame")
541- }
542-
543- // No second frame should arrive without another request.
544- select {
545- case <-frames:
546- t.Fatal("unexpected second frame")
547- case <-time.After(80 * time.Millisecond):
548- // good – coalesced.
549- }
550- },
551- },
552- {
553- name: "delivers a frame per request without spurious extras",
554- run: func(t *testing.T) {
555- s := NewScheduler(10 * time.Millisecond)
556- ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
557- defer cancel()
558-
559- frames := s.Run(ctx)
560-
561- s.Request()
562- <-frames // consume first
563-
564- s.Request()
565- <-frames // consume second
566-
567- // No pending request – should not deliver another frame.
568- select {
569- case <-frames:
570- t.Fatal("unexpected frame without request")
571- case <-time.After(40 * time.Millisecond):
572- // good
573- }
574- },
575- },
576- {
577- name: "closes frames channel on context cancellation",
578- run: func(t *testing.T) {
579- s := NewScheduler(50 * time.Millisecond)
580- ctx, cancel := context.WithCancel(context.Background())
581-
582- frames := s.Run(ctx)
583- s.Request()
584-
585- // Cancel before the timer fires.
586- cancel()
587-
588- // Channel should close without ever delivering a frame.
589- select {
590- case _, ok := <-frames:
591- if ok {
592- // A frame may have been emitted before cancellation took effect;
593- // that is acceptable. What matters is that the goroutine exits.
594- _ = ok
595- }
596- case <-time.After(200 * time.Millisecond):
597- // Channel didn't close – goroutine may be stuck.
598- }
599- },
600- },
601- }
602-
603- for _, tt := range tests {
604- t.Run(tt.name, tt.run)
605- }
606-}
607-
608 // ---------------------------------------------------------------------------508 // ---------------------------------------------------------------------------
609 // Reset509 // Reset
610 // ---------------------------------------------------------------------------510 // ---------------------------------------------------------------------------
D pkg/renderer/scheduler.go +0 -62
@@ -1,62 +0,0 @@
1-package renderer
2-
3-import (
4- "context"
5- "time"
6-)
7-
8-const DefaultFrameInterval = 10 * time.Millisecond
9-
10-type Scheduler struct {
11- interval time.Duration
12- requests chan struct{}
13-}
14-
15-func NewScheduler(interval time.Duration) *Scheduler {
16- if interval <= 0 {
17- interval = DefaultFrameInterval
18- }
19- return &Scheduler{interval: interval, requests: make(chan struct{}, 1)}
20-}
21-
22-func (s *Scheduler) Request() {
23- select {
24- case s.requests <- struct{}{}:
25- default:
26- }
27-}
28-
29-func (s *Scheduler) Run(ctx context.Context) <-chan struct{} {
30- frames := make(chan struct{}, 1)
31- go func() {
32- defer close(frames)
33- var timer *time.Timer
34- var timerC <-chan time.Time
35- pending := false
36- for {
37- select {
38- case <-ctx.Done():
39- if timer != nil {
40- timer.Stop()
41- }
42- return
43- case <-s.requests:
44- if pending {
45- continue
46- }
47- pending = true
48- timer = time.NewTimer(s.interval)
49- timerC = timer.C
50- case <-timerC:
51- pending = false
52- timerC = nil
53- select {
54- case frames <- struct{}{}:
55- case <-ctx.Done():
56- return
57- }
58- }
59- }
60- }()
61- return frames
62-}
@@ -1,62 +0,0 @@
1-package renderer
2-
3-import (
4- "context"
5- "time"
6-)
7-
8-const DefaultFrameInterval = 10 * time.Millisecond
9-
10-type Scheduler struct {
11- interval time.Duration
12- requests chan struct{}
13-}
14-
15-func NewScheduler(interval time.Duration) *Scheduler {
16- if interval <= 0 {
17- interval = DefaultFrameInterval
18- }
19- return &Scheduler{interval: interval, requests: make(chan struct{}, 1)}
20-}
21-
22-func (s *Scheduler) Request() {
23- select {
24- case s.requests <- struct{}{}:
25- default:
26- }
27-}
28-
29-func (s *Scheduler) Run(ctx context.Context) <-chan struct{} {
30- frames := make(chan struct{}, 1)
31- go func() {
32- defer close(frames)
33- var timer *time.Timer
34- var timerC <-chan time.Time
35- pending := false
36- for {
37- select {
38- case <-ctx.Done():
39- if timer != nil {
40- timer.Stop()
41- }
42- return
43- case <-s.requests:
44- if pending {
45- continue
46- }
47- pending = true
48- timer = time.NewTimer(s.interval)
49- timerC = timer.C
50- case <-timerC:
51- pending = false
52- timerC = nil
53- select {
54- case frames <- struct{}{}:
55- case <-ctx.Done():
56- return
57- }
58- }
59- }
60- }()
61- return frames
62-}
A pkg/vt/query_test.go +73 -0
@@ -0,0 +1,73 @@
1+package vt
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestScreenQueryResponses(t *testing.T) {
9+ cases := []struct {
10+ name string
11+ input string
12+ want string
13+ }{
14+ {name: "primary DA bare", input: "\x1b[c", want: "\x1b[?6c"},
15+ {name: "primary DA with 0", input: "\x1b[0c", want: "\x1b[?6c"},
16+ {name: "secondary DA", input: "\x1b[>c", want: "\x1b[>0;0;0c"},
17+ {name: "secondary DA with 0", input: "\x1b[>0c", want: "\x1b[>0;0;0c"},
18+ {name: "DSR status report", input: "\x1b[5n", want: "\x1b[0n"},
19+ {name: "CPR at home", input: "\x1b[6n", want: "\x1b[1;1R"},
20+ {name: "CPR after CUP", input: "\x1b[3;7H\x1b[6n", want: "\x1b[3;7R"},
21+ {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"},
22+ {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"},
23+ {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"},
24+ {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"},
25+ {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""},
26+ {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""},
27+ {name: "DA split across writes", input: "", want: "\x1b[?6c"},
28+ }
29+ for _, tc := range cases {
30+ t.Run(tc.name, func(t *testing.T) {
31+ s := NewScreen(80, 24)
32+ var got bytes.Buffer
33+ s.OnResponse = func(b []byte) { got.Write(b) }
34+ if tc.name == "DA split across writes" {
35+ s.Write([]byte("\x1b["))
36+ s.Write([]byte("0c"))
37+ } else {
38+ s.Write([]byte(tc.input))
39+ }
40+ if got.String() != tc.want {
41+ t.Fatalf("response = %q, want %q", got.String(), tc.want)
42+ }
43+ })
44+ }
45+}
46+
47+func TestScreenQueriesWithNilResponderDoNotPanic(t *testing.T) {
48+ s := NewScreen(80, 24)
49+ s.Write([]byte("\x1b[c\x1b[6n\x1b[?2026$p"))
50+}
51+
52+func TestCSIuDispatch(t *testing.T) {
53+ cases := []struct {
54+ name string
55+ input string
56+ wantRow int
57+ wantCol int
58+ }{
59+ {name: "bare u restores cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[u", wantRow: 4, wantCol: 9},
60+ {name: "kitty query leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[?u", wantRow: 1, wantCol: 1},
61+ {name: "kitty push leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[>1u", wantRow: 1, wantCol: 1},
62+ {name: "kitty pop leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[<u", wantRow: 1, wantCol: 1},
63+ }
64+ for _, tc := range cases {
65+ t.Run(tc.name, func(t *testing.T) {
66+ s := NewScreen(80, 24)
67+ s.Write([]byte(tc.input))
68+ if s.Row != tc.wantRow || s.Col != tc.wantCol {
69+ t.Fatalf("cursor = %d;%d, want %d;%d", s.Row, s.Col, tc.wantRow, tc.wantCol)
70+ }
71+ })
72+ }
73+}
@@ -0,0 +1,73 @@
1+package vt
2+
3+import (
4+ "bytes"
5+ "testing"
6+)
7+
8+func TestScreenQueryResponses(t *testing.T) {
9+ cases := []struct {
10+ name string
11+ input string
12+ want string
13+ }{
14+ {name: "primary DA bare", input: "\x1b[c", want: "\x1b[?6c"},
15+ {name: "primary DA with 0", input: "\x1b[0c", want: "\x1b[?6c"},
16+ {name: "secondary DA", input: "\x1b[>c", want: "\x1b[>0;0;0c"},
17+ {name: "secondary DA with 0", input: "\x1b[>0c", want: "\x1b[>0;0;0c"},
18+ {name: "DSR status report", input: "\x1b[5n", want: "\x1b[0n"},
19+ {name: "CPR at home", input: "\x1b[6n", want: "\x1b[1;1R"},
20+ {name: "CPR after CUP", input: "\x1b[3;7H\x1b[6n", want: "\x1b[3;7R"},
21+ {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"},
22+ {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"},
23+ {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"},
24+ {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"},
25+ {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""},
26+ {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""},
27+ {name: "DA split across writes", input: "", want: "\x1b[?6c"},
28+ }
29+ for _, tc := range cases {
30+ t.Run(tc.name, func(t *testing.T) {
31+ s := NewScreen(80, 24)
32+ var got bytes.Buffer
33+ s.OnResponse = func(b []byte) { got.Write(b) }
34+ if tc.name == "DA split across writes" {
35+ s.Write([]byte("\x1b["))
36+ s.Write([]byte("0c"))
37+ } else {
38+ s.Write([]byte(tc.input))
39+ }
40+ if got.String() != tc.want {
41+ t.Fatalf("response = %q, want %q", got.String(), tc.want)
42+ }
43+ })
44+ }
45+}
46+
47+func TestScreenQueriesWithNilResponderDoNotPanic(t *testing.T) {
48+ s := NewScreen(80, 24)
49+ s.Write([]byte("\x1b[c\x1b[6n\x1b[?2026$p"))
50+}
51+
52+func TestCSIuDispatch(t *testing.T) {
53+ cases := []struct {
54+ name string
55+ input string
56+ wantRow int
57+ wantCol int
58+ }{
59+ {name: "bare u restores cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[u", wantRow: 4, wantCol: 9},
60+ {name: "kitty query leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[?u", wantRow: 1, wantCol: 1},
61+ {name: "kitty push leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[>1u", wantRow: 1, wantCol: 1},
62+ {name: "kitty pop leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[<u", wantRow: 1, wantCol: 1},
63+ }
64+ for _, tc := range cases {
65+ t.Run(tc.name, func(t *testing.T) {
66+ s := NewScreen(80, 24)
67+ s.Write([]byte(tc.input))
68+ if s.Row != tc.wantRow || s.Col != tc.wantCol {
69+ t.Fatalf("cursor = %d;%d, want %d;%d", s.Row, s.Col, tc.wantRow, tc.wantCol)
70+ }
71+ })
72+ }
73+}
M pkg/vt/screen.go +63 -1
@@ -36,6 +36,10 @@ type Screen struct {
3636 // OnLineEvicted is called just before a full-width upward scroll recycles
3737 // and blanks rows. The callback receives a stable copy of each evicted row.
3838 OnLineEvicted func([]renderer.Cell)
39+ // OnResponse is called synchronously from Write with reply bytes that the
40+ // emulator must send back to the child process (DA, DSR, DECRQM reports).
41+ // The host wires it to the PTY input. Nil disables responses.
42+ OnResponse func([]byte)
3943
4044 damage []renderer.Damage
4145 escapeBuf []byte
@@ -80,6 +84,11 @@ func (s *Screen) ClearDamage() { s.damage = s.damage[:0] }
8084 // is currently enabled by the child process.
8185 func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
8286
87+// ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
88+// Hosts use this as a safety valve if a child enters synchronized update mode
89+// and never sends the matching end sequence.
90+func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }
91+
8392 func (s *Screen) Write(data []byte) {
8493 if len(s.escapeBuf) > 0 {
8594 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
@@ -487,6 +496,12 @@ func (s *Screen) emitLineEvicted(top, n int) {
487496 }
488497 }
489498
499+func (s *Screen) respond(b []byte) {
500+ if s.OnResponse != nil {
501+ s.OnResponse(b)
502+ }
503+}
504+
490505 func (s *Screen) scrollDownRegion(top, bottom, n int) {
491506 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
492507 return
@@ -629,6 +644,51 @@ func (s *Screen) applyCSI(params string, cmd byte) {
629644 private := strings.HasPrefix(params, "?")
630645 parts := parseCSIInts(params)
631646 switch cmd {
647+ case 'c':
648+ if params == "" || params == "0" {
649+ s.respond([]byte("\x1b[?6c"))
650+ } else if params == ">" || params == ">0" {
651+ s.respond([]byte("\x1b[>0;0;0c"))
652+ }
653+ case 'n':
654+ switch firstPositive(parts, 0) {
655+ case 5:
656+ if !private {
657+ s.respond([]byte("\x1b[0n"))
658+ }
659+ case 6:
660+ resp := make([]byte, 0, 16)
661+ resp = append(resp, "\x1b["...)
662+ if private {
663+ resp = append(resp, '?')
664+ }
665+ resp = strconv.AppendInt(resp, int64(s.Row+1), 10)
666+ resp = append(resp, ';')
667+ resp = strconv.AppendInt(resp, int64(s.Col+1), 10)
668+ resp = append(resp, 'R')
669+ s.respond(resp)
670+ }
671+ case 'p':
672+ if private && strings.HasSuffix(params, "$") {
673+ mode, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(params, "?"), "$"))
674+ if err != nil {
675+ return
676+ }
677+ state := 0
678+ if mode == 2026 {
679+ state = 2
680+ if s.syncUpdateActive {
681+ state = 1
682+ }
683+ }
684+ resp := make([]byte, 0, 16)
685+ resp = append(resp, "\x1b[?"...)
686+ resp = strconv.AppendInt(resp, int64(mode), 10)
687+ resp = append(resp, ';')
688+ resp = strconv.AppendInt(resp, int64(state), 10)
689+ resp = append(resp, "$y"...)
690+ s.respond(resp)
691+ }
632692 case 'm':
633693 if strings.HasPrefix(params, ">") {
634694 return
@@ -693,7 +753,9 @@ func (s *Screen) applyCSI(params string, cmd byte) {
693753 case 's':
694754 s.saveCursor()
695755 case 'u':
696- s.restoreCursor()
756+ if params == "" {
757+ s.restoreCursor()
758+ }
697759 case 'h':
698760 s.setMode(private, parts, true)
699761 case 'l':
@@ -36,6 +36,10 @@ type Screen struct {
36 // OnLineEvicted is called just before a full-width upward scroll recycles36 // OnLineEvicted is called just before a full-width upward scroll recycles
37 // and blanks rows. The callback receives a stable copy of each evicted row.37 // and blanks rows. The callback receives a stable copy of each evicted row.
38 OnLineEvicted func([]renderer.Cell)38 OnLineEvicted func([]renderer.Cell)
39+ // OnResponse is called synchronously from Write with reply bytes that the
40+ // emulator must send back to the child process (DA, DSR, DECRQM reports).
41+ // The host wires it to the PTY input. Nil disables responses.
42+ OnResponse func([]byte)
39 43
40 damage []renderer.Damage44 damage []renderer.Damage
41 escapeBuf []byte45 escapeBuf []byte
@@ -80,6 +84,11 @@ func (s *Screen) ClearDamage() { s.damage = s.damage[:0] }
80 // is currently enabled by the child process.84 // is currently enabled by the child process.
81 func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }85 func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
82 86
87+// ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
88+// Hosts use this as a safety valve if a child enters synchronized update mode
89+// and never sends the matching end sequence.
90+func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }
91+
83 func (s *Screen) Write(data []byte) {92 func (s *Screen) Write(data []byte) {
84 if len(s.escapeBuf) > 0 {93 if len(s.escapeBuf) > 0 {
85 combined := make([]byte, 0, len(s.escapeBuf)+len(data))94 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
@@ -487,6 +496,12 @@ func (s *Screen) emitLineEvicted(top, n int) {
487 }496 }
488 }497 }
489 498
499+func (s *Screen) respond(b []byte) {
500+ if s.OnResponse != nil {
501+ s.OnResponse(b)
502+ }
503+}
504+
490 func (s *Screen) scrollDownRegion(top, bottom, n int) {505 func (s *Screen) scrollDownRegion(top, bottom, n int) {
491 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {506 if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
492 return507 return
@@ -629,6 +644,51 @@ func (s *Screen) applyCSI(params string, cmd byte) {
629 private := strings.HasPrefix(params, "?")644 private := strings.HasPrefix(params, "?")
630 parts := parseCSIInts(params)645 parts := parseCSIInts(params)
631 switch cmd {646 switch cmd {
647+ case 'c':
648+ if params == "" || params == "0" {
649+ s.respond([]byte("\x1b[?6c"))
650+ } else if params == ">" || params == ">0" {
651+ s.respond([]byte("\x1b[>0;0;0c"))
652+ }
653+ case 'n':
654+ switch firstPositive(parts, 0) {
655+ case 5:
656+ if !private {
657+ s.respond([]byte("\x1b[0n"))
658+ }
659+ case 6:
660+ resp := make([]byte, 0, 16)
661+ resp = append(resp, "\x1b["...)
662+ if private {
663+ resp = append(resp, '?')
664+ }
665+ resp = strconv.AppendInt(resp, int64(s.Row+1), 10)
666+ resp = append(resp, ';')
667+ resp = strconv.AppendInt(resp, int64(s.Col+1), 10)
668+ resp = append(resp, 'R')
669+ s.respond(resp)
670+ }
671+ case 'p':
672+ if private && strings.HasSuffix(params, "$") {
673+ mode, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(params, "?"), "$"))
674+ if err != nil {
675+ return
676+ }
677+ state := 0
678+ if mode == 2026 {
679+ state = 2
680+ if s.syncUpdateActive {
681+ state = 1
682+ }
683+ }
684+ resp := make([]byte, 0, 16)
685+ resp = append(resp, "\x1b[?"...)
686+ resp = strconv.AppendInt(resp, int64(mode), 10)
687+ resp = append(resp, ';')
688+ resp = strconv.AppendInt(resp, int64(state), 10)
689+ resp = append(resp, "$y"...)
690+ s.respond(resp)
691+ }
632 case 'm':692 case 'm':
633 if strings.HasPrefix(params, ">") {693 if strings.HasPrefix(params, ">") {
634 return694 return
@@ -693,7 +753,9 @@ func (s *Screen) applyCSI(params string, cmd byte) {
693 case 's':753 case 's':
694 s.saveCursor()754 s.saveCursor()
695 case 'u':755 case 'u':
696- s.restoreCursor()756+ if params == "" {
757+ s.restoreCursor()
758+ }
697 case 'h':759 case 'h':
698 s.setMode(private, parts, true)760 s.setMode(private, parts, true)
699 case 'l':761 case 'l':
M pkg/vt/screen_test.go +12 -0
@@ -2192,3 +2192,15 @@ func TestScreenReportsSynchronizedUpdateMode(t *testing.T) {
21922192 t.Fatal("sync update mode should be inactive")
21932193 }
21942194 }
2195+
2196+func TestForceSyncEnd(t *testing.T) {
2197+ s := NewScreen(10, 2)
2198+ s.Write([]byte("\x1b[?2026h"))
2199+ if !s.SyncUpdateActive() {
2200+ t.Fatal("sync update mode should be active before forcing end")
2201+ }
2202+ s.ForceSyncEnd()
2203+ if s.SyncUpdateActive() {
2204+ t.Fatal("sync update mode should be inactive after forcing end")
2205+ }
2206+}
@@ -2192,3 +2192,15 @@ func TestScreenReportsSynchronizedUpdateMode(t *testing.T) {
2192 t.Fatal("sync update mode should be inactive")2192 t.Fatal("sync update mode should be inactive")
2193 }2193 }
2194 }2194 }
2195+
2196+func TestForceSyncEnd(t *testing.T) {
2197+ s := NewScreen(10, 2)
2198+ s.Write([]byte("\x1b[?2026h"))
2199+ if !s.SyncUpdateActive() {
2200+ t.Fatal("sync update mode should be active before forcing end")
2201+ }
2202+ s.ForceSyncEnd()
2203+ if s.SyncUpdateActive() {
2204+ t.Fatal("sync update mode should be inactive after forcing end")
2205+ }
2206+}

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 2

Tags 13

↑↓ moveenter openesc close