From 2eab1339aa65fb45837cec50256cee64ebccb974 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Mon, 4 May 2026 10:08:11 +0200 Subject: [PATCH] interactive: speed up mouse scrolling in vscode --- internal/agent/modes/interactive.go | 34 +++++++++++++++++++++++++++-- internal/tui/input.go | 18 +++++++++++++++ internal/tui/input_test.go | 28 ++++++++++++++++++++++++ internal/tui/terminal.go | 14 ++++++++---- 4 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 internal/tui/input_test.go diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 148b55e..fd55978 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -336,9 +336,16 @@ func (i *Interactive) Run(ctx context.Context) error { } }() - _, _ = term.Write([]byte(tui.SeqBracketedPasteOn)) + mouseSeqOn := "" + mouseSeqOff := "" + if isVSCodeTerminal() { + mouseSeqOn = tui.SeqMouseOn + mouseSeqOff = tui.SeqMouseOff + } + + _, _ = term.Write([]byte(tui.SeqBracketedPasteOn + mouseSeqOn)) _, _ = term.Write([]byte(tui.SeqAltScreenOn)) - defer term.Write([]byte(tui.SeqAltScreenOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor)) + defer term.Write([]byte(tui.SeqAltScreenOff + mouseSeqOff + tui.SeqBracketedPasteOff + tui.SeqShowCursor)) // Streaming pacer: drains buffered text deltas at a steady rate // so typewriter feel is identical across providers regardless of @@ -551,6 +558,21 @@ func (i *Interactive) invalidate() { } } +func isVSCodeTerminal() bool { + return strings.EqualFold(os.Getenv("TERM_PROGRAM"), "vscode") +} + +func mouseWheelScrollRows() int { + // VS Code's integrated terminal emits relatively small wheel + // steps compared with Ghostty's native scrolling. Mouse-wheel + // events get a bigger delta than keyboard arrows so trackpads and + // wheel mice feel responsive without changing Up/Down behaviour. + if isVSCodeTerminal() { + return 12 + } + return 6 +} + // lastCols returns the current terminal width in columns. func (i *Interactive) lastCols() int { cols, _ := i.cfg.Terminal.Size() @@ -1537,6 +1559,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { case tui.KeyPageDown: i.scrollBy(-i.chatPage()) return false + case tui.KeyMouseWheelUp: + i.scrollBy(+mouseWheelScrollRows()) + return false + case tui.KeyMouseWheelDown: + if i.scrollOffset > 0 { + i.scrollBy(-mouseWheelScrollRows()) + } + return false case tui.KeyUp: // Always use up/down for chat scrolling, even when the editor // contains text. This makes keyboard scrolling consistent with diff --git a/internal/tui/input.go b/internal/tui/input.go index 326ae71..ae20748 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -42,6 +42,8 @@ const ( KeyCtrlW KeyCtrlO KeyPaste + KeyMouseWheelUp + KeyMouseWheelDown KeyUnknown ) @@ -222,6 +224,22 @@ func (r *Reader) readCSI() (Key, error) { } func (r *Reader) dispatchCSI(params string, final byte) Key { + // SGR mouse mode: CSI < button ; x ; y M/m. Wheel events use + // button codes 64 (up) and 65 (down). We ignore coordinates for + // now; the chat view only needs scroll direction. + if strings.HasPrefix(params, "<") && (final == 'M' || final == 'm') { + parts := strings.Split(strings.TrimPrefix(params, "<"), ";") + if len(parts) >= 1 { + switch parts[0] { + case "64": + return Key{Kind: KeyMouseWheelUp} + case "65": + return Key{Kind: KeyMouseWheelDown} + } + } + return Key{Kind: KeyUnknown} + } + // Modified arrow keys come in as CSI 1;. Modifier values // we care about: 2=Shift, 3=Alt/Option, 5=Ctrl. We only extract Alt. var alt bool diff --git a/internal/tui/input_test.go b/internal/tui/input_test.go new file mode 100644 index 0000000..8ab692b --- /dev/null +++ b/internal/tui/input_test.go @@ -0,0 +1,28 @@ +package tui + +import "testing" + +func TestReaderParsesSGRMouseWheel(t *testing.T) { + cases := []struct { + seq string + want KeyKind + }{ + {"\x1b[<64;10;20M", KeyMouseWheelUp}, + {"\x1b[<65;10;20M", KeyMouseWheelDown}, + } + for _, tc := range cases { + idx := 0 + r := NewReader(func() (byte, error) { + b := tc.seq[idx] + idx++ + return b, nil + }) + k, err := r.Read() + if err != nil { + t.Fatalf("Read(%q): %v", tc.seq, err) + } + if k.Kind != tc.want { + t.Fatalf("Read(%q) kind=%v, want %v", tc.seq, k.Kind, tc.want) + } + } +} diff --git a/internal/tui/terminal.go b/internal/tui/terminal.go index d10e708..c8b8bc9 100644 --- a/internal/tui/terminal.go +++ b/internal/tui/terminal.go @@ -89,10 +89,16 @@ const ( SeqClearLine = "\x1b[2K" SeqBracketedPasteOn = "\x1b[?2004h" SeqBracketedPasteOff = "\x1b[?2004l" - SeqAltScreenOn = "\x1b[?1049h" - SeqAltScreenOff = "\x1b[?1049l" - SeqSynchronizedOn = "\x1b[?2026h" - SeqSynchronizedOff = "\x1b[?2026l" + // Basic mouse tracking + SGR extended coordinates. Used only + // when explicitly enabled by the interactive mode (currently VS + // Code terminal) so terminals with good native scrolling, like + // Ghostty, are left alone. + SeqMouseOn = "\x1b[?1000h\x1b[?1006h" + SeqMouseOff = "\x1b[?1000l\x1b[?1006l" + SeqAltScreenOn = "\x1b[?1049h" + SeqAltScreenOff = "\x1b[?1049l" + SeqSynchronizedOn = "\x1b[?2026h" + SeqSynchronizedOff = "\x1b[?2026l" ) // MoveTo moves the cursor to 1-indexed (row, col).