feat(maestro): forward touch scroll to mouse-tracking apps
ci / rust (push) Failing after 1m40s

On the alternate screen there's no scrollback, but apps like Claude Code and
tmux enable mouse tracking. When term.modes.mouseTrackingMode is active, turn
finger drags into wheel events so xterm encodes them as mouse-wheel reports the
app reads — scrolling its own content. Normal buffer still walks scrollback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 16:05:57 +00:00
co-authored by Claude Opus 4.8
parent a1b6c85f8d
commit 347b6bdc2a
+36 -9
View File
@@ -130,13 +130,17 @@ export function createAttachConnection(
term.loadAddon(fit);
term.open(body);
// Touch scrollback: xterm's built-in touch handling on the viewport is jittery
// (drags crawl a line at a time and stall after a few), so map finger drags to
// term.scrollLines ourselves. Skipped on the alternate screen (full-screen
// TUIs like tmux/Claude Code) — there's no scrollback to move and the app owns
// the gesture. `touch-action: pan-y` (CSS) tells the browser we handle Y.
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
// line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback.
// • mouse-tracking apps (tmux, Claude Code on the alt screen) → synthesize
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
// scrolling *its* content (the only scroll that exists on the alt screen).
// • alt screen with no mouse tracking → nothing to do; the app owns input.
// `touch-action: pan-y` (CSS) tells the browser we handle the Y gesture.
{
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
const cellH = () => {
const core = (term as unknown as {
_core?: { _renderService?: { dimensions?: { css?: { cell?: { height: number } } } } };
@@ -145,12 +149,14 @@ export function createAttachConnection(
};
if (vp) {
let lastY = 0;
let lastX = 0;
let remainder = 0;
vp.addEventListener(
"touchstart",
(e) => {
if (e.touches.length !== 1) return;
lastY = e.touches[0].clientY;
lastX = e.touches[0].clientX;
remainder = 0;
},
{ passive: true },
@@ -159,13 +165,34 @@ export function createAttachConnection(
"touchmove",
(e) => {
if (e.touches.length !== 1) return;
if (term.buffer.active.type === "alternate") return; // app-owned
const ch = cellH();
const y = e.touches[0].clientY;
const px = lastY - y + remainder; // finger up → scroll toward newer
lastY = y;
const lines = Math.trunc(px / cellH());
remainder = px - lines * cellH();
if (lines !== 0) {
const lines = Math.trunc(px / ch);
remainder = px - lines * ch;
if (lines === 0) return;
// term.modes.mouseTrackingMode is "none" unless the app turned mouse
// reporting on — that's the signal to forward instead of scrollback.
const mouseTracked = term.modes.mouseTrackingMode !== "none";
if (mouseTracked && screen) {
// One wheel event per crossed cell, dispatched at the touch point so
// xterm computes valid cell coords for the mouse report.
const dir = lines > 0 ? 1 : -1;
for (let i = 0; i < Math.abs(lines); i++) {
screen.dispatchEvent(
new WheelEvent("wheel", {
deltaY: dir * ch,
deltaMode: 0,
clientX: lastX,
clientY: y,
bubbles: true,
cancelable: true,
}),
);
}
e.preventDefault();
} else if (term.buffer.active.type !== "alternate") {
term.scrollLines(lines);
e.preventDefault();
}