diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx
index 9e81f1a0..f4950fa8 100644
--- a/client/src/components/run/TerminalView.tsx
+++ b/client/src/components/run/TerminalView.tsx
@@ -86,7 +86,7 @@ import {
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
-import { wheelReports, type WheelState } from "../../lib/wheelReports";
+import { SCROLL_SPEED, wheelReports, type WheelState } from "../../lib/wheelReports";
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
@@ -455,6 +455,8 @@ export const TerminalView = memo(function TerminalView({
fontWeightBold: 700,
theme: paletteRef.current,
screenReaderMode: readFlag(SCREEN_READER_KEY),
+ // xterm's own scrollback scrolls at the same share as the reports below.
+ scrollSensitivity: SCROLL_SPEED,
});
termRef.current = term;
const fit = new FitAddon();
@@ -538,8 +540,14 @@ export const TerminalView = memo(function TerminalView({
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 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)));
};
diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx
index 4518e4f4..de9ad21c 100644
--- a/client/src/components/run/__tests__/TerminalView.test.tsx
+++ b/client/src/components/run/__tests__/TerminalView.test.tsx
@@ -329,7 +329,8 @@ describe("TerminalView", () => {
/** 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 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 = () =>
@@ -350,9 +351,11 @@ describe("TerminalView", () => {
// 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.
+ // A touchpad: 6 steps of 25px = 15 rows, ×0.7 scroll speed = 10.5 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 }));
+ for (let i = 0; i < 6; i++)
+ wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
});
@@ -523,9 +526,7 @@ describe("TerminalView", () => {
// wrong. The WebGL renderer draws each cell at a fixed position instead
// (xterm 6 removed the canvas one).
render();
- expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
- true
- );
+ expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(true);
});
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
diff --git a/client/src/lib/__tests__/wheelReports.test.ts b/client/src/lib/__tests__/wheelReports.test.ts
index 82d1d56b..4a9b277b 100644
--- a/client/src/lib/__tests__/wheelReports.test.ts
+++ b/client/src/lib/__tests__/wheelReports.test.ts
@@ -6,7 +6,7 @@
*/
import { describe, it, expect } from "vitest";
-import { LINES_PER_REPORT, wheelReports, type WheelState } from "../wheelReports";
+import { LINES_PER_REPORT, SCROLL_SPEED, wheelReports, type WheelState } from "../wheelReports";
const ROW = 20;
const px = (deltaY: number) => ({ deltaY, deltaMode: 0 });
@@ -15,16 +15,17 @@ 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);
+ // 110 steps of 10px = 1100px = 55 rows, scrolled at SCROLL_SPEED.
+ for (let i = 0; i < 110; i++) sent += wheelReports(s, px(10), ROW, 40);
+ expect(sent).toBe(Math.trunc((55 * SCROLL_SPEED) / 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
+ // 8 rows up = 5.6 scrolled: one report, the 2 rows down forgotten.
+ for (let i = 0; i < 8; i++) sent += wheelReports(s, px(-20), ROW, 40);
expect(sent).toBe(-1);
});
@@ -32,7 +33,16 @@ describe("wheelReports", () => {
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);
+ expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(6); // 8 × 0.7
+ });
+
+ it("slows a run of wheel notches to SCROLL_SPEED of a report each", () => {
+ const s: WheelState = { partial: 0 };
+ let sent = 0;
+ for (let i = 0; i < 10; i++) sent += wheelReports(s, px(100), ROW, 40);
+ expect(sent).toBe(10 * SCROLL_SPEED);
+ // Turning around starts from zero: the first notch up moves at once.
+ expect(wheelReports(s, px(-100), ROW, 40)).toBe(-1);
});
it("sends nothing for a horizontal-only event or an unmeasured terminal", () => {
diff --git a/client/src/lib/wheelReports.ts b/client/src/lib/wheelReports.ts
index 27d50621..e606b8d6 100644
--- a/client/src/lib/wheelReports.ts
+++ b/client/src/lib/wheelReports.ts
@@ -8,13 +8,19 @@
* 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.
+ * finger, and a wheel notch always sends at least one. `SCROLL_SPEED` then
+ * slows both down (the user asked for 30 % less): a touchpad's travel is
+ * scaled, and a notch's reports are carried as a fraction, rounded, so the
+ * first notch still moves and the average comes out at that share.
* @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;
+/** Share of the travel a pane scrolls (0.7 = 30 % slower than 1:1). */
+export const SCROLL_SPEED = 0.7;
+
/** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */
const NOTCH_PX = 50;
@@ -25,6 +31,10 @@ const DOM_DELTA_PAGE = 2;
export interface WheelState {
/** Touchpad travel not yet sent, in rows (signed). */
partial: number;
+ /** Wheel-notch reports owed but not sent yet (signed, a fraction). */
+ notch?: number;
+ /** Direction of the last notch (the carry above may round to either sign). */
+ notchDir?: number;
}
/**
@@ -42,18 +52,25 @@ export function wheelReports(
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;
+ state.partial += (ev.deltaY / rowPx) * SCROLL_SPEED;
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;
+ if (state.notchDir !== sign) state.notch = 0; // turned around
+ state.notchDir = sign;
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));
+ const owed =
+ (state.notch ?? 0) +
+ sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT)) * SCROLL_SPEED;
+ const n = sign * Math.round(Math.abs(owed));
+ state.notch = owed - n;
+ return n;
}