From 7156e146c04163980aee56108cd384a133dca7fd Mon Sep 17 00:00:00 2001 From: Tink Date: Mon, 3 Aug 2026 22:42:46 +0800 Subject: [PATCH 1/4] fix: keep terminal rows in sync with visible area under chrome TUI apps (Claude Code, Codex, vim, htop) draw their bottom input row at the pty's last row. The WebView was sized by marginBottom so it spans only the visible area, but xterm computed rows from #terminal's 100vh CSS height, which WKWebView reports stale after a frame resize. The pty told the shell too many rows, so TUIs painted their input row behind the keyboard bar. Measure the real visible height with onLayout, push it into the WebView (debounced to avoid resize storms), pin #terminal's height to it, then fit and re-send the pty resize. Re-apply on terminalReady and prefer the pinned height in nativeFit/resize over the stale 100vh. Co-Authored-By: Claude --- app/tabs/sessions/terminal/Terminal.tsx | 96 ++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 0b02ca5..c14584c 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -13,6 +13,7 @@ import { Dimensions, AccessibilityInfo, TouchableOpacity, + type LayoutChangeEvent, } from "react-native"; import { WebView } from "react-native-webview"; import { ChevronDown } from "lucide-react-native"; @@ -96,6 +97,19 @@ const TerminalComponent = forwardRef( const wsManagerRef = useRef(null); const terminalColsRef = useRef(80); const terminalRowsRef = useRef(24); + // Pixel height of the visible terminal area as measured by RN layout. + // The WebView is shrunk by the TabBar/KeyboardBar/system-keyboard via the + // parent's marginBottom, but inside the WebView `100vh`/`window.innerHeight` + // is unreliable (WKWebView reports stale values after a frame resize). Pushing + // the exact laid-out height lets xterm compute the correct row count, so TUI + // apps (Claude Code, Codex, …) draw their bottom input row inside the visible + // area instead of behind the chrome. + const viewportHeightRef = useRef(null); + // Debounces onLayout pushes during LayoutAnimation / keyboard slide so the + // pty isn't spammed with resize storms (each resize → SIGWINCH → TUI redraw). + const viewportDebounceTimerRef = useRef | null>( + null, + ); const pendingDataRef = useRef([]); const dataFlushTimerRef = useRef | null>( null, @@ -679,11 +693,57 @@ const TerminalComponent = forwardRef( } } + var lastViewportHeight = null; + function applyViewportHeight(px, force) { + var el = document.getElementById('terminal'); + if (!el || !px || px <= 0) return; + if (!force && Math.abs(px - (lastViewportHeight || 0)) < 1) return; + lastViewportHeight = px; + + el.style.height = px + 'px'; + el.style.minHeight = '0px'; + + try { + fitAddon.fit(); + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'resize', + data: { cols: terminal.cols, rows: terminal.rows } + })); + } + // If the user was scrolled near the bottom, keep them pinned there so + // the prompt/TUI input row stays visible after the resize. + try { + if (terminal.buffer.active.viewportY >= terminal.buffer.active.baseY - 1) { + terminal.scrollToBottom(); + } + } catch(e2) {} + } catch(e) {} + } + window.setTerminalViewportHeight = function(px) { + applyViewportHeight(px, false); + } + + // Re-fit using the last RN-measured viewport height (if known) instead of + // the possibly-stale 100vh, so RN-driven resizes (keyboard, orientation, + // chrome show/hide) keep the row count in sync with the visible area. window.nativeFit = function() { - try { handleResize(); } catch(e) {} + if (lastViewportHeight) { + applyViewportHeight(lastViewportHeight, true); + } else { + try { handleResize(); } catch(e) {} + } } - window.addEventListener('resize', handleResize); + window.addEventListener('resize', function() { + // Prefer the RN-measured height; fall back to the WebView's own viewport + // when RN hasn't measured yet (e.g. initial load before onLayout). + if (lastViewportHeight) { + applyViewportHeight(lastViewportHeight, true); + } else { + try { handleResize(); } catch(e) {} + } + }); window.addEventListener('orientationchange', function() { setTimeout(handleResize, 100); @@ -819,6 +879,26 @@ const TerminalComponent = forwardRef( [], ); + const handleTerminalLayout = useCallback((event: LayoutChangeEvent) => { + const h = Math.round(event.nativeEvent.layout.height || 0); + if (h <= 0 || h === viewportHeightRef.current) { + return; + } + viewportHeightRef.current = h; + // Debounce so mid-animation frames don't each trigger a pty resize. + if (viewportDebounceTimerRef.current) { + clearTimeout(viewportDebounceTimerRef.current); + } + viewportDebounceTimerRef.current = setTimeout(() => { + viewportDebounceTimerRef.current = null; + try { + webViewRef.current?.injectJavaScript( + `window.setTerminalViewportHeight && window.setTerminalViewportHeight(${h}); true;`, + ); + } catch (err) {} + }, 80); + }, []); + const handleWebViewMessage = useCallback((event: any) => { try { const message = JSON.parse(event.nativeEvent.data); @@ -827,6 +907,13 @@ const TerminalComponent = forwardRef( case "terminalReady": terminalColsRef.current = message.data.cols; terminalRowsRef.current = message.data.rows; + // Re-apply the RN-measured viewport height now that the terminal + // exists — onLayout may have fired before the HTML finished loading. + if (viewportHeightRef.current) { + webViewRef.current?.injectJavaScript( + `window.setTerminalViewportHeight && window.setTerminalViewportHeight(${viewportHeightRef.current}); true;`, + ); + } wsManagerRef.current?.connect(message.data.cols, message.data.rows); break; @@ -979,6 +1066,10 @@ const TerminalComponent = forwardRef( clearTimeout(accessibilityTimerRef.current); accessibilityTimerRef.current = null; } + if (viewportDebounceTimerRef.current) { + clearTimeout(viewportDebounceTimerRef.current); + viewportDebounceTimerRef.current = null; + } }; }, []); @@ -1027,6 +1118,7 @@ const TerminalComponent = forwardRef( return ( Date: Tue, 4 Aug 2026 08:20:33 +0800 Subject: [PATCH 2/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 c14584c..18fa61a 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -422,7 +422,7 @@ const TerminalComponent = forwardRef( fastScrollModifier: 'alt', fastScrollSensitivity: 5, allowProposedApi: true, - disableStdin: true, + disableStdin: false, cursorInactiveStyle: '${terminalConfig.cursorStyle || "bar"}' }); @@ -431,6 +431,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'); @@ -749,9 +758,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; @@ -760,11 +775,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 }); })(); @@ -937,6 +963,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 ae8ed1fbf42fff1bf37aaaccd2dd9372092f96fe Mon Sep 17 00:00:00 2001 From: Tink Date: Tue, 4 Aug 2026 10:19:51 +0800 Subject: [PATCH 3/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 18fa61a..0ab6a1c 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -311,6 +311,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 d84f0ede9d374e6eac5eb00a4fa2c47c0947112b Mon Sep 17 00:00:00 2001 From: Tink Date: Tue, 4 Aug 2026 11:41:36 +0800 Subject: [PATCH 4/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 0ab6a1c..a4953aa 100644 --- a/app/tabs/sessions/terminal/Terminal.tsx +++ b/app/tabs/sessions/terminal/Terminal.tsx @@ -308,7 +308,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. @@ -776,6 +777,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; @@ -785,6 +789,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; @@ -799,7 +811,7 @@ const TerminalComponent = forwardRef( })); } catch(e2) {} } - }, { passive: true, capture: true }); + }, { passive: false, capture: true }); terminalElement.addEventListener('touchend', function() { scrollTouchY = null; pendingLines = 0; @@ -1230,7 +1242,7 @@ const TerminalComponent = forwardRef( `WebView HTTP error: ${nativeEvent.statusCode}`, ); }} - scrollEnabled={true} + scrollEnabled={false} overScrollMode="never" bounces={false} showsHorizontalScrollIndicator={false}