From c47e623c652c04e23d63030ac5c06085883bcd7f Mon Sep 17 00:00:00 2001 From: Tink Date: Tue, 4 Aug 2026 08:20:33 +0800 Subject: [PATCH 1/4] feat: touch-swipe scrolling for TUI apps (Claude Code, Codex) The previous touch handler called terminal.scrollLines(), which is a no-op on the alternate screen buffer that TUI apps run in, so swiping did nothing inside Claude Code / Codex. Synthesize a wheel event on the xterm root element instead: xterm routes it to the scrollback in the normal buffer, or to SGR mouse sequences / arrow keys in the alternate buffer, which TUIs understand. Enable stdin in the WebView and bridge xterm onData back to the pty via a new 'input' WebView message so the synthesized wheel bytes reach the shell. Keyboard input is unaffected (it goes through the RN IME). Co-Authored-By: Claude --- app/tabs/sessions/terminal/Terminal.tsx | 40 ++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 3049c47..3b79b8b 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -413,7 +413,7 @@ const TerminalComponent = forwardRef( fastScrollModifier: 'alt', fastScrollSensitivity: 5, allowProposedApi: true, - disableStdin: true, + disableStdin: false, cursorInactiveStyle: '${terminalConfig.cursorStyle || "bar"}' }); @@ -422,6 +422,15 @@ const TerminalComponent = forwardRef( terminal.open(document.getElementById('terminal')); + // Bridge xterm-originated input (e.g. wheel events synthesized into SGR + // mouse sequences / arrow keys) back to the pty. Regular keyboard input + // goes through the RN IME and does not pass through this onData. + terminal.onData(function(data) { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'input', data: data })); + } + }); + fitAddon.fit(); terminal.write('\x1b[?25h'); @@ -694,9 +703,15 @@ const TerminalComponent = forwardRef( setTimeout(handleResize, 100); }); - // Touch-scroll acceleration for iOS WebView + // Touch-scroll for both the normal scrollback and TUI alternate-screen + // buffers. Instead of calling terminal.scrollLines() (which is a no-op on + // the alt buffer that Claude Code / Codex run in), synthesize a wheel + // event on the xterm root element: xterm then routes it either to the + // scrollback (normal buffer) or to SGR mouse / arrow-key sequences the TUI + // understands (alternate buffer with/without mouse tracking). (function() { var scrollTouchY = null; + var pendingLines = 0; var lineH = terminal._core._renderService.dimensions.css.cell.height || ${baseFontSize * 1.2}; terminalElement.addEventListener('touchstart', function(e) { if (e.touches.length === 1) scrollTouchY = e.touches[0].clientY; @@ -705,11 +720,22 @@ const TerminalComponent = forwardRef( if (scrollTouchY === null || e.touches.length !== 1) return; var dy = scrollTouchY - e.touches[0].clientY; scrollTouchY = e.touches[0].clientY; - var lines = Math.trunc(dy / lineH); - if (lines !== 0) terminal.scrollLines(lines); + pendingLines += dy / lineH; + var whole = Math.trunc(pendingLines); + if (whole !== 0) { + pendingLines -= whole; + try { + terminal.element.dispatchEvent(new WheelEvent('wheel', { + deltaY: whole, + deltaMode: WheelEvent.DOM_DELTA_LINE, + cancelable: true + })); + } catch(e2) {} + } }, { passive: true, capture: true }); terminalElement.addEventListener('touchend', function() { scrollTouchY = null; + pendingLines = 0; }, { passive: true, capture: true }); })(); @@ -857,6 +883,12 @@ const TerminalComponent = forwardRef( case "scrollState": setShowScrollToBottomButton(!message.data.isAtBottom); break; + + case "input": + // Wheel/mouse input synthesized inside the WebView (xterm onData), + // forwarded to the pty so TUI apps can scroll their context. + wsManagerRef.current?.sendInput(message.data); + break; } } catch (error) { console.error("[Terminal] Error parsing WebView message:", error); From df31b04e4b3a1ecdaf5a73a9fdbcd2a839deafb7 Mon Sep 17 00:00:00 2001 From: Tink Date: Tue, 4 Aug 2026 10:19:51 +0800 Subject: [PATCH 2/4] fix: prevent whole WebView page from scrolling on up-swipe When swiping up in a TUI app to return to the newest content, the native touch scroll of .xterm-viewport bubbled to the WebView page when the alternate buffer was at its top, scrolling the whole page instead of the terminal. Disable native touch-scrolling at the source with touch-action: none on the terminal elements (including .xterm-screen, the actual touch hit-target), so the synthetic WheelEvent remains the sole scroll driver. Works on both iOS WKWebView and Android WebView. Co-Authored-By: Claude --- app/tabs/sessions/terminal/Terminal.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 3b79b8b..c069775 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -302,6 +302,18 @@ const TerminalComponent = forwardRef( -webkit-overflow-scrolling: touch; } + /* Disable native touch-scrolling of the embedded terminal at the source. + The terminal is scroll-driven exclusively by JS (synthetic WheelEvent → + viewport.scrollTop). Without this, iOS WKWebView / Android WebView's + native touch scroll of .xterm-viewport bubbles to the page when the + (alternate) buffer is at its top, scrolling the whole WebView instead of + the terminal. */ + html, body, #terminal, .xterm, .xterm-viewport, .xterm-screen { + touch-action: none; + -webkit-touch-action: none; + -ms-touch-action: none; + } + .xterm { font-feature-settings: "liga" 1, "calt" 1; text-rendering: optimizeLegibility; From 05fd77834495064ba3f80b57773c6e59e2346bff Mon Sep 17 00:00:00 2001 From: Tink Date: Tue, 4 Aug 2026 11:41:36 +0800 Subject: [PATCH 3/4] fix: claim touch gestures so up-swipe does not scroll the WebView touch-action:none alone is not enough on iOS WKWebView: a passive touchmove still lets the page steal the gesture when the alt buffer is already at its top, so the whole terminal slides instead of the content. Make the scroll touchmove non-passive, call preventDefault (skipping only when text selection is active), disable WebView scrollEnabled, and pin .xterm-viewport overflow so the synthetic WheelEvent remains the only scroll driver. Co-Authored-By: Claude --- app/tabs/sessions/terminal/Terminal.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index c069775..1ae9f52 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -299,7 +299,8 @@ const TerminalComponent = forwardRef( .xterm-viewport { width: 100% !important; height: 100% !important; - -webkit-overflow-scrolling: touch; + overflow: hidden !important; + -webkit-overflow-scrolling: auto; } /* Disable native touch-scrolling of the embedded terminal at the source. @@ -721,6 +722,9 @@ const TerminalComponent = forwardRef( // event on the xterm root element: xterm then routes it either to the // scrollback (normal buffer) or to SGR mouse / arrow-key sequences the TUI // understands (alternate buffer with/without mouse tracking). + // touchmove is non-passive so we can preventDefault and stop the native + // WebView/page from hijacking the swipe (especially up-swipe when the + // alt buffer is already at its top). (function() { var scrollTouchY = null; var pendingLines = 0; @@ -730,6 +734,14 @@ const TerminalComponent = forwardRef( }, { passive: true, capture: true }); terminalElement.addEventListener('touchmove', function(e) { if (scrollTouchY === null || e.touches.length !== 1) return; + // While the user is text-selecting, leave the gesture alone so xterm's + // selection drag can track the finger. + if (typeof isCurrentlySelecting !== 'undefined' && isCurrentlySelecting) { + return; + } + // Claim the gesture so WKWebView / Android WebView do not scroll the + // whole page when the terminal content cannot scroll further. + try { e.preventDefault(); } catch(e3) {} var dy = scrollTouchY - e.touches[0].clientY; scrollTouchY = e.touches[0].clientY; pendingLines += dy / lineH; @@ -744,7 +756,7 @@ const TerminalComponent = forwardRef( })); } catch(e2) {} } - }, { passive: true, capture: true }); + }, { passive: false, capture: true }); terminalElement.addEventListener('touchend', function() { scrollTouchY = null; pendingLines = 0; @@ -1148,7 +1160,7 @@ const TerminalComponent = forwardRef( `WebView HTTP error: ${nativeEvent.statusCode}`, ); }} - scrollEnabled={true} + scrollEnabled={false} overScrollMode="never" bounces={false} showsHorizontalScrollIndicator={false} From 85cdced03bc268efffc02c2eff960fed3cfc6c95 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 13 Aug 2026 06:20:12 +0800 Subject: [PATCH 4/4] fix(terminal): isolate wheel input from keyboard stdin --- app/tabs/sessions/terminal/Terminal.tsx | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 1ae9f52..e0c992c 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -426,7 +426,7 @@ const TerminalComponent = forwardRef( fastScrollModifier: 'alt', fastScrollSensitivity: 5, allowProposedApi: true, - disableStdin: false, + disableStdin: true, cursorInactiveStyle: '${terminalConfig.cursorStyle || "bar"}' }); @@ -435,9 +435,9 @@ const TerminalComponent = forwardRef( terminal.open(document.getElementById('terminal')); - // Bridge xterm-originated input (e.g. wheel events synthesized into SGR - // mouse sequences / arrow keys) back to the pty. Regular keyboard input - // goes through the RN IME and does not pass through this onData. + // Bridge xterm-originated wheel input back to the pty. Stdin remains + // disabled outside the synchronous wheel dispatch below, so keyboard text + // continues to use only the React Native input path. terminal.onData(function(data) { if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'input', data: data })); @@ -749,11 +749,16 @@ const TerminalComponent = forwardRef( if (whole !== 0) { pendingLines -= whole; try { - terminal.element.dispatchEvent(new WheelEvent('wheel', { - deltaY: whole, - deltaMode: WheelEvent.DOM_DELTA_LINE, - cancelable: true - })); + terminal.options.disableStdin = false; + try { + terminal.element.dispatchEvent(new WheelEvent('wheel', { + deltaY: whole, + deltaMode: WheelEvent.DOM_DELTA_LINE, + cancelable: true + })); + } finally { + terminal.options.disableStdin = true; + } } catch(e2) {} } }, { passive: false, capture: true });