From 4814ec23634f1a0549c90273df0bb32b7ebd1e83 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Fri, 29 May 2026 20:35:28 +0200 Subject: [PATCH] fix: mobile terminal scroll in both normal mode and tmux - Dual-mode touch scroll: - Normal mode: scroll .xterm-viewport directly when scrollHeight > clientHeight - Alternate screen (tmux/vim): send SGR 1006 mouse-wheel protocol data using cursor position so tmux knows which pane to scroll - Add touch-action: none to .terminal-container to prevent browser gestures - Lock both html and body overflow when terminal page is open on mobile - Remove synthetic WheelEvent approach (xterm.js SmoothScrollableElement doesn't reliably handle synthetic events) --- apps/web/src/components/terminal.tsx | 36 ++++++++++++++++++++++------ apps/web/src/pages/terminal.tsx | 4 +++- apps/web/src/styles.css | 2 ++ 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index e85e5a9..b74d087 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -313,12 +313,12 @@ export const TerminalComponent = React.forwardRef( term.focus(); const ws = connectWebSocket(); - // Mobile: xterm.js 5.x+ viewport is behind the canvas in z-order, so - // native browser touch scrolling never reaches it. We attach capture- - // phase listeners to the wrapper container (parent of xterm) so we - // intercept touches before they bubble up to the page. Vertical swipes - // adjust .xterm-viewport.scrollTop directly, which fires scroll events - // that xterm.js handles internally. + // Mobile touch scroll. + // In normal mode xterm.js has a scrollable viewport; in alternate + // screen (tmux/vim) there is no scrollback and the only way to + // scroll is to send mouse-wheel protocol sequences to the + // application. We detect which situation we're in by checking + // whether the viewport has scrollable height. let touchCleanup: (() => void) | undefined; if (isMobile) { let startY = 0; @@ -347,8 +347,30 @@ export const TerminalComponent = React.forwardRef( const viewport = container.querySelector( ".xterm-viewport", ) as HTMLElement | null; - if (viewport) { + if (!viewport) return; + + // If the viewport is scrollable, scroll it directly. + // Otherwise we are in alternate screen (tmux/vim) and must + // send SGR 1006 mouse-wheel protocol data. + const hasScrollback = + viewport.scrollHeight > viewport.clientHeight; + if (hasScrollback) { viewport.scrollTop += deltaY; + } else { + const ws = wsRef.current; + if ( + ws?.readyState === WebSocket.OPEN && + termRef.current + ) { + // Use the cursor position as the wheel location so + // tmux knows which pane to scroll. + const buf = termRef.current.buffer.active; + const col = buf.cursorX + 1; + const row = buf.cursorY + 1; + // SGR 1006: 64 = wheel-up, 65 = wheel-down + const btn = deltaY > 0 ? 64 : 65; + ws.send(`\x1b[<${btn};${col};${row}M`); + } } startY = touch.clientY; } diff --git a/apps/web/src/pages/terminal.tsx b/apps/web/src/pages/terminal.tsx index 7bf5898..8d8e073 100644 --- a/apps/web/src/pages/terminal.tsx +++ b/apps/web/src/pages/terminal.tsx @@ -204,12 +204,14 @@ export const TerminalPage: React.FC = () => { }; }, []); - // Lock body scroll on mobile terminal so swipes scroll the terminal buffer, + // Lock page scroll on mobile terminal so swipes scroll the terminal buffer, // not the page. useEffect(() => { if (!isMobile) return; + document.documentElement.classList.add("terminal-page-open"); document.body.classList.add("terminal-page-open"); return () => { + document.documentElement.classList.remove("terminal-page-open"); document.body.classList.remove("terminal-page-open"); }; }, [isMobile]); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index e2872a9..e63cc65 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -77,6 +77,7 @@ body { color: var(--ink); } +html.terminal-page-open, body.terminal-page-open { overflow: hidden; } @@ -3707,6 +3708,7 @@ a.nav-item, padding: 0; overflow: hidden; position: relative; + touch-action: none; } /* xterm.js manages its own sizing */