detached 2eab133

interactive: speed up mouse scrolling in vscode

commit
2eab1339aa65fb45837cec50256cee64ebccb974
author
patriceckhart · · 2026-05-04 08:08 UTC
parent
67c7dc9
signature
unsigned
refs
tag: v0.1.48
4 files · +88 -6
M internal/agent/modes/interactive.go +32 -2
@@ -336,9 +336,16 @@ func (i *Interactive) Run(ctx context.Context) error {
336336 }
337337 }()
338338
339- _, _ = term.Write([]byte(tui.SeqBracketedPasteOn))
339+ mouseSeqOn := ""
340+ mouseSeqOff := ""
341+ if isVSCodeTerminal() {
342+ mouseSeqOn = tui.SeqMouseOn
343+ mouseSeqOff = tui.SeqMouseOff
344+ }
345+
346+ _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + mouseSeqOn))
340347 _, _ = term.Write([]byte(tui.SeqAltScreenOn))
341- defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
348+ defer term.Write([]byte(tui.SeqAltScreenOff + mouseSeqOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
342349
343350 // Streaming pacer: drains buffered text deltas at a steady rate
344351 // so typewriter feel is identical across providers regardless of
@@ -551,6 +558,21 @@ func (i *Interactive) invalidate() {
551558 }
552559 }
553560
561+func isVSCodeTerminal() bool {
562+ return strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vscode")
563+}
564+
565+func mouseWheelScrollRows() int {
566+ // VS Code's integrated terminal emits relatively small wheel
567+ // steps compared with Ghostty's native scrolling. Mouse-wheel
568+ // events get a bigger delta than keyboard arrows so trackpads and
569+ // wheel mice feel responsive without changing Up/Down behaviour.
570+ if isVSCodeTerminal() {
571+ return 12
572+ }
573+ return 6
574+}
575+
554576 // lastCols returns the current terminal width in columns.
555577 func (i *Interactive) lastCols() int {
556578 cols, _ := i.cfg.Terminal.Size()
@@ -1537,6 +1559,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
15371559 case tui.KeyPageDown:
15381560 i.scrollBy(-i.chatPage())
15391561 return false
1562+ case tui.KeyMouseWheelUp:
1563+ i.scrollBy(+mouseWheelScrollRows())
1564+ return false
1565+ case tui.KeyMouseWheelDown:
1566+ if i.scrollOffset > 0 {
1567+ i.scrollBy(-mouseWheelScrollRows())
1568+ }
1569+ return false
15401570 case tui.KeyUp:
15411571 // Always use up/down for chat scrolling, even when the editor
15421572 // contains text. This makes keyboard scrolling consistent with
@@ -336,9 +336,16 @@ func (i *Interactive) Run(ctx context.Context) error {
336 }336 }
337 }()337 }()
338 338
339- _, _ = term.Write([]byte(tui.SeqBracketedPasteOn))339+ mouseSeqOn := ""
340+ mouseSeqOff := ""
341+ if isVSCodeTerminal() {
342+ mouseSeqOn = tui.SeqMouseOn
343+ mouseSeqOff = tui.SeqMouseOff
344+ }
345+
346+ _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + mouseSeqOn))
340 _, _ = term.Write([]byte(tui.SeqAltScreenOn))347 _, _ = term.Write([]byte(tui.SeqAltScreenOn))
341- defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))348+ defer term.Write([]byte(tui.SeqAltScreenOff + mouseSeqOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor))
342 349
343 // Streaming pacer: drains buffered text deltas at a steady rate350 // Streaming pacer: drains buffered text deltas at a steady rate
344 // so typewriter feel is identical across providers regardless of351 // so typewriter feel is identical across providers regardless of
@@ -551,6 +558,21 @@ func (i *Interactive) invalidate() {
551 }558 }
552 }559 }
553 560
561+func isVSCodeTerminal() bool {
562+ return strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vscode")
563+}
564+
565+func mouseWheelScrollRows() int {
566+ // VS Code's integrated terminal emits relatively small wheel
567+ // steps compared with Ghostty's native scrolling. Mouse-wheel
568+ // events get a bigger delta than keyboard arrows so trackpads and
569+ // wheel mice feel responsive without changing Up/Down behaviour.
570+ if isVSCodeTerminal() {
571+ return 12
572+ }
573+ return 6
574+}
575+
554 // lastCols returns the current terminal width in columns.576 // lastCols returns the current terminal width in columns.
555 func (i *Interactive) lastCols() int {577 func (i *Interactive) lastCols() int {
556 cols, _ := i.cfg.Terminal.Size()578 cols, _ := i.cfg.Terminal.Size()
@@ -1537,6 +1559,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1537 case tui.KeyPageDown:1559 case tui.KeyPageDown:
1538 i.scrollBy(-i.chatPage())1560 i.scrollBy(-i.chatPage())
1539 return false1561 return false
1562+ case tui.KeyMouseWheelUp:
1563+ i.scrollBy(+mouseWheelScrollRows())
1564+ return false
1565+ case tui.KeyMouseWheelDown:
1566+ if i.scrollOffset > 0 {
1567+ i.scrollBy(-mouseWheelScrollRows())
1568+ }
1569+ return false
1540 case tui.KeyUp:1570 case tui.KeyUp:
1541 // Always use up/down for chat scrolling, even when the editor1571 // Always use up/down for chat scrolling, even when the editor
1542 // contains text. This makes keyboard scrolling consistent with1572 // contains text. This makes keyboard scrolling consistent with
M internal/tui/input.go +18 -0
@@ -42,6 +42,8 @@ const (
4242 KeyCtrlW
4343 KeyCtrlO
4444 KeyPaste
45+ KeyMouseWheelUp
46+ KeyMouseWheelDown
4547 KeyUnknown
4648 )
4749
@@ -222,6 +224,22 @@ func (r *Reader) readCSI() (Key, error) {
222224 }
223225
224226 func (r *Reader) dispatchCSI(params string, final byte) Key {
227+ // SGR mouse mode: CSI < button ; x ; y M/m. Wheel events use
228+ // button codes 64 (up) and 65 (down). We ignore coordinates for
229+ // now; the chat view only needs scroll direction.
230+ if strings.HasPrefix(params, "<") && (final == 'M' || final == 'm') {
231+ parts := strings.Split(strings.TrimPrefix(params, "<"), ";")
232+ if len(parts) >= 1 {
233+ switch parts[0] {
234+ case "64":
235+ return Key{Kind: KeyMouseWheelUp}
236+ case "65":
237+ return Key{Kind: KeyMouseWheelDown}
238+ }
239+ }
240+ return Key{Kind: KeyUnknown}
241+ }
242+
225243 // Modified arrow keys come in as CSI 1;<mod><final>. Modifier values
226244 // we care about: 2=Shift, 3=Alt/Option, 5=Ctrl. We only extract Alt.
227245 var alt bool
@@ -42,6 +42,8 @@ const (
42 KeyCtrlW42 KeyCtrlW
43 KeyCtrlO43 KeyCtrlO
44 KeyPaste44 KeyPaste
45+ KeyMouseWheelUp
46+ KeyMouseWheelDown
45 KeyUnknown47 KeyUnknown
46 )48 )
47 49
@@ -222,6 +224,22 @@ func (r *Reader) readCSI() (Key, error) {
222 }224 }
223 225
224 func (r *Reader) dispatchCSI(params string, final byte) Key {226 func (r *Reader) dispatchCSI(params string, final byte) Key {
227+ // SGR mouse mode: CSI < button ; x ; y M/m. Wheel events use
228+ // button codes 64 (up) and 65 (down). We ignore coordinates for
229+ // now; the chat view only needs scroll direction.
230+ if strings.HasPrefix(params, "<") && (final == 'M' || final == 'm') {
231+ parts := strings.Split(strings.TrimPrefix(params, "<"), ";")
232+ if len(parts) >= 1 {
233+ switch parts[0] {
234+ case "64":
235+ return Key{Kind: KeyMouseWheelUp}
236+ case "65":
237+ return Key{Kind: KeyMouseWheelDown}
238+ }
239+ }
240+ return Key{Kind: KeyUnknown}
241+ }
242+
225 // Modified arrow keys come in as CSI 1;<mod><final>. Modifier values243 // Modified arrow keys come in as CSI 1;<mod><final>. Modifier values
226 // we care about: 2=Shift, 3=Alt/Option, 5=Ctrl. We only extract Alt.244 // we care about: 2=Shift, 3=Alt/Option, 5=Ctrl. We only extract Alt.
227 var alt bool245 var alt bool
A internal/tui/input_test.go +28 -0
@@ -0,0 +1,28 @@
1+package tui
2+
3+import "testing"
4+
5+func TestReaderParsesSGRMouseWheel(t *testing.T) {
6+ cases := []struct {
7+ seq string
8+ want KeyKind
9+ }{
10+ {"\x1b[<64;10;20M", KeyMouseWheelUp},
11+ {"\x1b[<65;10;20M", KeyMouseWheelDown},
12+ }
13+ for _, tc := range cases {
14+ idx := 0
15+ r := NewReader(func() (byte, error) {
16+ b := tc.seq[idx]
17+ idx++
18+ return b, nil
19+ })
20+ k, err := r.Read()
21+ if err != nil {
22+ t.Fatalf("Read(%q): %v", tc.seq, err)
23+ }
24+ if k.Kind != tc.want {
25+ t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, k.Kind, tc.want)
26+ }
27+ }
28+}
@@ -0,0 +1,28 @@
1+package tui
2+
3+import "testing"
4+
5+func TestReaderParsesSGRMouseWheel(t *testing.T) {
6+ cases := []struct {
7+ seq string
8+ want KeyKind
9+ }{
10+ {"\x1b[<64;10;20M", KeyMouseWheelUp},
11+ {"\x1b[<65;10;20M", KeyMouseWheelDown},
12+ }
13+ for _, tc := range cases {
14+ idx := 0
15+ r := NewReader(func() (byte, error) {
16+ b := tc.seq[idx]
17+ idx++
18+ return b, nil
19+ })
20+ k, err := r.Read()
21+ if err != nil {
22+ t.Fatalf("Read(%q): %v", tc.seq, err)
23+ }
24+ if k.Kind != tc.want {
25+ t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, k.Kind, tc.want)
26+ }
27+ }
28+}
M internal/tui/terminal.go +10 -4
@@ -89,10 +89,16 @@ const (
8989 SeqClearLine = "\x1b[2K"
9090 SeqBracketedPasteOn = "\x1b[?2004h"
9191 SeqBracketedPasteOff = "\x1b[?2004l"
92- SeqAltScreenOn = "\x1b[?1049h"
93- SeqAltScreenOff = "\x1b[?1049l"
94- SeqSynchronizedOn = "\x1b[?2026h"
95- SeqSynchronizedOff = "\x1b[?2026l"
92+ // Basic mouse tracking + SGR extended coordinates. Used only
93+ // when explicitly enabled by the interactive mode (currently VS
94+ // Code terminal) so terminals with good native scrolling, like
95+ // Ghostty, are left alone.
96+ SeqMouseOn = "\x1b[?1000h\x1b[?1006h"
97+ SeqMouseOff = "\x1b[?1000l\x1b[?1006l"
98+ SeqAltScreenOn = "\x1b[?1049h"
99+ SeqAltScreenOff = "\x1b[?1049l"
100+ SeqSynchronizedOn = "\x1b[?2026h"
101+ SeqSynchronizedOff = "\x1b[?2026l"
96102 )
97103
98104 // MoveTo moves the cursor to 1-indexed (row, col).
@@ -89,10 +89,16 @@ const (
89 SeqClearLine = "\x1b[2K"89 SeqClearLine = "\x1b[2K"
90 SeqBracketedPasteOn = "\x1b[?2004h"90 SeqBracketedPasteOn = "\x1b[?2004h"
91 SeqBracketedPasteOff = "\x1b[?2004l"91 SeqBracketedPasteOff = "\x1b[?2004l"
92- SeqAltScreenOn = "\x1b[?1049h"92+ // Basic mouse tracking + SGR extended coordinates. Used only
93- SeqAltScreenOff = "\x1b[?1049l"93+ // when explicitly enabled by the interactive mode (currently VS
94- SeqSynchronizedOn = "\x1b[?2026h"94+ // Code terminal) so terminals with good native scrolling, like
95- SeqSynchronizedOff = "\x1b[?2026l"95+ // Ghostty, are left alone.
96+ SeqMouseOn = "\x1b[?1000h\x1b[?1006h"
97+ SeqMouseOff = "\x1b[?1000l\x1b[?1006l"
98+ SeqAltScreenOn = "\x1b[?1049h"
99+ SeqAltScreenOff = "\x1b[?1049l"
100+ SeqSynchronizedOn = "\x1b[?2026h"
101+ SeqSynchronizedOff = "\x1b[?2026l"
96 )102 )
97 103
98 // MoveTo moves the cursor to 1-indexed (row, col).104 // MoveTo moves the cursor to 1-indexed (row, col).

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 1

Tags 476

↑↓ moveenter openesc close