From 2d6c751fb2c2b4e46a6cd8bc2adcba64302e0c0f Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 2 Nov 2026 16:06:43 +0700 Subject: [PATCH] fix(terminal): touchpad scrolls 1:1 with the finger xterm.js sends one wheel report per event whatever its size and damps a touchpad's pixel deltas x0.3, so a swipe crawled. With mouse tracking on, TerminalView now sends the SGR reports itself (lib/wheelReports.ts): one per 5 rows of travel (what one report scrolls in a Claude pane), the remainder carried between events, at least one per wheel notch, at the pointer's cell with xterm's modifier bits. --- client/src/components/run/TerminalView.tsx | 24 ++++++++ .../run/__tests__/TerminalView.test.tsx | 36 ++++++++--- client/src/lib/__tests__/wheelReports.test.ts | 42 +++++++++++++ client/src/lib/wheelReports.ts | 59 +++++++++++++++++++ docs/API.md | 2 +- 5 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 client/src/lib/__tests__/wheelReports.test.ts create mode 100644 client/src/lib/wheelReports.ts diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index 19d62691..9e81f1a0 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -86,6 +86,7 @@ import { sameFilesHere, } from "../../lib/promptAttach"; import { attachImeInput } from "../../lib/imeInput"; +import { wheelReports, type WheelState } from "../../lib/wheelReports"; import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste"; import { usePermissions } from "../AuthGate"; import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont"; @@ -524,9 +525,32 @@ export const TerminalView = memo(function TerminalView({ // wheel report itself — psmux scrolls, or hands it to the pane's app. // ponytail: at 1;1, which is all psmux reports to the app anyway. let ownWheel = false; + // Tracked: the reports are this pane's own too — one per + // LINES_PER_REPORT rows of travel (lib/wheelReports.ts), where xterm + // sends one per event and damps a touchpad ×0.3. SGR, as tmux always + // asks for it; at the pointer's cell, with xterm's modifier bits. + const wheelState: WheelState = { partial: 0 }; + const sendWheel = (ev: WheelEvent) => { + const screen = term.element?.querySelector(".xterm-screen"); + const box = screen?.getBoundingClientRect(); + if (!box || !box.height || !box.width) return; + const n = wheelReports(wheelState, ev, box.height / term.rows, term.rows); + const ws = wsRef.current; + if (!n || !ws || ws.readyState !== WebSocket.OPEN) return; + const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v)); + const col = clamp(Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1, term.cols); + const row = clamp(Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1, term.rows); + const button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0); + ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n))); + }; term.attachCustomWheelEventHandler((ev) => { const mode = term.modes.mouseTrackingMode; const tracked = mode !== "none" && mode !== "x10"; + // Shift+wheel stays xterm's (it reports nothing for it). + if (tracked && !ev.shiftKey) { + sendWheel(ev); + return false; + } if (!tracked && ownWheel) { if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`); diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index 1a0a1b51..4518e4f4 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -21,6 +21,7 @@ import { screen, waitFor, } from "@testing-library/react"; +import { Terminal } from "@xterm/xterm"; import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView"; import { pasteToLane, pasteToRun } from "../../../lib/panePaste"; import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste"; @@ -326,13 +327,33 @@ describe("TerminalView", () => { expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false); }); - it("lets the wheel through once the pane's program tracks the mouse", () => { - // tmux with `mouse on` sets this; the wheel must reach it as a real SGR - // report, so returning false here would kill scrolling entirely. + /** Gives the mocked terminal an on-screen box: 10px per column and per row. */ + const screenBox = () => { + const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!.value as { element: HTMLElement }; + const screen = document.createElement("div"); + screen.className = "xterm-screen"; + screen.getBoundingClientRect = () => + ({ left: 0, top: 0, width: termState.cols * 10, height: termState.rows * 10 }) as DOMRect; + term.element.appendChild(screen); + }; + + it("sends a tracked wheel itself, one report per LINES_PER_REPORT rows, at the pointer", () => { + // tmux with `mouse on` sets this. xterm would send ONE report per event and + // damp a touchpad ×0.3 — a swipe crawled — so the pane sends its own. termState.modes.mouseTrackingMode = "any"; termState.buffer.active.type = "alternate"; render(); - expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true); + screenBox(); + const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!; + ws.sent.length = 0; + const wheel = wheelHandlerMock.mock.calls[0]![0]!; + // A notch of 6 rows: one report, at the cell under the pointer. + expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false); + expect(ws.sent).toEqual(["\x1b[<64;5;8M"]); + // A touchpad: 4 steps of 25px = 10 rows = two reports, sent as they add up. + ws.sent.length = 0; + for (let i = 0; i < 4; i++) wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 })); + expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2)); }); it("lets the wheel through on a normal buffer, where xterm scrolls scrollback", () => { @@ -361,10 +382,11 @@ describe("TerminalView", () => { expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false); expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false); expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]); - // Tracked again (a tmux that does report): xterm sends it, never twice. + // Tracked again (a tmux that does report): sent once, by the tracked path. termState.modes.mouseTrackingMode = "any"; - expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true); - expect(ws.sent).toHaveLength(2); + screenBox(); + expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false); + expect(ws.sent).toHaveLength(3); }); it("keeps Shift+Tab in the terminal instead of letting focus escape", () => { diff --git a/client/src/lib/__tests__/wheelReports.test.ts b/client/src/lib/__tests__/wheelReports.test.ts new file mode 100644 index 00000000..82d1d56b --- /dev/null +++ b/client/src/lib/__tests__/wheelReports.test.ts @@ -0,0 +1,42 @@ +/** + * @file wheelReports.test.ts + * @description A touchpad scrolls 1:1 with the finger (one report per + * LINES_PER_REPORT rows, remainder carried), a wheel notch always sends one. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect } from "vitest"; +import { LINES_PER_REPORT, wheelReports, type WheelState } from "../wheelReports"; + +const ROW = 20; +const px = (deltaY: number) => ({ deltaY, deltaMode: 0 }); + +describe("wheelReports", () => { + it("adds small touchpad steps up to one report per LINES_PER_REPORT rows", () => { + const s: WheelState = { partial: 0 }; + let sent = 0; + // 30 steps of 10px = 300px = 15 rows. + for (let i = 0; i < 30; i++) sent += wheelReports(s, px(10), ROW, 40); + expect(sent).toBe(15 / LINES_PER_REPORT); + }); + + it("drops the carried remainder when the swipe turns around", () => { + const s: WheelState = { partial: 0 }; + wheelReports(s, px(40), ROW, 40); // 2 rows down, nothing sent yet + let sent = 0; + for (let i = 0; i < 5; i++) sent += wheelReports(s, px(-20), ROW, 40); // 5 rows up + expect(sent).toBe(-1); + }); + + it("sends at least one report for a wheel notch, in either delta mode", () => { + expect(wheelReports({ partial: 0 }, px(100), ROW, 40)).toBe(1); + expect(wheelReports({ partial: 0 }, px(-100), ROW, 40)).toBe(-1); + expect(wheelReports({ partial: 0 }, { deltaY: 3, deltaMode: 1 }, ROW, 40)).toBe(1); + expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(8); + }); + + it("sends nothing for a horizontal-only event or an unmeasured terminal", () => { + expect(wheelReports({ partial: 0 }, px(0), ROW, 40)).toBe(0); + expect(wheelReports({ partial: 0 }, px(10), 0, 40)).toBe(0); + }); +}); diff --git a/client/src/lib/wheelReports.ts b/client/src/lib/wheelReports.ts new file mode 100644 index 00000000..27d50621 --- /dev/null +++ b/client/src/lib/wheelReports.ts @@ -0,0 +1,59 @@ +/** + * @file wheelReports.ts — turns a browser wheel event into the number of SGR + * wheel reports a terminal pane gets, in proportion to how far the finger or + * the wheel moved, the way a native terminal does. + * + * xterm.js sends ONE report per wheel event whatever its size, and damps a + * touchpad's pixel deltas ×0.3 before it counts even that one — a touchpad + * swipe crawled. In our panes every report moves a fixed stretch (Claude's + * `CLAUDE_CODE_SCROLL_SPEED`, tmux's copy-mode `WHEEL_LINES`), so a report is + * sent per `LINES_PER_REPORT` rows of travel: a touchpad scrolls 1:1 with the + * finger, and a wheel notch always sends at least one. + * @author Nguyễn Ngọc Trí Vĩ + */ + +/** Rows one report scrolls in a Claude pane (server/lib/pty-run.js CLAUDE_SCROLL_SPEED). */ +export const LINES_PER_REPORT = 5; + +/** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */ +const NOTCH_PX = 50; + +const DOM_DELTA_PIXEL = 0; +const DOM_DELTA_LINE = 1; +const DOM_DELTA_PAGE = 2; + +export interface WheelState { + /** Touchpad travel not yet sent, in rows (signed). */ + partial: number; +} + +/** + * Signed report count for `ev` — negative scrolls up. `rowPx` is one row's + * height on screen (CSS scale included), `rows` the terminal's height. + */ +export function wheelReports( + state: WheelState, + ev: Pick, + rowPx: number, + rows: number +): number { + if (!ev.deltaY || !(rowPx > 0)) return 0; + const sign = ev.deltaY < 0 ? -1 : 1; + if (ev.deltaMode === DOM_DELTA_PIXEL && Math.abs(ev.deltaY) < NOTCH_PX) { + // Touchpad: carry the remainder, so slow swipes still add up. + if (Math.sign(state.partial) === -sign) state.partial = 0; // turned around + state.partial += ev.deltaY / rowPx; + const n = Math.trunc(state.partial / LINES_PER_REPORT); + state.partial -= n * LINES_PER_REPORT; + return n; + } + // A notch (or a fast flick): at least one report, more for a long one. + state.partial = 0; + const lines = + ev.deltaMode === DOM_DELTA_LINE + ? ev.deltaY + : ev.deltaMode === DOM_DELTA_PAGE + ? ev.deltaY * rows + : ev.deltaY / rowPx; + return sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT)); +} diff --git a/docs/API.md b/docs/API.md index 6d201e12..e1ad5be3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3804,7 +3804,7 @@ Sent when a notification is created. #### /ws-pty/:runId — PTY frames -A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=&rows=`, integers in `[2, 1000]`). A terminal with no size to give — one on a hidden tab, whose box is 0×0 — names none and reports none until it is shown, and its attach is spawned at the window's CURRENT size (`tmux.windowSize`, 80×24 only when tmux cannot answer), so a background tab never shrinks the pane (it used to fit to xterm's 12×6 minimum and resize tmux to that): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Fullscreen Claude Code scrolls ONE line per wheel report when it cannot tell its terminal is xterm.js (behind tmux it never can), and xterm.js sends one report per notch, so `spawnRun` gives every Claude pane `CLAUDE_CODE_SCROLL_SPEED=5` unless the dashboard's own env sets it; the user's `settings.json` `env` or `/config` → Scroll speed still wins. Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again. +A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=&rows=`, integers in `[2, 1000]`). A terminal with no size to give — one on a hidden tab, whose box is 0×0 — names none and reports none until it is shown, and its attach is spawned at the window's CURRENT size (`tmux.windowSize`, 80×24 only when tmux cannot answer), so a background tab never shrinks the pane (it used to fit to xterm's 12×6 minimum and resize tmux to that): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Fullscreen Claude Code scrolls ONE line per wheel report when it cannot tell its terminal is xterm.js (behind tmux it never can), and xterm.js sends one report per notch, so `spawnRun` gives every Claude pane `CLAUDE_CODE_SCROLL_SPEED=5` unless the dashboard's own env sets it; the user's `settings.json` `env` or `/config` → Scroll speed still wins. Where tracking is on, the browser sends the wheel reports itself too (`client/src/lib/wheelReports.ts`): one per 5 rows the finger or wheel travelled, at the pointer's cell — xterm.js would send one per event and damp a touchpad ×0.3 — and always at least one for a notch. Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again. #### cc_config_changed