detached 922d0fd

feat(input): add mouse wheel and hardware cursor support

Track child cursor and mouse state, route SGR mouse input through copy-mode, child forwarding, and alternate-screen wheel behavior, and enable terminal mouse reporting while attached.

Also guards the client sender against late queued frames after cancellation, fixing a shutdown-race test flake found during verification.
commit
922d0fd3c33174dbbb4ed0a2ab489ec8657956a9
author
brice · · 2026-07-02 19:51 UTC
parent
c42d73a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +878 -21
M README.md +6 -0
@@ -65,6 +65,12 @@ Copy mode freezes a view over the scrollback while the program keeps running und
6565
6666 Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH.
6767
68+## Mouse
69+
70+vev enables terminal mouse reporting while attached. Mouse input on the status row is reserved for vev and is not sent to the child. In copy mode, the wheel scrolls the copy-mode view. When the child enables mouse reporting, SGR mouse events are forwarded to it. Otherwise, wheel events in the alternate screen are translated to Up/Down arrow keys (`ESC[A` / `ESC[B`). This translation intentionally does not honor DECCKM application-cursor mode (`ESC OA` / `ESC OB`). An extremely rare input read split exactly between `ESC[` and `<` in an SGR mouse sequence may leak the partial `ESC[` to the child.
71+
72+vev follows the child application's cursor visibility and style requests and appends a hardware cursor update after each paint. Visible cursors blink as requested by the terminal/application state.
73+
6874 ## Development
6975
7076 ```sh
@@ -65,6 +65,12 @@ Copy mode freezes a view over the scrollback while the program keeps running und
65 65
66 Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH.66 Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH.
67 67
68+## Mouse
69+
70+vev enables terminal mouse reporting while attached. Mouse input on the status row is reserved for vev and is not sent to the child. In copy mode, the wheel scrolls the copy-mode view. When the child enables mouse reporting, SGR mouse events are forwarded to it. Otherwise, wheel events in the alternate screen are translated to Up/Down arrow keys (`ESC[A` / `ESC[B`). This translation intentionally does not honor DECCKM application-cursor mode (`ESC OA` / `ESC OB`). An extremely rare input read split exactly between `ESC[` and `<` in an SGR mouse sequence may leak the partial `ESC[` to the child.
71+
72+vev follows the child application's cursor visibility and style requests and appends a hardware cursor update after each paint. Visible cursors blink as requested by the terminal/application state.
73+
68 ## Development74 ## Development
69 75
70 ```sh76 ```sh
M internal/adapters/term/pty_integration_test.go +1 -1
@@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
102102
103103 select {
104104 case got := <-captured:
105- want := altScreenEnter + cursorHide + cursorShow + altScreenExit
105+ want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
106106 if string(got) != want {
107107 t.Fatalf("captured escapes = %q, want %q", got, want)
108108 }
@@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
102 102
103 select {103 select {
104 case got := <-captured:104 case got := <-captured:
105- want := altScreenEnter + cursorHide + cursorShow + altScreenExit105+ want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
106 if string(got) != want {106 if string(got) != want {
107 t.Fatalf("captured escapes = %q, want %q", got, want)107 t.Fatalf("captured escapes = %q, want %q", got, want)
108 }108 }
M internal/adapters/term/terminal.go +9 -6
@@ -21,10 +21,13 @@ var _ ports.Terminal = (*Terminal)(nil)
2121 // Escape sequences for alt-screen and cursor visibility control. All
2222 // emissions go through the batched writer and are explicitly flushed.
2323 const (
24- altScreenEnter = "\x1b[?1049h"
25- altScreenExit = "\x1b[?1049l"
26- cursorHide = "\x1b[?25l"
27- cursorShow = "\x1b[?25h"
24+ altScreenEnter = "\x1b[?1049h"
25+ altScreenExit = "\x1b[?1049l"
26+ cursorHide = "\x1b[?25l"
27+ cursorShow = "\x1b[?25h"
28+ cursorStyleDefault = "\x1b[0 q"
29+ mouseEnable = "\x1b[?1002h\x1b[?1006h"
30+ mouseDisable = "\x1b[?1002l\x1b[?1006l"
2831 )
2932
3033 // bufSize is the batched writer's buffer capacity.
@@ -101,7 +104,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
101104 }
102105 t.orig = old
103106
104- if _, err := t.bw.WriteString(altScreenEnter + cursorHide); err != nil {
107+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable); err != nil {
105108 _ = t.restoreRawLocked()
106109 return nil, fmt.Errorf("term: enter alt screen: %w", err)
107110 }
@@ -135,7 +138,7 @@ func (t *Terminal) restore() error {
135138 t.mu.Lock()
136139 defer t.mu.Unlock()
137140
138- _, werr := t.bw.WriteString(cursorShow + altScreenExit)
141+ _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + altScreenExit)
139142 ferr := t.bw.Flush()
140143
141144 rerr := t.restoreRawLocked()
@@ -21,10 +21,13 @@ var _ ports.Terminal = (*Terminal)(nil)
21 // Escape sequences for alt-screen and cursor visibility control. All21 // Escape sequences for alt-screen and cursor visibility control. All
22 // emissions go through the batched writer and are explicitly flushed.22 // emissions go through the batched writer and are explicitly flushed.
23 const (23 const (
24- altScreenEnter = "\x1b[?1049h"24+ altScreenEnter = "\x1b[?1049h"
25- altScreenExit = "\x1b[?1049l"25+ altScreenExit = "\x1b[?1049l"
26- cursorHide = "\x1b[?25l"26+ cursorHide = "\x1b[?25l"
27- cursorShow = "\x1b[?25h"27+ cursorShow = "\x1b[?25h"
28+ cursorStyleDefault = "\x1b[0 q"
29+ mouseEnable = "\x1b[?1002h\x1b[?1006h"
30+ mouseDisable = "\x1b[?1002l\x1b[?1006l"
28 )31 )
29 32
30 // bufSize is the batched writer's buffer capacity.33 // bufSize is the batched writer's buffer capacity.
@@ -101,7 +104,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
101 }104 }
102 t.orig = old105 t.orig = old
103 106
104- if _, err := t.bw.WriteString(altScreenEnter + cursorHide); err != nil {107+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable); err != nil {
105 _ = t.restoreRawLocked()108 _ = t.restoreRawLocked()
106 return nil, fmt.Errorf("term: enter alt screen: %w", err)109 return nil, fmt.Errorf("term: enter alt screen: %w", err)
107 }110 }
@@ -135,7 +138,7 @@ func (t *Terminal) restore() error {
135 t.mu.Lock()138 t.mu.Lock()
136 defer t.mu.Unlock()139 defer t.mu.Unlock()
137 140
138- _, werr := t.bw.WriteString(cursorShow + altScreenExit)141+ _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + altScreenExit)
139 ferr := t.bw.Flush()142 ferr := t.bw.Flush()
140 143
141 rerr := t.restoreRawLocked()144 rerr := t.restoreRawLocked()
M internal/adapters/term/terminal_test.go +2 -2
@@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
6060 _ = outW.Close()
6161 <-done
6262
63- want := altScreenEnter + cursorHide + cursorShow + altScreenExit
63+ want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
6464 if got := captured.String(); got != want {
6565 t.Fatalf("captured escapes = %q, want %q", got, want)
6666 }
@@ -101,7 +101,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
101101 // Alt-screen/cursor escapes must appear exactly once for enter and
102102 // once for exit, regardless of how many times EnterRaw/restore were
103103 // called.
104- want := altScreenEnter + cursorHide + cursorShow + altScreenExit
104+ want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
105105 if got := captured.String(); got != want {
106106 t.Fatalf("captured escapes = %q, want %q", got, want)
107107 }
@@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
60 _ = outW.Close()60 _ = outW.Close()
61 <-done61 <-done
62 62
63- want := altScreenEnter + cursorHide + cursorShow + altScreenExit63+ want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
64 if got := captured.String(); got != want {64 if got := captured.String(); got != want {
65 t.Fatalf("captured escapes = %q, want %q", got, want)65 t.Fatalf("captured escapes = %q, want %q", got, want)
66 }66 }
@@ -101,7 +101,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
101 // Alt-screen/cursor escapes must appear exactly once for enter and101 // Alt-screen/cursor escapes must appear exactly once for enter and
102 // once for exit, regardless of how many times EnterRaw/restore were102 // once for exit, regardless of how many times EnterRaw/restore were
103 // called.103 // called.
104- want := altScreenEnter + cursorHide + cursorShow + altScreenExit104+ want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit
105 if got := captured.String(); got != want {105 if got := captured.String(); got != want {
106 t.Fatalf("captured escapes = %q, want %q", got, want)106 t.Fatalf("captured escapes = %q, want %q", got, want)
107 }107 }
M internal/usecase/client/client.go +5 -0
@@ -248,6 +248,11 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
248248 for {
249249 select {
250250 case f := <-in:
251+ select {
252+ case <-ctx.Done():
253+ return
254+ default:
255+ }
251256 if err := transport.Send(f); err != nil {
252257 select {
253258 case errCh <- err:
@@ -248,6 +248,11 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
248 for {248 for {
249 select {249 select {
250 case f := <-in:250 case f := <-in:
251+ select {
252+ case <-ctx.Done():
253+ return
254+ default:
255+ }
251 if err := transport.Send(f); err != nil {256 if err := transport.Send(f); err != nil {
252 select {257 select {
253 case errCh <- err:258 case errCh <- err:
M internal/usecase/copy/mode.go +8 -0
@@ -60,6 +60,14 @@ func (m *Mode) Page(s Snapshot, pages int) { m.setCursor(s, m.Cursor+pages*max(s
6060 func (m *Mode) Top(s Snapshot) { m.setCursor(s, 0) }
6161 func (m *Mode) Bottom(s Snapshot) { m.setCursor(s, len(s.Rows)-1) }
6262
63+func (m *Mode) AtBottom(s Snapshot) bool {
64+ total := len(s.Rows)
65+ if total == 0 {
66+ return m.Cursor == 0 && m.ViewportTop == 0
67+ }
68+ return m.Cursor == total-1 && m.ViewportTop == max(total-max(s.Height, 1), 0)
69+}
70+
6371 func (m *Mode) ToggleSelection() {
6472 if m.Selecting {
6573 m.Selecting = false
@@ -60,6 +60,14 @@ func (m *Mode) Page(s Snapshot, pages int) { m.setCursor(s, m.Cursor+pages*max(s
60 func (m *Mode) Top(s Snapshot) { m.setCursor(s, 0) }60 func (m *Mode) Top(s Snapshot) { m.setCursor(s, 0) }
61 func (m *Mode) Bottom(s Snapshot) { m.setCursor(s, len(s.Rows)-1) }61 func (m *Mode) Bottom(s Snapshot) { m.setCursor(s, len(s.Rows)-1) }
62 62
63+func (m *Mode) AtBottom(s Snapshot) bool {
64+ total := len(s.Rows)
65+ if total == 0 {
66+ return m.Cursor == 0 && m.ViewportTop == 0
67+ }
68+ return m.Cursor == total-1 && m.ViewportTop == max(total-max(s.Height, 1), 0)
69+}
70+
63 func (m *Mode) ToggleSelection() {71 func (m *Mode) ToggleSelection() {
64 if m.Selecting {72 if m.Selecting {
65 m.Selecting = false73 m.Selecting = false
M internal/usecase/copy/mode_test.go +67 -0
@@ -88,6 +88,47 @@ func TestCopyModeNavigationBounds(t *testing.T) {
8888 }
8989 }
9090
91+func TestCopyModeAtBottom(t *testing.T) {
92+ s := snapshot([]string{"00", "01", "02", "03", "04"}, 3)
93+ m := NewMode(s)
94+ if !m.AtBottom(s) {
95+ t.Fatalf("AtBottom() after NewMode = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop)
96+ }
97+
98+ m.Move(s, -1)
99+ if m.AtBottom(s) {
100+ t.Fatalf("AtBottom() after moving above bottom = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop)
101+ }
102+
103+ m.Bottom(s)
104+ if !m.AtBottom(s) {
105+ t.Fatalf("AtBottom() after Bottom = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop)
106+ }
107+
108+ m.Top(s)
109+ if m.AtBottom(s) {
110+ t.Fatalf("AtBottom() after Top = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop)
111+ }
112+}
113+
114+func TestCopyModeAtBottomWithShortSnapshot(t *testing.T) {
115+ s := snapshot([]string{"00", "01"}, 5)
116+ m := NewMode(s)
117+ if !m.AtBottom(s) {
118+ t.Fatalf("AtBottom() after NewMode on short snapshot = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop)
119+ }
120+
121+ m.Top(s)
122+ if m.AtBottom(s) {
123+ t.Fatalf("AtBottom() after Top on short snapshot = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop)
124+ }
125+
126+ m.Bottom(s)
127+ if !m.AtBottom(s) {
128+ t.Fatalf("AtBottom() after Bottom on short snapshot = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop)
129+ }
130+}
131+
91132 func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
92133 s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)
93134 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
@@ -104,6 +145,32 @@ func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
104145 }
105146 }
106147
148+func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) {
149+ s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3)
150+ m := NewMode(s)
151+ m.Move(s, -2)
152+ m.ToggleSelection()
153+ anchor := m.Anchor
154+
155+ m.Move(s, 3)
156+ if m.Anchor != anchor {
157+ t.Fatalf("Anchor after Move(+3) while selecting = %d, want %d", m.Anchor, anchor)
158+ }
159+ lo, hi, ok := m.SelectedBounds()
160+ if !ok || lo != anchor || hi != m.Cursor {
161+ t.Fatalf("SelectedBounds after Move(+3) = %d/%d/%v, want %d/%d/true", lo, hi, ok, anchor, m.Cursor)
162+ }
163+
164+ m.Move(s, -3)
165+ if m.Anchor != anchor {
166+ t.Fatalf("Anchor after Move(-3) while selecting = %d, want %d", m.Anchor, anchor)
167+ }
168+ lo, hi, ok = m.SelectedBounds()
169+ if !ok || lo != m.Cursor || hi != anchor {
170+ t.Fatalf("SelectedBounds after Move(-3) = %d/%d/%v, want %d/%d/true", lo, hi, ok, m.Cursor, anchor)
171+ }
172+}
173+
107174 func TestOSC52Base64Encoding(t *testing.T) {
108175 tests := []struct {
109176 name string
@@ -88,6 +88,47 @@ func TestCopyModeNavigationBounds(t *testing.T) {
88 }88 }
89 }89 }
90 90
91+func TestCopyModeAtBottom(t *testing.T) {
92+ s := snapshot([]string{"00", "01", "02", "03", "04"}, 3)
93+ m := NewMode(s)
94+ if !m.AtBottom(s) {
95+ t.Fatalf("AtBottom() after NewMode = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop)
96+ }
97+
98+ m.Move(s, -1)
99+ if m.AtBottom(s) {
100+ t.Fatalf("AtBottom() after moving above bottom = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop)
101+ }
102+
103+ m.Bottom(s)
104+ if !m.AtBottom(s) {
105+ t.Fatalf("AtBottom() after Bottom = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop)
106+ }
107+
108+ m.Top(s)
109+ if m.AtBottom(s) {
110+ t.Fatalf("AtBottom() after Top = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop)
111+ }
112+}
113+
114+func TestCopyModeAtBottomWithShortSnapshot(t *testing.T) {
115+ s := snapshot([]string{"00", "01"}, 5)
116+ m := NewMode(s)
117+ if !m.AtBottom(s) {
118+ t.Fatalf("AtBottom() after NewMode on short snapshot = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop)
119+ }
120+
121+ m.Top(s)
122+ if m.AtBottom(s) {
123+ t.Fatalf("AtBottom() after Top on short snapshot = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop)
124+ }
125+
126+ m.Bottom(s)
127+ if !m.AtBottom(s) {
128+ t.Fatalf("AtBottom() after Bottom on short snapshot = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop)
129+ }
130+}
131+
91 func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {132 func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
92 s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)133 s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)
93 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}134 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
@@ -104,6 +145,32 @@ func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
104 }145 }
105 }146 }
106 147
148+func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) {
149+ s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3)
150+ m := NewMode(s)
151+ m.Move(s, -2)
152+ m.ToggleSelection()
153+ anchor := m.Anchor
154+
155+ m.Move(s, 3)
156+ if m.Anchor != anchor {
157+ t.Fatalf("Anchor after Move(+3) while selecting = %d, want %d", m.Anchor, anchor)
158+ }
159+ lo, hi, ok := m.SelectedBounds()
160+ if !ok || lo != anchor || hi != m.Cursor {
161+ t.Fatalf("SelectedBounds after Move(+3) = %d/%d/%v, want %d/%d/true", lo, hi, ok, anchor, m.Cursor)
162+ }
163+
164+ m.Move(s, -3)
165+ if m.Anchor != anchor {
166+ t.Fatalf("Anchor after Move(-3) while selecting = %d, want %d", m.Anchor, anchor)
167+ }
168+ lo, hi, ok = m.SelectedBounds()
169+ if !ok || lo != m.Cursor || hi != anchor {
170+ t.Fatalf("SelectedBounds after Move(-3) = %d/%d/%v, want %d/%d/true", lo, hi, ok, m.Cursor, anchor)
171+ }
172+}
173+
107 func TestOSC52Base64Encoding(t *testing.T) {174 func TestOSC52Base64Encoding(t *testing.T) {
108 tests := []struct {175 tests := []struct {
109 name string176 name string
M internal/usecase/daemon/daemon.go +149 -6
@@ -42,6 +42,7 @@ import (
4242 "github.com/bnema/vev/internal/ports"
4343 scopy "github.com/bnema/vev/internal/usecase/copy"
4444 "github.com/bnema/vev/internal/usecase/keys"
45+ "github.com/bnema/vev/internal/usecase/mouse"
4546 "github.com/bnema/vev/pkg/renderer"
4647 "github.com/bnema/vev/pkg/vt"
4748 )
@@ -160,9 +161,20 @@ type attachedClient struct {
160161 copyMu sync.Mutex
161162 copyMode *scopy.Mode
162163 copyPending []byte
164+ mouseScan mouse.Scanner
165+ lastCursor cursorOut
163166 sendMu sync.Mutex
164167 }
165168
169+type cursorOut struct {
170+ valid bool
171+ hidden bool
172+ row int
173+ col int
174+ style int
175+ hasStyle bool
176+}
177+
166178 func (ac *attachedClient) copyModeActive() bool {
167179 ac.copyMu.Lock()
168180 defer ac.copyMu.Unlock()
@@ -699,11 +711,93 @@ func (s *session) activeWindow() *window {
699711 }
700712
701713 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
714+ ac.mouseScan.Scan(data,
715+ func(ev mouse.Event) { d.handleMouse(sess, ac, ev) },
716+ func(b []byte) {
717+ if ac.copyModeActive() {
718+ d.handleCopyInput(sess, ac, b)
719+ return
720+ }
721+ ac.keys.Route(b)
722+ },
723+ )
724+}
725+
726+func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) {
727+ win := sess.activeWindow()
728+ if win == nil {
729+ return
730+ }
731+
702732 if ac.copyModeActive() {
703- d.handleCopyInput(sess, ac, data)
733+ switch ev.Button {
734+ case mouse.WheelUp:
735+ d.copyWheel(sess, ac, -3)
736+ case mouse.WheelDown:
737+ d.copyWheel(sess, ac, 3)
738+ }
739+ return
740+ }
741+
742+ win.mu.Lock()
743+ childRows := win.screen.Frame.Height
744+ mouseMode, mouseSGR := win.screen.MouseMode()
745+ altScreen := win.screen.AltScreenActive()
746+ win.mu.Unlock()
747+
748+ if mouseMode != 0 {
749+ if !mouseSGR || ev.Row >= childRows {
750+ return
751+ }
752+ daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward(ev.Raw)
753+ return
754+ }
755+
756+ switch ev.Button {
757+ case mouse.WheelUp:
758+ if altScreen {
759+ daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))
760+ return
761+ }
762+ d.enterCopyMode(sess, ac)
763+ d.copyWheel(sess, ac, -3)
764+ case mouse.WheelDown:
765+ if altScreen {
766+ daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))
767+ }
768+ }
769+}
770+
771+func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
772+ win := sess.activeWindow()
773+ if win == nil {
774+ return
775+ }
776+
777+ win.mu.Lock()
778+ ac.copyMu.Lock()
779+ if ac.copyMode == nil {
780+ ac.copyMu.Unlock()
781+ win.mu.Unlock()
704782 return
705783 }
706- ac.keys.Route(data)
784+ snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
785+ if delta > 0 && ac.copyMode.AtBottom(snap) {
786+ ac.copyMode = nil
787+ ac.copyMu.Unlock()
788+ win.mu.Unlock()
789+ d.paint(sess, ac, true)
790+ return
791+ }
792+ ac.copyMode.Move(snap, delta)
793+ exit := delta > 0 && ac.copyMode.AtBottom(snap)
794+ if exit {
795+ ac.copyMode = nil
796+ }
797+ ac.copyMu.Unlock()
798+ win.mu.Unlock()
799+
800+ d.paint(sess, ac, true)
707801 }
708802
709803 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
@@ -1150,21 +1244,33 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
11501244 ac.sendMu.Lock()
11511245 win.mu.Lock()
11521246 ac.copyMu.Lock()
1153- if reset || ac.copyMode != nil {
1247+ copyActive := ac.copyMode != nil
1248+ if reset || copyActive {
11541249 ac.rend.Reset()
11551250 }
1251+ if reset {
1252+ ac.lastCursor.valid = false
1253+ }
11561254 frame, damage := composeClientFrame(sess, win, reset)
1157- if ac.copyMode != nil {
1255+ if copyActive {
11581256 frame, damage = composeCopyClientFrame(ac.copyMode, win)
11591257 }
1258+ desiredCursor := desiredCursorOut(win.screen, copyActive)
11601259 ac.copyMu.Unlock()
11611260 data, err := ac.rend.Draw(frame, damage)
1261+ var cursorTail []byte
1262+ if err == nil {
1263+ cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
1264+ }
11621265 win.screen.ClearDamage()
11631266 win.mu.Unlock()
11641267
11651268 var serr error
1166- if err == nil && len(data) > 0 {
1167- serr = ac.tr.Send(frameOutput(data))
1269+ if err == nil {
1270+ data = append(data, cursorTail...)
1271+ if len(data) > 0 {
1272+ serr = ac.tr.Send(frameOutput(data))
1273+ }
11681274 }
11691275 ac.sendMu.Unlock()
11701276
@@ -1180,6 +1286,43 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
11801286 // resize applies a client size change to every window using rows-1 for the PTY
11811287 // and VT screen, then resets the renderer shadow and repaints the composed
11821288 // client-sized frame (including the status row).
1289+func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {
1290+ if copyActive || !s.CursorVisible() {
1291+ return cursorOut{hidden: true}
1292+ }
1293+ style, ok := s.CursorStyle()
1294+ if !ok {
1295+ style = 5
1296+ }
1297+ return cursorOut{row: s.CursorRow(), col: s.CursorCol(), style: style, hasStyle: true}
1298+}
1299+
1300+func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
1301+ changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
1302+ if !changed {
1303+ return nil
1304+ }
1305+ prev := ac.lastCursor
1306+ ac.lastCursor = desired
1307+ ac.lastCursor.valid = true
1308+ if desired.hidden {
1309+ return []byte("\x1b[?25l")
1310+ }
1311+ var b []byte
1312+ b = append(b, "\x1b["...)
1313+ b = strconv.AppendInt(b, int64(desired.row+1), 10)
1314+ b = append(b, ';')
1315+ b = strconv.AppendInt(b, int64(desired.col+1), 10)
1316+ b = append(b, 'H')
1317+ if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
1318+ b = append(b, "\x1b["...)
1319+ b = strconv.AppendInt(b, int64(desired.style), 10)
1320+ b = append(b, " q"...)
1321+ }
1322+ b = append(b, "\x1b[?25h"...)
1323+ return b
1324+}
1325+
11831326 func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) {
11841327 width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height
11851328 frame := renderer.NewFrame(width, screenRows+1)
@@ -42,6 +42,7 @@ import (
42 "github.com/bnema/vev/internal/ports"42 "github.com/bnema/vev/internal/ports"
43 scopy "github.com/bnema/vev/internal/usecase/copy"43 scopy "github.com/bnema/vev/internal/usecase/copy"
44 "github.com/bnema/vev/internal/usecase/keys"44 "github.com/bnema/vev/internal/usecase/keys"
45+ "github.com/bnema/vev/internal/usecase/mouse"
45 "github.com/bnema/vev/pkg/renderer"46 "github.com/bnema/vev/pkg/renderer"
46 "github.com/bnema/vev/pkg/vt"47 "github.com/bnema/vev/pkg/vt"
47 )48 )
@@ -160,9 +161,20 @@ type attachedClient struct {
160 copyMu sync.Mutex161 copyMu sync.Mutex
161 copyMode *scopy.Mode162 copyMode *scopy.Mode
162 copyPending []byte163 copyPending []byte
164+ mouseScan mouse.Scanner
165+ lastCursor cursorOut
163 sendMu sync.Mutex166 sendMu sync.Mutex
164 }167 }
165 168
169+type cursorOut struct {
170+ valid bool
171+ hidden bool
172+ row int
173+ col int
174+ style int
175+ hasStyle bool
176+}
177+
166 func (ac *attachedClient) copyModeActive() bool {178 func (ac *attachedClient) copyModeActive() bool {
167 ac.copyMu.Lock()179 ac.copyMu.Lock()
168 defer ac.copyMu.Unlock()180 defer ac.copyMu.Unlock()
@@ -699,11 +711,93 @@ func (s *session) activeWindow() *window {
699 }711 }
700 712
701 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {713 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
714+ ac.mouseScan.Scan(data,
715+ func(ev mouse.Event) { d.handleMouse(sess, ac, ev) },
716+ func(b []byte) {
717+ if ac.copyModeActive() {
718+ d.handleCopyInput(sess, ac, b)
719+ return
720+ }
721+ ac.keys.Route(b)
722+ },
723+ )
724+}
725+
726+func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) {
727+ win := sess.activeWindow()
728+ if win == nil {
729+ return
730+ }
731+
702 if ac.copyModeActive() {732 if ac.copyModeActive() {
703- d.handleCopyInput(sess, ac, data)733+ switch ev.Button {
734+ case mouse.WheelUp:
735+ d.copyWheel(sess, ac, -3)
736+ case mouse.WheelDown:
737+ d.copyWheel(sess, ac, 3)
738+ }
739+ return
740+ }
741+
742+ win.mu.Lock()
743+ childRows := win.screen.Frame.Height
744+ mouseMode, mouseSGR := win.screen.MouseMode()
745+ altScreen := win.screen.AltScreenActive()
746+ win.mu.Unlock()
747+
748+ if mouseMode != 0 {
749+ if !mouseSGR || ev.Row >= childRows {
750+ return
751+ }
752+ daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward(ev.Raw)
753+ return
754+ }
755+
756+ switch ev.Button {
757+ case mouse.WheelUp:
758+ if altScreen {
759+ daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))
760+ return
761+ }
762+ d.enterCopyMode(sess, ac)
763+ d.copyWheel(sess, ac, -3)
764+ case mouse.WheelDown:
765+ if altScreen {
766+ daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))
767+ }
768+ }
769+}
770+
771+func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
772+ win := sess.activeWindow()
773+ if win == nil {
774+ return
775+ }
776+
777+ win.mu.Lock()
778+ ac.copyMu.Lock()
779+ if ac.copyMode == nil {
780+ ac.copyMu.Unlock()
781+ win.mu.Unlock()
704 return782 return
705 }783 }
706- ac.keys.Route(data)784+ snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
785+ if delta > 0 && ac.copyMode.AtBottom(snap) {
786+ ac.copyMode = nil
787+ ac.copyMu.Unlock()
788+ win.mu.Unlock()
789+ d.paint(sess, ac, true)
790+ return
791+ }
792+ ac.copyMode.Move(snap, delta)
793+ exit := delta > 0 && ac.copyMode.AtBottom(snap)
794+ if exit {
795+ ac.copyMode = nil
796+ }
797+ ac.copyMu.Unlock()
798+ win.mu.Unlock()
799+
800+ d.paint(sess, ac, true)
707 }801 }
708 802
709 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {803 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
@@ -1150,21 +1244,33 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
1150 ac.sendMu.Lock()1244 ac.sendMu.Lock()
1151 win.mu.Lock()1245 win.mu.Lock()
1152 ac.copyMu.Lock()1246 ac.copyMu.Lock()
1153- if reset || ac.copyMode != nil {1247+ copyActive := ac.copyMode != nil
1248+ if reset || copyActive {
1154 ac.rend.Reset()1249 ac.rend.Reset()
1155 }1250 }
1251+ if reset {
1252+ ac.lastCursor.valid = false
1253+ }
1156 frame, damage := composeClientFrame(sess, win, reset)1254 frame, damage := composeClientFrame(sess, win, reset)
1157- if ac.copyMode != nil {1255+ if copyActive {
1158 frame, damage = composeCopyClientFrame(ac.copyMode, win)1256 frame, damage = composeCopyClientFrame(ac.copyMode, win)
1159 }1257 }
1258+ desiredCursor := desiredCursorOut(win.screen, copyActive)
1160 ac.copyMu.Unlock()1259 ac.copyMu.Unlock()
1161 data, err := ac.rend.Draw(frame, damage)1260 data, err := ac.rend.Draw(frame, damage)
1261+ var cursorTail []byte
1262+ if err == nil {
1263+ cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
1264+ }
1162 win.screen.ClearDamage()1265 win.screen.ClearDamage()
1163 win.mu.Unlock()1266 win.mu.Unlock()
1164 1267
1165 var serr error1268 var serr error
1166- if err == nil && len(data) > 0 {1269+ if err == nil {
1167- serr = ac.tr.Send(frameOutput(data))1270+ data = append(data, cursorTail...)
1271+ if len(data) > 0 {
1272+ serr = ac.tr.Send(frameOutput(data))
1273+ }
1168 }1274 }
1169 ac.sendMu.Unlock()1275 ac.sendMu.Unlock()
1170 1276
@@ -1180,6 +1286,43 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
1180 // resize applies a client size change to every window using rows-1 for the PTY1286 // resize applies a client size change to every window using rows-1 for the PTY
1181 // and VT screen, then resets the renderer shadow and repaints the composed1287 // and VT screen, then resets the renderer shadow and repaints the composed
1182 // client-sized frame (including the status row).1288 // client-sized frame (including the status row).
1289+func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {
1290+ if copyActive || !s.CursorVisible() {
1291+ return cursorOut{hidden: true}
1292+ }
1293+ style, ok := s.CursorStyle()
1294+ if !ok {
1295+ style = 5
1296+ }
1297+ return cursorOut{row: s.CursorRow(), col: s.CursorCol(), style: style, hasStyle: true}
1298+}
1299+
1300+func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
1301+ changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
1302+ if !changed {
1303+ return nil
1304+ }
1305+ prev := ac.lastCursor
1306+ ac.lastCursor = desired
1307+ ac.lastCursor.valid = true
1308+ if desired.hidden {
1309+ return []byte("\x1b[?25l")
1310+ }
1311+ var b []byte
1312+ b = append(b, "\x1b["...)
1313+ b = strconv.AppendInt(b, int64(desired.row+1), 10)
1314+ b = append(b, ';')
1315+ b = strconv.AppendInt(b, int64(desired.col+1), 10)
1316+ b = append(b, 'H')
1317+ if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
1318+ b = append(b, "\x1b["...)
1319+ b = strconv.AppendInt(b, int64(desired.style), 10)
1320+ b = append(b, " q"...)
1321+ }
1322+ b = append(b, "\x1b[?25h"...)
1323+ return b
1324+}
1325+
1183 func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) {1326 func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) {
1184 width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height1327 width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height
1185 frame := renderer.NewFrame(width, screenRows+1)1328 frame := renderer.NewFrame(width, screenRows+1)
M internal/usecase/daemon/daemon_test.go +134 -0
@@ -1781,3 +1781,137 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
17811781 cancel()
17821782 d.sessWg.Wait()
17831783 }
1784+
1785+func mustOutputData(t *testing.T, sends chan ports.Frame) []byte {
1786+ t.Helper()
1787+ f := awaitFrame(t, sends, ports.MsgOutput)
1788+ out, err := ports.UnmarshalOutput(f.Payload)
1789+ require.NoError(t, err)
1790+ return out.Data
1791+}
1792+
1793+func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) {
1794+ writes := make(chan []byte, 4)
1795+ p, _ := newBlockingPTYWithWrites(t, writes)
1796+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1797+ win := sess.windows[0]
1798+ win.screen.Write([]byte("live"))
1799+
1800+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
1801+ mustOutputData(t, sends)
1802+ require.NotNil(t, ac.copyMode)
1803+ require.Equal(t, 19, ac.copyMode.Cursor)
1804+
1805+ d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
1806+ mustOutputData(t, sends)
1807+ require.Nil(t, ac.copyMode)
1808+
1809+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))
1810+ mustOutputData(t, sends)
1811+ mustOutputData(t, sends)
1812+ require.Nil(t, ac.copyMode, "q after wheel in same input must be routed after copy mode is entered")
1813+ select {
1814+ case got := <-writes:
1815+ t.Fatalf("mouse/copy input forwarded to PTY: %q", got)
1816+ default:
1817+ }
1818+}
1819+
1820+func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
1821+ writes := make(chan []byte, 2)
1822+ p, _ := newBlockingPTYWithWrites(t, writes)
1823+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1824+ sess.windows[0].screen.Write([]byte("\x1b[?1049h"))
1825+
1826+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
1827+ d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
1828+
1829+ require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)
1830+ require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)
1831+ require.Nil(t, ac.copyMode)
1832+}
1833+
1834+func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
1835+ writes := make(chan []byte, 4)
1836+ p, _ := newBlockingPTYWithWrites(t, writes)
1837+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1838+
1839+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
1840+ select {
1841+ case got := <-writes:
1842+ t.Fatalf("press without child mouse mode forwarded: %q", got)
1843+ default:
1844+ }
1845+
1846+ sess.windows[0].screen.Write([]byte("\x1b[?1000h"))
1847+ raw := []byte("\x1b[<0;2;3M")
1848+ d.handleInput(sess, ac, raw)
1849+ select {
1850+ case got := <-writes:
1851+ t.Fatalf("SGR report forwarded to child without SGR mode: %q", got)
1852+ default:
1853+ }
1854+
1855+ sess.windows[0].screen.Write([]byte("\x1b[?1006h"))
1856+ d.handleInput(sess, ac, raw)
1857+ require.Equal(t, raw, <-writes)
1858+
1859+ d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))
1860+ select {
1861+ case got := <-writes:
1862+ t.Fatalf("status-row mouse report forwarded: %q", got)
1863+ default:
1864+ }
1865+}
1866+
1867+func TestMouseSplitReportPreservesOrder(t *testing.T) {
1868+ writes := make(chan []byte, 2)
1869+ p, _ := newBlockingPTYWithWrites(t, writes)
1870+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1871+ sess.windows[0].screen.Write([]byte("live"))
1872+
1873+ d.handleInput(sess, ac, []byte("\x1b[<64;"))
1874+ d.handleInput(sess, ac, []byte("1;1Mq"))
1875+
1876+ mustOutputData(t, sends)
1877+ mustOutputData(t, sends)
1878+ require.Nil(t, ac.copyMode)
1879+ select {
1880+ case got := <-writes:
1881+ t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)
1882+ default:
1883+ }
1884+}
1885+
1886+func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
1887+ p, _ := newBlockingPTY(t)
1888+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1889+ win := sess.windows[0]
1890+ win.screen.Write([]byte("A"))
1891+
1892+ d.paint(sess, ac, true)
1893+ data := mustOutputData(t, sends)
1894+ require.Contains(t, string(data), "\x1b[5 q")
1895+ require.Contains(t, string(data), "\x1b[?25h")
1896+
1897+ win.screen.Write([]byte("\x1b[2;3H"))
1898+ d.paint(sess, ac, false)
1899+ data = mustOutputData(t, sends)
1900+ require.Contains(t, string(data), "\x1b[2;3H")
1901+ require.Contains(t, string(data), "\x1b[?25h")
1902+
1903+ win.screen.Write([]byte("\x1b[?25l"))
1904+ d.paint(sess, ac, false)
1905+ data = mustOutputData(t, sends)
1906+ require.Contains(t, string(data), "\x1b[?25l")
1907+}
1908+
1909+func TestCursorTailHidesInCopyMode(t *testing.T) {
1910+ p, _ := newBlockingPTY(t)
1911+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1912+ sess.windows[0].screen.Write([]byte("live"))
1913+
1914+ d.enterCopyMode(sess, ac)
1915+ data := mustOutputData(t, sends)
1916+ require.Contains(t, string(data), "\x1b[?25l")
1917+}
@@ -1781,3 +1781,137 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
1781 cancel()1781 cancel()
1782 d.sessWg.Wait()1782 d.sessWg.Wait()
1783 }1783 }
1784+
1785+func mustOutputData(t *testing.T, sends chan ports.Frame) []byte {
1786+ t.Helper()
1787+ f := awaitFrame(t, sends, ports.MsgOutput)
1788+ out, err := ports.UnmarshalOutput(f.Payload)
1789+ require.NoError(t, err)
1790+ return out.Data
1791+}
1792+
1793+func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) {
1794+ writes := make(chan []byte, 4)
1795+ p, _ := newBlockingPTYWithWrites(t, writes)
1796+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1797+ win := sess.windows[0]
1798+ win.screen.Write([]byte("live"))
1799+
1800+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
1801+ mustOutputData(t, sends)
1802+ require.NotNil(t, ac.copyMode)
1803+ require.Equal(t, 19, ac.copyMode.Cursor)
1804+
1805+ d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
1806+ mustOutputData(t, sends)
1807+ require.Nil(t, ac.copyMode)
1808+
1809+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))
1810+ mustOutputData(t, sends)
1811+ mustOutputData(t, sends)
1812+ require.Nil(t, ac.copyMode, "q after wheel in same input must be routed after copy mode is entered")
1813+ select {
1814+ case got := <-writes:
1815+ t.Fatalf("mouse/copy input forwarded to PTY: %q", got)
1816+ default:
1817+ }
1818+}
1819+
1820+func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
1821+ writes := make(chan []byte, 2)
1822+ p, _ := newBlockingPTYWithWrites(t, writes)
1823+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1824+ sess.windows[0].screen.Write([]byte("\x1b[?1049h"))
1825+
1826+ d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
1827+ d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
1828+
1829+ require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)
1830+ require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)
1831+ require.Nil(t, ac.copyMode)
1832+}
1833+
1834+func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
1835+ writes := make(chan []byte, 4)
1836+ p, _ := newBlockingPTYWithWrites(t, writes)
1837+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1838+
1839+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
1840+ select {
1841+ case got := <-writes:
1842+ t.Fatalf("press without child mouse mode forwarded: %q", got)
1843+ default:
1844+ }
1845+
1846+ sess.windows[0].screen.Write([]byte("\x1b[?1000h"))
1847+ raw := []byte("\x1b[<0;2;3M")
1848+ d.handleInput(sess, ac, raw)
1849+ select {
1850+ case got := <-writes:
1851+ t.Fatalf("SGR report forwarded to child without SGR mode: %q", got)
1852+ default:
1853+ }
1854+
1855+ sess.windows[0].screen.Write([]byte("\x1b[?1006h"))
1856+ d.handleInput(sess, ac, raw)
1857+ require.Equal(t, raw, <-writes)
1858+
1859+ d.handleInput(sess, ac, []byte("\x1b[<0;1;24M"))
1860+ select {
1861+ case got := <-writes:
1862+ t.Fatalf("status-row mouse report forwarded: %q", got)
1863+ default:
1864+ }
1865+}
1866+
1867+func TestMouseSplitReportPreservesOrder(t *testing.T) {
1868+ writes := make(chan []byte, 2)
1869+ p, _ := newBlockingPTYWithWrites(t, writes)
1870+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1871+ sess.windows[0].screen.Write([]byte("live"))
1872+
1873+ d.handleInput(sess, ac, []byte("\x1b[<64;"))
1874+ d.handleInput(sess, ac, []byte("1;1Mq"))
1875+
1876+ mustOutputData(t, sends)
1877+ mustOutputData(t, sends)
1878+ require.Nil(t, ac.copyMode)
1879+ select {
1880+ case got := <-writes:
1881+ t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)
1882+ default:
1883+ }
1884+}
1885+
1886+func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
1887+ p, _ := newBlockingPTY(t)
1888+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1889+ win := sess.windows[0]
1890+ win.screen.Write([]byte("A"))
1891+
1892+ d.paint(sess, ac, true)
1893+ data := mustOutputData(t, sends)
1894+ require.Contains(t, string(data), "\x1b[5 q")
1895+ require.Contains(t, string(data), "\x1b[?25h")
1896+
1897+ win.screen.Write([]byte("\x1b[2;3H"))
1898+ d.paint(sess, ac, false)
1899+ data = mustOutputData(t, sends)
1900+ require.Contains(t, string(data), "\x1b[2;3H")
1901+ require.Contains(t, string(data), "\x1b[?25h")
1902+
1903+ win.screen.Write([]byte("\x1b[?25l"))
1904+ d.paint(sess, ac, false)
1905+ data = mustOutputData(t, sends)
1906+ require.Contains(t, string(data), "\x1b[?25l")
1907+}
1908+
1909+func TestCursorTailHidesInCopyMode(t *testing.T) {
1910+ p, _ := newBlockingPTY(t)
1911+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1912+ sess.windows[0].screen.Write([]byte("live"))
1913+
1914+ d.enterCopyMode(sess, ac)
1915+ data := mustOutputData(t, sends)
1916+ require.Contains(t, string(data), "\x1b[?25l")
1917+}
A internal/usecase/mouse/mouse.go +186 -0
@@ -0,0 +1,186 @@
1+package mouse
2+
3+import (
4+ "bytes"
5+ "strconv"
6+)
7+
8+// Type describes the kind of SGR mouse event.
9+type Type int
10+
11+const (
12+ Press Type = iota
13+ Release
14+ Motion
15+)
16+
17+// Button describes the button encoded by an SGR mouse event.
18+type Button int
19+
20+const (
21+ Left Button = iota
22+ Middle
23+ Right
24+ WheelUp
25+ WheelDown
26+ Other
27+)
28+
29+// Event is a decoded SGR mouse report.
30+type Event struct {
31+ Type Type
32+ Button Button
33+ Col int
34+ Row int
35+ Raw []byte
36+}
37+
38+// Scanner decodes SGR mouse reports from a byte stream while preserving the
39+// ordering of decoded mouse events and ordinary byte segments.
40+type Scanner struct {
41+ pending []byte
42+}
43+
44+// Scan decodes SGR mouse reports of the form ESC [ < Cb ; Cx ; Cy M/m.
45+// Non-mouse bytes are emitted through onBytes in their original order. Only
46+// partial reports after a complete ESC[< introducer are buffered between calls;
47+// a bare ESC or ESC[ at the end of data is passed through unchanged.
48+func (s *Scanner) Scan(data []byte, onMouse func(Event), onBytes func([]byte)) {
49+ if len(s.pending) > 0 {
50+ combined := make([]byte, 0, len(s.pending)+len(data))
51+ combined = append(combined, s.pending...)
52+ combined = append(combined, data...)
53+ data = combined
54+ s.pending = nil
55+ }
56+
57+ byteStart := 0
58+ for i := 0; i < len(data); i++ {
59+ if data[i] != '\x1b' {
60+ continue
61+ }
62+
63+ remaining := len(data) - i
64+ if remaining < 3 {
65+ continue
66+ }
67+ if data[i+1] != '[' || data[i+2] != '<' {
68+ continue
69+ }
70+
71+ if byteStart < i {
72+ onBytes(data[byteStart:i])
73+ }
74+
75+ raw, ev, complete, ok := parseSGR(data[i:])
76+ if !complete {
77+ s.pending = append(s.pending, data[i:]...)
78+ return
79+ }
80+ if !ok {
81+ onBytes(raw)
82+ i += len(raw) - 1
83+ byteStart = i + 1
84+ continue
85+ }
86+
87+ onMouse(ev)
88+ i += len(raw) - 1
89+ byteStart = i + 1
90+ }
91+
92+ if byteStart < len(data) {
93+ onBytes(data[byteStart:])
94+ }
95+}
96+
97+func parseSGR(data []byte) (raw []byte, ev Event, complete bool, ok bool) {
98+ end := -1
99+ for i := 3; i < len(data); i++ {
100+ switch data[i] {
101+ case 'M', 'm':
102+ end = i
103+ goto found
104+ case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ';':
105+ continue
106+ default:
107+ return data[:i+1], Event{}, true, false
108+ }
109+ }
110+found:
111+ if end == -1 {
112+ return nil, Event{}, false, false
113+ }
114+
115+ raw = data[:end+1]
116+ parts := bytes.Split(data[3:end], []byte(";"))
117+ if len(parts) != 3 {
118+ return raw, Event{}, true, false
119+ }
120+ cb, ok := atoiBytes(parts[0])
121+ if !ok {
122+ return raw, Event{}, true, false
123+ }
124+ cx, ok := atoiBytes(parts[1])
125+ if !ok {
126+ return raw, Event{}, true, false
127+ }
128+ cy, ok := atoiBytes(parts[2])
129+ if !ok {
130+ return raw, Event{}, true, false
131+ }
132+ if cx < 1 || cy < 1 {
133+ return raw, Event{}, true, false
134+ }
135+
136+ ev = Event{
137+ Type: eventType(cb, data[end]),
138+ Button: eventButton(cb),
139+ Col: cx - 1,
140+ Row: cy - 1,
141+ Raw: append([]byte(nil), raw...),
142+ }
143+ return raw, ev, true, true
144+}
145+
146+func atoiBytes(b []byte) (int, bool) {
147+ if len(b) == 0 {
148+ return 0, false
149+ }
150+ v, err := strconv.Atoi(string(b))
151+ return v, err == nil
152+}
153+
154+func eventType(cb int, final byte) Type {
155+ if final == 'm' {
156+ return Release
157+ }
158+ if cb&32 != 0 {
159+ return Motion
160+ }
161+ return Press
162+}
163+
164+func eventButton(cb int) Button {
165+ if cb&64 != 0 {
166+ switch cb {
167+ case 64:
168+ return WheelUp
169+ case 65:
170+ return WheelDown
171+ default:
172+ return Other
173+ }
174+ }
175+
176+ switch cb & 3 {
177+ case 0:
178+ return Left
179+ case 1:
180+ return Middle
181+ case 2:
182+ return Right
183+ default:
184+ return Other
185+ }
186+}
@@ -0,0 +1,186 @@
1+package mouse
2+
3+import (
4+ "bytes"
5+ "strconv"
6+)
7+
8+// Type describes the kind of SGR mouse event.
9+type Type int
10+
11+const (
12+ Press Type = iota
13+ Release
14+ Motion
15+)
16+
17+// Button describes the button encoded by an SGR mouse event.
18+type Button int
19+
20+const (
21+ Left Button = iota
22+ Middle
23+ Right
24+ WheelUp
25+ WheelDown
26+ Other
27+)
28+
29+// Event is a decoded SGR mouse report.
30+type Event struct {
31+ Type Type
32+ Button Button
33+ Col int
34+ Row int
35+ Raw []byte
36+}
37+
38+// Scanner decodes SGR mouse reports from a byte stream while preserving the
39+// ordering of decoded mouse events and ordinary byte segments.
40+type Scanner struct {
41+ pending []byte
42+}
43+
44+// Scan decodes SGR mouse reports of the form ESC [ < Cb ; Cx ; Cy M/m.
45+// Non-mouse bytes are emitted through onBytes in their original order. Only
46+// partial reports after a complete ESC[< introducer are buffered between calls;
47+// a bare ESC or ESC[ at the end of data is passed through unchanged.
48+func (s *Scanner) Scan(data []byte, onMouse func(Event), onBytes func([]byte)) {
49+ if len(s.pending) > 0 {
50+ combined := make([]byte, 0, len(s.pending)+len(data))
51+ combined = append(combined, s.pending...)
52+ combined = append(combined, data...)
53+ data = combined
54+ s.pending = nil
55+ }
56+
57+ byteStart := 0
58+ for i := 0; i < len(data); i++ {
59+ if data[i] != '\x1b' {
60+ continue
61+ }
62+
63+ remaining := len(data) - i
64+ if remaining < 3 {
65+ continue
66+ }
67+ if data[i+1] != '[' || data[i+2] != '<' {
68+ continue
69+ }
70+
71+ if byteStart < i {
72+ onBytes(data[byteStart:i])
73+ }
74+
75+ raw, ev, complete, ok := parseSGR(data[i:])
76+ if !complete {
77+ s.pending = append(s.pending, data[i:]...)
78+ return
79+ }
80+ if !ok {
81+ onBytes(raw)
82+ i += len(raw) - 1
83+ byteStart = i + 1
84+ continue
85+ }
86+
87+ onMouse(ev)
88+ i += len(raw) - 1
89+ byteStart = i + 1
90+ }
91+
92+ if byteStart < len(data) {
93+ onBytes(data[byteStart:])
94+ }
95+}
96+
97+func parseSGR(data []byte) (raw []byte, ev Event, complete bool, ok bool) {
98+ end := -1
99+ for i := 3; i < len(data); i++ {
100+ switch data[i] {
101+ case 'M', 'm':
102+ end = i
103+ goto found
104+ case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ';':
105+ continue
106+ default:
107+ return data[:i+1], Event{}, true, false
108+ }
109+ }
110+found:
111+ if end == -1 {
112+ return nil, Event{}, false, false
113+ }
114+
115+ raw = data[:end+1]
116+ parts := bytes.Split(data[3:end], []byte(";"))
117+ if len(parts) != 3 {
118+ return raw, Event{}, true, false
119+ }
120+ cb, ok := atoiBytes(parts[0])
121+ if !ok {
122+ return raw, Event{}, true, false
123+ }
124+ cx, ok := atoiBytes(parts[1])
125+ if !ok {
126+ return raw, Event{}, true, false
127+ }
128+ cy, ok := atoiBytes(parts[2])
129+ if !ok {
130+ return raw, Event{}, true, false
131+ }
132+ if cx < 1 || cy < 1 {
133+ return raw, Event{}, true, false
134+ }
135+
136+ ev = Event{
137+ Type: eventType(cb, data[end]),
138+ Button: eventButton(cb),
139+ Col: cx - 1,
140+ Row: cy - 1,
141+ Raw: append([]byte(nil), raw...),
142+ }
143+ return raw, ev, true, true
144+}
145+
146+func atoiBytes(b []byte) (int, bool) {
147+ if len(b) == 0 {
148+ return 0, false
149+ }
150+ v, err := strconv.Atoi(string(b))
151+ return v, err == nil
152+}
153+
154+func eventType(cb int, final byte) Type {
155+ if final == 'm' {
156+ return Release
157+ }
158+ if cb&32 != 0 {
159+ return Motion
160+ }
161+ return Press
162+}
163+
164+func eventButton(cb int) Button {
165+ if cb&64 != 0 {
166+ switch cb {
167+ case 64:
168+ return WheelUp
169+ case 65:
170+ return WheelDown
171+ default:
172+ return Other
173+ }
174+ }
175+
176+ switch cb & 3 {
177+ case 0:
178+ return Left
179+ case 1:
180+ return Middle
181+ case 2:
182+ return Right
183+ default:
184+ return Other
185+ }
186+}
A internal/usecase/mouse/mouse_test.go +145 -0
@@ -0,0 +1,145 @@
1+package mouse
2+
3+import (
4+ "bytes"
5+ "reflect"
6+ "testing"
7+)
8+
9+type scanToken struct {
10+ kind string
11+ data []byte
12+ event Event
13+}
14+
15+func collectScan(s *Scanner, chunks ...[]byte) []scanToken {
16+ var tokens []scanToken
17+ for _, chunk := range chunks {
18+ s.Scan(chunk, func(ev Event) {
19+ tokens = append(tokens, scanToken{kind: "mouse", event: ev})
20+ }, func(b []byte) {
21+ tokens = append(tokens, scanToken{kind: "bytes", data: append([]byte(nil), b...)})
22+ })
23+ }
24+ return tokens
25+}
26+
27+func TestScanDecodesWheelClickAndDrag(t *testing.T) {
28+ tests := []struct {
29+ name string
30+ in []byte
31+ want Event
32+ }{
33+ {
34+ name: "wheel up",
35+ in: []byte("\x1b[<64;10;20M"),
36+ want: Event{Type: Press, Button: WheelUp, Col: 9, Row: 19, Raw: []byte("\x1b[<64;10;20M")},
37+ },
38+ {
39+ name: "wheel down",
40+ in: []byte("\x1b[<65;3;4M"),
41+ want: Event{Type: Press, Button: WheelDown, Col: 2, Row: 3, Raw: []byte("\x1b[<65;3;4M")},
42+ },
43+ {
44+ name: "left press",
45+ in: []byte("\x1b[<0;1;2M"),
46+ want: Event{Type: Press, Button: Left, Col: 0, Row: 1, Raw: []byte("\x1b[<0;1;2M")},
47+ },
48+ {
49+ name: "left release",
50+ in: []byte("\x1b[<0;1;2m"),
51+ want: Event{Type: Release, Button: Left, Col: 0, Row: 1, Raw: []byte("\x1b[<0;1;2m")},
52+ },
53+ {
54+ name: "right motion",
55+ in: []byte("\x1b[<34;7;8M"),
56+ want: Event{Type: Motion, Button: Right, Col: 6, Row: 7, Raw: []byte("\x1b[<34;7;8M")},
57+ },
58+ }
59+
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ tokens := collectScan(&Scanner{}, tt.in)
63+ if len(tokens) != 1 || tokens[0].kind != "mouse" {
64+ t.Fatalf("tokens = %#v, want one mouse token", tokens)
65+ }
66+ if !reflect.DeepEqual(tokens[0].event, tt.want) {
67+ t.Fatalf("event = %#v, want %#v", tokens[0].event, tt.want)
68+ }
69+ })
70+ }
71+}
72+
73+func TestScanCoalescedEvents(t *testing.T) {
74+ tokens := collectScan(&Scanner{}, []byte("\x1b[<0;1;1M\x1b[<65;2;3M"))
75+ if len(tokens) != 2 || tokens[0].kind != "mouse" || tokens[1].kind != "mouse" {
76+ t.Fatalf("tokens = %#v, want two mouse tokens", tokens)
77+ }
78+ if tokens[0].event.Button != Left || tokens[1].event.Button != WheelDown {
79+ t.Fatalf("buttons = %v, %v; want Left, WheelDown", tokens[0].event.Button, tokens[1].event.Button)
80+ }
81+}
82+
83+func TestScanBuffersSplitReportAfterIntroducer(t *testing.T) {
84+ var s Scanner
85+ first := collectScan(&s, []byte("\x1b[<64;"))
86+ if len(first) != 0 {
87+ t.Fatalf("first scan tokens = %#v, want none while pending", first)
88+ }
89+ second := collectScan(&s, []byte("5;6M"))
90+ if len(second) != 1 || second[0].kind != "mouse" {
91+ t.Fatalf("second scan tokens = %#v, want one mouse token", second)
92+ }
93+ want := Event{Type: Press, Button: WheelUp, Col: 4, Row: 5, Raw: []byte("\x1b[<64;5;6M")}
94+ if !reflect.DeepEqual(second[0].event, want) {
95+ t.Fatalf("event = %#v, want %#v", second[0].event, want)
96+ }
97+}
98+
99+func TestScanPreservesInterleavedOrdering(t *testing.T) {
100+ tokens := collectScan(&Scanner{}, []byte("a\x1b[<0;1;1Mb\x1b[<65;2;2Mc"))
101+ want := []scanToken{
102+ {kind: "bytes", data: []byte("a")},
103+ {kind: "mouse", event: Event{Type: Press, Button: Left, Col: 0, Row: 0, Raw: []byte("\x1b[<0;1;1M")}},
104+ {kind: "bytes", data: []byte("b")},
105+ {kind: "mouse", event: Event{Type: Press, Button: WheelDown, Col: 1, Row: 1, Raw: []byte("\x1b[<65;2;2M")}},
106+ {kind: "bytes", data: []byte("c")},
107+ }
108+ if !reflect.DeepEqual(tokens, want) {
109+ t.Fatalf("tokens = %#v, want %#v", tokens, want)
110+ }
111+}
112+
113+func TestScanMalformedBailsToBytes(t *testing.T) {
114+ tests := [][]byte{
115+ []byte("\x1b[<x"),
116+ []byte("\x1b[<0;;1M"),
117+ []byte("\x1b[<0;0;1M"),
118+ []byte("\x1b[<0;1M"),
119+ }
120+ for _, in := range tests {
121+ t.Run(string(bytes.ReplaceAll(in, []byte("\x1b"), []byte("ESC"))), func(t *testing.T) {
122+ tokens := collectScan(&Scanner{}, in)
123+ if len(tokens) != 1 || tokens[0].kind != "bytes" || !bytes.Equal(tokens[0].data, in) {
124+ t.Fatalf("tokens = %#v, want one byte token %q", tokens, in)
125+ }
126+ })
127+ }
128+}
129+
130+func TestScanPassesThroughBareESCAndESCBracket(t *testing.T) {
131+ tests := [][]byte{
132+ []byte("\x1b"),
133+ []byte("\x1b["),
134+ []byte("x\x1b"),
135+ []byte("x\x1b["),
136+ }
137+ for _, in := range tests {
138+ t.Run(string(bytes.ReplaceAll(in, []byte("\x1b"), []byte("ESC"))), func(t *testing.T) {
139+ tokens := collectScan(&Scanner{}, in)
140+ if len(tokens) != 1 || tokens[0].kind != "bytes" || !bytes.Equal(tokens[0].data, in) {
141+ t.Fatalf("tokens = %#v, want passthrough %q", tokens, in)
142+ }
143+ })
144+ }
145+}
@@ -0,0 +1,145 @@
1+package mouse
2+
3+import (
4+ "bytes"
5+ "reflect"
6+ "testing"
7+)
8+
9+type scanToken struct {
10+ kind string
11+ data []byte
12+ event Event
13+}
14+
15+func collectScan(s *Scanner, chunks ...[]byte) []scanToken {
16+ var tokens []scanToken
17+ for _, chunk := range chunks {
18+ s.Scan(chunk, func(ev Event) {
19+ tokens = append(tokens, scanToken{kind: "mouse", event: ev})
20+ }, func(b []byte) {
21+ tokens = append(tokens, scanToken{kind: "bytes", data: append([]byte(nil), b...)})
22+ })
23+ }
24+ return tokens
25+}
26+
27+func TestScanDecodesWheelClickAndDrag(t *testing.T) {
28+ tests := []struct {
29+ name string
30+ in []byte
31+ want Event
32+ }{
33+ {
34+ name: "wheel up",
35+ in: []byte("\x1b[<64;10;20M"),
36+ want: Event{Type: Press, Button: WheelUp, Col: 9, Row: 19, Raw: []byte("\x1b[<64;10;20M")},
37+ },
38+ {
39+ name: "wheel down",
40+ in: []byte("\x1b[<65;3;4M"),
41+ want: Event{Type: Press, Button: WheelDown, Col: 2, Row: 3, Raw: []byte("\x1b[<65;3;4M")},
42+ },
43+ {
44+ name: "left press",
45+ in: []byte("\x1b[<0;1;2M"),
46+ want: Event{Type: Press, Button: Left, Col: 0, Row: 1, Raw: []byte("\x1b[<0;1;2M")},
47+ },
48+ {
49+ name: "left release",
50+ in: []byte("\x1b[<0;1;2m"),
51+ want: Event{Type: Release, Button: Left, Col: 0, Row: 1, Raw: []byte("\x1b[<0;1;2m")},
52+ },
53+ {
54+ name: "right motion",
55+ in: []byte("\x1b[<34;7;8M"),
56+ want: Event{Type: Motion, Button: Right, Col: 6, Row: 7, Raw: []byte("\x1b[<34;7;8M")},
57+ },
58+ }
59+
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ tokens := collectScan(&Scanner{}, tt.in)
63+ if len(tokens) != 1 || tokens[0].kind != "mouse" {
64+ t.Fatalf("tokens = %#v, want one mouse token", tokens)
65+ }
66+ if !reflect.DeepEqual(tokens[0].event, tt.want) {
67+ t.Fatalf("event = %#v, want %#v", tokens[0].event, tt.want)
68+ }
69+ })
70+ }
71+}
72+
73+func TestScanCoalescedEvents(t *testing.T) {
74+ tokens := collectScan(&Scanner{}, []byte("\x1b[<0;1;1M\x1b[<65;2;3M"))
75+ if len(tokens) != 2 || tokens[0].kind != "mouse" || tokens[1].kind != "mouse" {
76+ t.Fatalf("tokens = %#v, want two mouse tokens", tokens)
77+ }
78+ if tokens[0].event.Button != Left || tokens[1].event.Button != WheelDown {
79+ t.Fatalf("buttons = %v, %v; want Left, WheelDown", tokens[0].event.Button, tokens[1].event.Button)
80+ }
81+}
82+
83+func TestScanBuffersSplitReportAfterIntroducer(t *testing.T) {
84+ var s Scanner
85+ first := collectScan(&s, []byte("\x1b[<64;"))
86+ if len(first) != 0 {
87+ t.Fatalf("first scan tokens = %#v, want none while pending", first)
88+ }
89+ second := collectScan(&s, []byte("5;6M"))
90+ if len(second) != 1 || second[0].kind != "mouse" {
91+ t.Fatalf("second scan tokens = %#v, want one mouse token", second)
92+ }
93+ want := Event{Type: Press, Button: WheelUp, Col: 4, Row: 5, Raw: []byte("\x1b[<64;5;6M")}
94+ if !reflect.DeepEqual(second[0].event, want) {
95+ t.Fatalf("event = %#v, want %#v", second[0].event, want)
96+ }
97+}
98+
99+func TestScanPreservesInterleavedOrdering(t *testing.T) {
100+ tokens := collectScan(&Scanner{}, []byte("a\x1b[<0;1;1Mb\x1b[<65;2;2Mc"))
101+ want := []scanToken{
102+ {kind: "bytes", data: []byte("a")},
103+ {kind: "mouse", event: Event{Type: Press, Button: Left, Col: 0, Row: 0, Raw: []byte("\x1b[<0;1;1M")}},
104+ {kind: "bytes", data: []byte("b")},
105+ {kind: "mouse", event: Event{Type: Press, Button: WheelDown, Col: 1, Row: 1, Raw: []byte("\x1b[<65;2;2M")}},
106+ {kind: "bytes", data: []byte("c")},
107+ }
108+ if !reflect.DeepEqual(tokens, want) {
109+ t.Fatalf("tokens = %#v, want %#v", tokens, want)
110+ }
111+}
112+
113+func TestScanMalformedBailsToBytes(t *testing.T) {
114+ tests := [][]byte{
115+ []byte("\x1b[<x"),
116+ []byte("\x1b[<0;;1M"),
117+ []byte("\x1b[<0;0;1M"),
118+ []byte("\x1b[<0;1M"),
119+ }
120+ for _, in := range tests {
121+ t.Run(string(bytes.ReplaceAll(in, []byte("\x1b"), []byte("ESC"))), func(t *testing.T) {
122+ tokens := collectScan(&Scanner{}, in)
123+ if len(tokens) != 1 || tokens[0].kind != "bytes" || !bytes.Equal(tokens[0].data, in) {
124+ t.Fatalf("tokens = %#v, want one byte token %q", tokens, in)
125+ }
126+ })
127+ }
128+}
129+
130+func TestScanPassesThroughBareESCAndESCBracket(t *testing.T) {
131+ tests := [][]byte{
132+ []byte("\x1b"),
133+ []byte("\x1b["),
134+ []byte("x\x1b"),
135+ []byte("x\x1b["),
136+ }
137+ for _, in := range tests {
138+ t.Run(string(bytes.ReplaceAll(in, []byte("\x1b"), []byte("ESC"))), func(t *testing.T) {
139+ tokens := collectScan(&Scanner{}, in)
140+ if len(tokens) != 1 || tokens[0].kind != "bytes" || !bytes.Equal(tokens[0].data, in) {
141+ t.Fatalf("tokens = %#v, want passthrough %q", tokens, in)
142+ }
143+ })
144+ }
145+}
M pkg/vt/screen.go +61 -6
@@ -49,13 +49,19 @@ type Screen struct {
4949 savedCursor cursorState
5050 alternate *screenState
5151 syncUpdateActive bool
52+ cursorVisible bool
53+ cursorStyle int
54+ cursorStyleSet bool
55+ mouseMode int
56+ mouseSGR bool
5257 }
5358
5459 func NewScreen(width, height int) *Screen {
5560 s := &Screen{
56- Frame: renderer.NewFrame(width, height),
57- Style: renderer.DefaultStyle(),
58- damage: []renderer.Damage{renderer.FullRedraw()},
61+ Frame: renderer.NewFrame(width, height),
62+ Style: renderer.DefaultStyle(),
63+ damage: []renderer.Damage{renderer.FullRedraw()},
64+ cursorVisible: true,
5965 }
6066 s.resetScrollRegion()
6167 return s
@@ -71,6 +77,11 @@ func (s *Screen) Resize(width, height int) {
7177 s.escapeBuf = s.escapeBuf[:0]
7278 s.savedCursor = cursorState{}
7379 s.alternate = nil
80+ s.cursorVisible = true
81+ s.cursorStyle = 0
82+ s.cursorStyleSet = false
83+ s.mouseMode = 0
84+ s.mouseSGR = false
7485 s.resetScrollRegion()
7586 s.fullRedraw()
7687 }
@@ -89,6 +100,15 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
89100 // and never sends the matching end sequence.
90101 func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }
91102
103+func (s *Screen) CursorRow() int { return s.Row }
104+func (s *Screen) CursorCol() int { return s.Col }
105+func (s *Screen) CursorVisible() bool {
106+ return s.cursorVisible
107+}
108+func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet }
109+func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }
110+func (s *Screen) AltScreenActive() bool { return s.alternate != nil }
111+
92112 func (s *Screen) Write(data []byte) {
93113 if len(s.escapeBuf) > 0 {
94114 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
@@ -645,9 +665,10 @@ func (s *Screen) applyCSI(params string, cmd byte) {
645665 parts := parseCSIInts(params)
646666 switch cmd {
647667 case 'c':
648- if params == "" || params == "0" {
668+ switch params {
669+ case "", "0":
649670 s.respond([]byte("\x1b[?6c"))
650- } else if params == ">" || params == ">0" {
671+ case ">", ">0":
651672 s.respond([]byte("\x1b[>0;0;0c"))
652673 }
653674 case 'n':
@@ -694,6 +715,8 @@ func (s *Screen) applyCSI(params string, cmd byte) {
694715 return
695716 }
696717 s.applySGR(params)
718+ case 'q':
719+ s.applyCursorStyle(params)
697720 case 'J':
698721 mode := 0
699722 if len(parts) > 0 {
@@ -770,6 +793,23 @@ func firstPositive(parts []int, fallback int) int {
770793 return parts[0]
771794 }
772795
796+func (s *Screen) applyCursorStyle(params string) {
797+ if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {
798+ return
799+ }
800+ styleParam := strings.TrimSuffix(params, " ")
801+ style := 0
802+ if styleParam != "" {
803+ v, err := strconv.Atoi(styleParam)
804+ if err != nil {
805+ return
806+ }
807+ style = v
808+ }
809+ s.cursorStyle = style
810+ s.cursorStyleSet = true
811+}
812+
773813 func (s *Screen) applySGR(params string) {
774814 if params == "" {
775815 s.Style = renderer.DefaultStyle()
@@ -860,6 +900,11 @@ func (s *Screen) reset() {
860900 s.escapeBuf = s.escapeBuf[:0]
861901 s.savedCursor = cursorState{}
862902 s.alternate = nil
903+ s.cursorVisible = true
904+ s.cursorStyle = 0
905+ s.cursorStyleSet = false
906+ s.mouseMode = 0
907+ s.mouseSGR = false
863908 s.resetScrollRegion()
864909 s.fullRedraw()
865910 }
@@ -910,7 +955,17 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
910955 }
911956 case 2026:
912957 s.syncUpdateActive = enabled
913- case 1, 25, 1000, 1002, 1003, 1004, 1005, 1006, 2004:
958+ case 25:
959+ s.cursorVisible = enabled
960+ case 1000, 1002, 1003:
961+ if enabled {
962+ s.mouseMode = mode
963+ } else if s.mouseMode == mode {
964+ s.mouseMode = 0
965+ }
966+ case 1006:
967+ s.mouseSGR = enabled
968+ case 1, 1004, 1005, 2004:
914969 // Trackable terminal modes that do not directly affect the current
915970 // cell model yet. Consuming them prevents mode bytes from leaking.
916971 continue
@@ -49,13 +49,19 @@ type Screen struct {
49 savedCursor cursorState49 savedCursor cursorState
50 alternate *screenState50 alternate *screenState
51 syncUpdateActive bool51 syncUpdateActive bool
52+ cursorVisible bool
53+ cursorStyle int
54+ cursorStyleSet bool
55+ mouseMode int
56+ mouseSGR bool
52 }57 }
53 58
54 func NewScreen(width, height int) *Screen {59 func NewScreen(width, height int) *Screen {
55 s := &Screen{60 s := &Screen{
56- Frame: renderer.NewFrame(width, height),61+ Frame: renderer.NewFrame(width, height),
57- Style: renderer.DefaultStyle(),62+ Style: renderer.DefaultStyle(),
58- damage: []renderer.Damage{renderer.FullRedraw()},63+ damage: []renderer.Damage{renderer.FullRedraw()},
64+ cursorVisible: true,
59 }65 }
60 s.resetScrollRegion()66 s.resetScrollRegion()
61 return s67 return s
@@ -71,6 +77,11 @@ func (s *Screen) Resize(width, height int) {
71 s.escapeBuf = s.escapeBuf[:0]77 s.escapeBuf = s.escapeBuf[:0]
72 s.savedCursor = cursorState{}78 s.savedCursor = cursorState{}
73 s.alternate = nil79 s.alternate = nil
80+ s.cursorVisible = true
81+ s.cursorStyle = 0
82+ s.cursorStyleSet = false
83+ s.mouseMode = 0
84+ s.mouseSGR = false
74 s.resetScrollRegion()85 s.resetScrollRegion()
75 s.fullRedraw()86 s.fullRedraw()
76 }87 }
@@ -89,6 +100,15 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
89 // and never sends the matching end sequence.100 // and never sends the matching end sequence.
90 func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }101 func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }
91 102
103+func (s *Screen) CursorRow() int { return s.Row }
104+func (s *Screen) CursorCol() int { return s.Col }
105+func (s *Screen) CursorVisible() bool {
106+ return s.cursorVisible
107+}
108+func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet }
109+func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }
110+func (s *Screen) AltScreenActive() bool { return s.alternate != nil }
111+
92 func (s *Screen) Write(data []byte) {112 func (s *Screen) Write(data []byte) {
93 if len(s.escapeBuf) > 0 {113 if len(s.escapeBuf) > 0 {
94 combined := make([]byte, 0, len(s.escapeBuf)+len(data))114 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
@@ -645,9 +665,10 @@ func (s *Screen) applyCSI(params string, cmd byte) {
645 parts := parseCSIInts(params)665 parts := parseCSIInts(params)
646 switch cmd {666 switch cmd {
647 case 'c':667 case 'c':
648- if params == "" || params == "0" {668+ switch params {
669+ case "", "0":
649 s.respond([]byte("\x1b[?6c"))670 s.respond([]byte("\x1b[?6c"))
650- } else if params == ">" || params == ">0" {671+ case ">", ">0":
651 s.respond([]byte("\x1b[>0;0;0c"))672 s.respond([]byte("\x1b[>0;0;0c"))
652 }673 }
653 case 'n':674 case 'n':
@@ -694,6 +715,8 @@ func (s *Screen) applyCSI(params string, cmd byte) {
694 return715 return
695 }716 }
696 s.applySGR(params)717 s.applySGR(params)
718+ case 'q':
719+ s.applyCursorStyle(params)
697 case 'J':720 case 'J':
698 mode := 0721 mode := 0
699 if len(parts) > 0 {722 if len(parts) > 0 {
@@ -770,6 +793,23 @@ func firstPositive(parts []int, fallback int) int {
770 return parts[0]793 return parts[0]
771 }794 }
772 795
796+func (s *Screen) applyCursorStyle(params string) {
797+ if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {
798+ return
799+ }
800+ styleParam := strings.TrimSuffix(params, " ")
801+ style := 0
802+ if styleParam != "" {
803+ v, err := strconv.Atoi(styleParam)
804+ if err != nil {
805+ return
806+ }
807+ style = v
808+ }
809+ s.cursorStyle = style
810+ s.cursorStyleSet = true
811+}
812+
773 func (s *Screen) applySGR(params string) {813 func (s *Screen) applySGR(params string) {
774 if params == "" {814 if params == "" {
775 s.Style = renderer.DefaultStyle()815 s.Style = renderer.DefaultStyle()
@@ -860,6 +900,11 @@ func (s *Screen) reset() {
860 s.escapeBuf = s.escapeBuf[:0]900 s.escapeBuf = s.escapeBuf[:0]
861 s.savedCursor = cursorState{}901 s.savedCursor = cursorState{}
862 s.alternate = nil902 s.alternate = nil
903+ s.cursorVisible = true
904+ s.cursorStyle = 0
905+ s.cursorStyleSet = false
906+ s.mouseMode = 0
907+ s.mouseSGR = false
863 s.resetScrollRegion()908 s.resetScrollRegion()
864 s.fullRedraw()909 s.fullRedraw()
865 }910 }
@@ -910,7 +955,17 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
910 }955 }
911 case 2026:956 case 2026:
912 s.syncUpdateActive = enabled957 s.syncUpdateActive = enabled
913- case 1, 25, 1000, 1002, 1003, 1004, 1005, 1006, 2004:958+ case 25:
959+ s.cursorVisible = enabled
960+ case 1000, 1002, 1003:
961+ if enabled {
962+ s.mouseMode = mode
963+ } else if s.mouseMode == mode {
964+ s.mouseMode = 0
965+ }
966+ case 1006:
967+ s.mouseSGR = enabled
968+ case 1, 1004, 1005, 2004:
914 // Trackable terminal modes that do not directly affect the current969 // Trackable terminal modes that do not directly affect the current
915 // cell model yet. Consuming them prevents mode bytes from leaking.970 // cell model yet. Consuming them prevents mode bytes from leaking.
916 continue971 continue
M pkg/vt/screen_test.go +105 -0
@@ -2204,3 +2204,108 @@ func TestForceSyncEnd(t *testing.T) {
22042204 t.Fatal("sync update mode should be inactive after forcing end")
22052205 }
22062206 }
2207+
2208+func TestScreenCursorAndMouseStateAccessors(t *testing.T) {
2209+ tests := []struct {
2210+ name string
2211+ seq string
2212+ check func(t *testing.T, s *Screen)
2213+ }{
2214+ {
2215+ name: "cursor position and visibility are exposed",
2216+ seq: "\x1b[2;3H\x1b[?25l",
2217+ check: func(t *testing.T, s *Screen) {
2218+ if s.CursorRow() != 1 || s.CursorCol() != 2 {
2219+ t.Fatalf("cursor = row %d col %d, want row 1 col 2", s.CursorRow(), s.CursorCol())
2220+ }
2221+ if s.CursorVisible() {
2222+ t.Fatal("cursor should be hidden")
2223+ }
2224+ s.Write([]byte("\x1b[?25h"))
2225+ if !s.CursorVisible() {
2226+ t.Fatal("cursor should be visible")
2227+ }
2228+ },
2229+ },
2230+ {
2231+ name: "mouse mode and SGR are exposed",
2232+ seq: "\x1b[?1002h\x1b[?1006h",
2233+ check: func(t *testing.T, s *Screen) {
2234+ mode, sgr := s.MouseMode()
2235+ if mode != 1002 || !sgr {
2236+ t.Fatalf("MouseMode() = (%d, %v), want (1002, true)", mode, sgr)
2237+ }
2238+ },
2239+ },
2240+ {
2241+ name: "reset restores cursor and mouse defaults",
2242+ seq: "\x1b[?25l\x1b[?1003h\x1b[?1006h\x1bc",
2243+ check: func(t *testing.T, s *Screen) {
2244+ mode, sgr := s.MouseMode()
2245+ if !s.CursorVisible() || mode != 0 || sgr {
2246+ t.Fatalf("after reset CursorVisible=%v MouseMode=(%d,%v), want true,(0,false)", s.CursorVisible(), mode, sgr)
2247+ }
2248+ },
2249+ },
2250+ }
2251+ for _, tt := range tests {
2252+ t.Run(tt.name, func(t *testing.T) {
2253+ s := NewScreen(10, 4)
2254+ s.Write([]byte(tt.seq))
2255+ tt.check(t, s)
2256+ })
2257+ }
2258+}
2259+
2260+func TestScreenMouseModeDisableInactiveIsNoOp(t *testing.T) {
2261+ s := NewScreen(10, 2)
2262+ s.Write([]byte("\x1b[?1002h"))
2263+ s.Write([]byte("\x1b[?1000l"))
2264+ mode, _ := s.MouseMode()
2265+ if mode != 1002 {
2266+ t.Fatalf("mouse mode after disabling inactive 1000 = %d, want 1002", mode)
2267+ }
2268+ s.Write([]byte("\x1b[?1002l"))
2269+ mode, _ = s.MouseMode()
2270+ if mode != 0 {
2271+ t.Fatalf("mouse mode after disabling active 1002 = %d, want 0", mode)
2272+ }
2273+}
2274+
2275+func TestScreenCursorStyleDECSCUSR(t *testing.T) {
2276+ tests := []struct {
2277+ name string
2278+ seq string
2279+ wantStyle int
2280+ wantSet bool
2281+ }{
2282+ {name: "explicit style", seq: "\x1b[5 q", wantStyle: 5, wantSet: true},
2283+ {name: "blank style parameter", seq: "\x1b[ q", wantStyle: 0, wantSet: true},
2284+ {name: "XTVERSION is ignored", seq: "\x1b[>0q", wantStyle: 0, wantSet: false},
2285+ }
2286+ for _, tt := range tests {
2287+ t.Run(tt.name, func(t *testing.T) {
2288+ s := NewScreen(10, 2)
2289+ s.Write([]byte(tt.seq))
2290+ style, set := s.CursorStyle()
2291+ if style != tt.wantStyle || set != tt.wantSet {
2292+ t.Fatalf("CursorStyle() = (%d, %v), want (%d, %v)", style, set, tt.wantStyle, tt.wantSet)
2293+ }
2294+ })
2295+ }
2296+}
2297+
2298+func TestScreenAltScreenActiveAccessor(t *testing.T) {
2299+ s := NewScreen(10, 2)
2300+ if s.AltScreenActive() {
2301+ t.Fatal("alt screen should start inactive")
2302+ }
2303+ s.Write([]byte("\x1b[?1049h"))
2304+ if !s.AltScreenActive() {
2305+ t.Fatal("alt screen should be active")
2306+ }
2307+ s.Write([]byte("\x1b[?1049l"))
2308+ if s.AltScreenActive() {
2309+ t.Fatal("alt screen should be inactive after exit")
2310+ }
2311+}
@@ -2204,3 +2204,108 @@ func TestForceSyncEnd(t *testing.T) {
2204 t.Fatal("sync update mode should be inactive after forcing end")2204 t.Fatal("sync update mode should be inactive after forcing end")
2205 }2205 }
2206 }2206 }
2207+
2208+func TestScreenCursorAndMouseStateAccessors(t *testing.T) {
2209+ tests := []struct {
2210+ name string
2211+ seq string
2212+ check func(t *testing.T, s *Screen)
2213+ }{
2214+ {
2215+ name: "cursor position and visibility are exposed",
2216+ seq: "\x1b[2;3H\x1b[?25l",
2217+ check: func(t *testing.T, s *Screen) {
2218+ if s.CursorRow() != 1 || s.CursorCol() != 2 {
2219+ t.Fatalf("cursor = row %d col %d, want row 1 col 2", s.CursorRow(), s.CursorCol())
2220+ }
2221+ if s.CursorVisible() {
2222+ t.Fatal("cursor should be hidden")
2223+ }
2224+ s.Write([]byte("\x1b[?25h"))
2225+ if !s.CursorVisible() {
2226+ t.Fatal("cursor should be visible")
2227+ }
2228+ },
2229+ },
2230+ {
2231+ name: "mouse mode and SGR are exposed",
2232+ seq: "\x1b[?1002h\x1b[?1006h",
2233+ check: func(t *testing.T, s *Screen) {
2234+ mode, sgr := s.MouseMode()
2235+ if mode != 1002 || !sgr {
2236+ t.Fatalf("MouseMode() = (%d, %v), want (1002, true)", mode, sgr)
2237+ }
2238+ },
2239+ },
2240+ {
2241+ name: "reset restores cursor and mouse defaults",
2242+ seq: "\x1b[?25l\x1b[?1003h\x1b[?1006h\x1bc",
2243+ check: func(t *testing.T, s *Screen) {
2244+ mode, sgr := s.MouseMode()
2245+ if !s.CursorVisible() || mode != 0 || sgr {
2246+ t.Fatalf("after reset CursorVisible=%v MouseMode=(%d,%v), want true,(0,false)", s.CursorVisible(), mode, sgr)
2247+ }
2248+ },
2249+ },
2250+ }
2251+ for _, tt := range tests {
2252+ t.Run(tt.name, func(t *testing.T) {
2253+ s := NewScreen(10, 4)
2254+ s.Write([]byte(tt.seq))
2255+ tt.check(t, s)
2256+ })
2257+ }
2258+}
2259+
2260+func TestScreenMouseModeDisableInactiveIsNoOp(t *testing.T) {
2261+ s := NewScreen(10, 2)
2262+ s.Write([]byte("\x1b[?1002h"))
2263+ s.Write([]byte("\x1b[?1000l"))
2264+ mode, _ := s.MouseMode()
2265+ if mode != 1002 {
2266+ t.Fatalf("mouse mode after disabling inactive 1000 = %d, want 1002", mode)
2267+ }
2268+ s.Write([]byte("\x1b[?1002l"))
2269+ mode, _ = s.MouseMode()
2270+ if mode != 0 {
2271+ t.Fatalf("mouse mode after disabling active 1002 = %d, want 0", mode)
2272+ }
2273+}
2274+
2275+func TestScreenCursorStyleDECSCUSR(t *testing.T) {
2276+ tests := []struct {
2277+ name string
2278+ seq string
2279+ wantStyle int
2280+ wantSet bool
2281+ }{
2282+ {name: "explicit style", seq: "\x1b[5 q", wantStyle: 5, wantSet: true},
2283+ {name: "blank style parameter", seq: "\x1b[ q", wantStyle: 0, wantSet: true},
2284+ {name: "XTVERSION is ignored", seq: "\x1b[>0q", wantStyle: 0, wantSet: false},
2285+ }
2286+ for _, tt := range tests {
2287+ t.Run(tt.name, func(t *testing.T) {
2288+ s := NewScreen(10, 2)
2289+ s.Write([]byte(tt.seq))
2290+ style, set := s.CursorStyle()
2291+ if style != tt.wantStyle || set != tt.wantSet {
2292+ t.Fatalf("CursorStyle() = (%d, %v), want (%d, %v)", style, set, tt.wantStyle, tt.wantSet)
2293+ }
2294+ })
2295+ }
2296+}
2297+
2298+func TestScreenAltScreenActiveAccessor(t *testing.T) {
2299+ s := NewScreen(10, 2)
2300+ if s.AltScreenActive() {
2301+ t.Fatal("alt screen should start inactive")
2302+ }
2303+ s.Write([]byte("\x1b[?1049h"))
2304+ if !s.AltScreenActive() {
2305+ t.Fatal("alt screen should be active")
2306+ }
2307+ s.Write([]byte("\x1b[?1049l"))
2308+ if s.AltScreenActive() {
2309+ t.Fatal("alt screen should be inactive after exit")
2310+ }
2311+}

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close