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 { | |||
| 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 | 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 | 350 | // Streaming pacer: drains buffered text deltas at a steady rate | |
| 344 | 351 | // 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 | 576 | // lastCols returns the current terminal width in columns. | |
| 555 | 577 | func (i *Interactive) lastCols() int { | |
| 556 | 578 | cols, _ := i.cfg.Terminal.Size() | |
| @@ -1537,6 +1559,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1537 | 1559 | case tui.KeyPageDown: | |
| 1538 | 1560 | i.scrollBy(-i.chatPage()) | |
| 1539 | 1561 | 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 | 1570 | case tui.KeyUp: | |
| 1541 | 1571 | // Always use up/down for chat scrolling, even when the editor | |
| 1542 | 1572 | // 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 rate | 350 | // Streaming pacer: drains buffered text deltas at a steady rate | ||
| 344 | // so typewriter feel is identical across providers regardless of | 351 | // 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 false | 1561 | 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 editor | 1571 | // Always use up/down for chat scrolling, even when the editor | ||
| 1542 | // contains text. This makes keyboard scrolling consistent with | 1572 | // contains text. This makes keyboard scrolling consistent with | ||
M internal/tui/input.go +18 -0
| @@ -42,6 +42,8 @@ const ( | |||
| 42 | 42 | KeyCtrlW | |
| 43 | 43 | KeyCtrlO | |
| 44 | 44 | KeyPaste | |
| 45 | + | KeyMouseWheelUp | |
| 46 | + | KeyMouseWheelDown | |
| 45 | 47 | KeyUnknown | |
| 46 | 48 | ) | |
| 47 | 49 | ||
| @@ -222,6 +224,22 @@ func (r *Reader) readCSI() (Key, error) { | |||
| 222 | 224 | } | |
| 223 | 225 | ||
| 224 | 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 | 243 | // Modified arrow keys come in as CSI 1;<mod><final>. Modifier values | |
| 226 | 244 | // we care about: 2=Shift, 3=Alt/Option, 5=Ctrl. We only extract Alt. | |
| 227 | 245 | var alt bool | |
| @@ -42,6 +42,8 @@ const ( | |||||
| 42 | KeyCtrlW | 42 | KeyCtrlW | ||
| 43 | KeyCtrlO | 43 | KeyCtrlO | ||
| 44 | KeyPaste | 44 | KeyPaste | ||
| 45 | + | KeyMouseWheelUp | |||
| 46 | + | KeyMouseWheelDown | |||
| 45 | KeyUnknown | 47 | 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 values | 243 | // 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 bool | 245 | 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 ( | |||
| 89 | 89 | SeqClearLine = "\x1b[2K" | |
| 90 | 90 | SeqBracketedPasteOn = "\x1b[?2004h" | |
| 91 | 91 | 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" | |
| 96 | 102 | ) | |
| 97 | 103 | ||
| 98 | 104 | // 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). | ||