diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 0b02ca5..a4953aa 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, @@ -294,7 +308,20 @@ 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. + 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 { @@ -408,7 +435,7 @@ const TerminalComponent = forwardRef( fastScrollModifier: 'alt', fastScrollSensitivity: 5, allowProposedApi: true, - disableStdin: true, + disableStdin: false, cursorInactiveStyle: '${terminalConfig.cursorStyle || "bar"}' }); @@ -417,6 +444,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'); @@ -679,32 +715,106 @@ 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); }); - // 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). + // 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; 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; }, { 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; - var lines = Math.trunc(dy / lineH); - if (lines !== 0) terminal.scrollLines(lines); - }, { passive: true, capture: true }); + 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: false, capture: true }); terminalElement.addEventListener('touchend', function() { scrollTouchY = null; + pendingLines = 0; }, { passive: true, capture: true }); })(); @@ -819,6 +929,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 +957,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; @@ -850,6 +987,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); @@ -979,6 +1122,10 @@ const TerminalComponent = forwardRef( clearTimeout(accessibilityTimerRef.current); accessibilityTimerRef.current = null; } + if (viewportDebounceTimerRef.current) { + clearTimeout(viewportDebounceTimerRef.current); + viewportDebounceTimerRef.current = null; + } }; }, []); @@ -1027,6 +1174,7 @@ const TerminalComponent = forwardRef( return ( ( `WebView HTTP error: ${nativeEvent.statusCode}`, ); }} - scrollEnabled={true} + scrollEnabled={false} overScrollMode="never" bounces={false} showsHorizontalScrollIndicator={false}