diff --git a/app/tabs/sessions/terminal/Terminal.tsx b/app/tabs/sessions/terminal/Terminal.tsx index 3049c47..a116dc5 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"; @@ -100,6 +101,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, @@ -684,11 +698,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); @@ -826,6 +886,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); @@ -834,6 +914,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; @@ -989,6 +1076,10 @@ const TerminalComponent = forwardRef( clearTimeout(accessibilityTimerRef.current); accessibilityTimerRef.current = null; } + if (viewportDebounceTimerRef.current) { + clearTimeout(viewportDebounceTimerRef.current); + viewportDebounceTimerRef.current = null; + } }; }, []); @@ -1037,6 +1128,7 @@ const TerminalComponent = forwardRef( return (