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(); }