From 347b6bdc2a4b6e396f199c51f386f76c9cdbc211 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Wed, 24 Jun 2026 16:05:57 +0000 Subject: [PATCH] feat(maestro): forward touch scroll to mouse-tracking apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/src/attach.ts | 45 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/web/src/attach.ts b/web/src/attach.ts index f5be8bf..b512490 100644 --- a/web/src/attach.ts +++ b/web/src/attach.ts @@ -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(); }