diff --git a/apps/web/src/components/features/terminal/terminal.tsx b/apps/web/src/components/features/terminal/terminal.tsx index 6ffd0c5..43b265b 100644 --- a/apps/web/src/components/features/terminal/terminal.tsx +++ b/apps/web/src/components/features/terminal/terminal.tsx @@ -363,34 +363,32 @@ export const TerminalComponent = React.forwardRef( let startY = 0; let startX = 0; let isScrolling = false; - let scrollPending = 0; - const WHEEL_DISTANCE = 12; - - const flushScroll = (force = false) => { - if (scrollPending === 0) return; - if (!force && Math.abs(scrollPending) < WHEEL_DISTANCE) return; - const direction = Math.sign(scrollPending); - const steps = Math.max( - 1, - Math.floor(Math.abs(scrollPending) / WHEEL_DISTANCE), - ); - const ws = wsRef.current; - if (ws?.readyState === WebSocket.OPEN && termRef.current) { - const buf = termRef.current.buffer.active; - const col = buf.cursorX + 1; - const row = buf.cursorY + 1; - const btn = direction > 0 ? 64 : 65; - for (let i = 0; i < steps; i++) { - ws.send(`\x1b[<${btn};${col};${row}M`); - } + let debugOverlay: HTMLDivElement | null = null; + const showDebug = (msg: string) => { + if (!debugOverlay) { + debugOverlay = document.createElement("div"); + debugOverlay.style.position = "fixed"; + debugOverlay.style.bottom = "120px"; + debugOverlay.style.left = "8px"; + debugOverlay.style.right = "8px"; + debugOverlay.style.zIndex = "9999"; + debugOverlay.style.background = "rgba(0,0,0,0.8)"; + debugOverlay.style.color = "#0f0"; + debugOverlay.style.fontFamily = "monospace"; + debugOverlay.style.fontSize = "11px"; + debugOverlay.style.padding = "8px"; + debugOverlay.style.borderRadius = "6px"; + debugOverlay.style.pointerEvents = "none"; + debugOverlay.style.whiteSpace = "pre-wrap"; + debugOverlay.style.maxHeight = "120px"; + debugOverlay.style.overflow = "auto"; + document.body.appendChild(debugOverlay); } - scrollPending = 0; - }; - - const isAlternateScreen = () => { - const term = termRef.current; - if (!term) return false; - return term.buffer.active === term.buffer.alternate; + const line = `${new Date().toLocaleTimeString()} ${msg}`; + debugOverlay.textContent = + line + + "\n" + + debugOverlay.textContent?.split("\n").slice(0, 8).join("\n"); }; const onTouchStart = (e: TouchEvent) => { @@ -398,7 +396,6 @@ export const TerminalComponent = React.forwardRef( startY = e.touches[0].clientY; startX = e.touches[0].clientX; isScrolling = false; - scrollPending = 0; } }; const onTouchMove = (e: TouchEvent) => { @@ -406,6 +403,7 @@ export const TerminalComponent = React.forwardRef( const touch = e.touches[0]; const deltaY = startY - touch.clientY; const deltaX = Math.abs(startX - touch.clientX); + showDebug(`move dy=${deltaY.toFixed(0)} dx=${deltaX.toFixed(0)} scrolling=${isScrolling}`); // Decide early whether this is a vertical scroll gesture. if (!isScrolling) { @@ -415,32 +413,34 @@ export const TerminalComponent = React.forwardRef( } if (!isScrolling) return; - // Take over the gesture so the page/toolbar never scrolls. + // Take over the gesture so the browser never scrolls the + // page or the terminal canvas. We synthesize wheel events + // and dispatch them into xterm.js, which already handles + // both normal buffer scrolling and alternate-screen apps + // (tmux/vim) correctly. e.preventDefault(); - if (isAlternateScreen()) { - // Alternate screen (tmux/vim): accumulate the swipe and - // send SGR 1006 mouse-wheel events in steps. - scrollPending += deltaY; - flushScroll(); - } else if (termRef.current) { - // Normal buffer: let xterm.js scroll its own viewport by - // the number of lines corresponding to the swipe distance. - const lineHeight = - termRef.current.options.fontSize != null - ? (termRef.current.options.fontSize as number) * 1.2 - : 10; - const lines = Math.round(deltaY / lineHeight); - if (lines !== 0) { - termRef.current.scrollLines(lines); - } + const target = container.querySelector(".xterm-viewport") as + | HTMLElement + | null; + if (target) { + showDebug(`dispatch wheel deltaY=${deltaY.toFixed(0)}`); + const wheel = new WheelEvent("wheel", { + deltaY, + deltaX: 0, + deltaZ: 0, + clientX: touch.clientX, + clientY: touch.clientY, + screenX: touch.screenX, + screenY: touch.screenY, + bubbles: true, + cancelable: true, + }); + target.dispatchEvent(wheel); } startY = touch.clientY; }; const onTouchEnd = () => { - if (isScrolling) { - flushScroll(true); - } isScrolling = false; };