From 856fec166ee039c39235f86b275ad7211cdcf49f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 30 Apr 2026 17:47:35 -0400 Subject: [PATCH] Use exact container height for mobile viewport measurement Instead of subtracting a hardcoded row count from window.innerHeight, measure the terminal frame's pixel height via onLayout and pass it into the WebView's measureFitDimensions. This gives pixel-accurate row calculations regardless of accessory bar height or safe-area insets. Co-authored-by: Orca --- .../app/h/[hostId]/session/[worktreeId].tsx | 19 ++++++++++-- mobile/src/terminal/TerminalWebView.tsx | 29 +++++++++++-------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 44412380032..aa20710b6a4 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -155,6 +155,10 @@ export default function SessionScreen() { const activeHandleRef = useRef(null) const subscribeSeqRef = useRef>(new Map()) const sendingRef = useRef(false) + // Why: tracks the pixel height of the terminal frame so measureFitDimensions + // can use the exact container height instead of relying on window.innerHeight, + // which can overstate the visible area due to layout timing. + const terminalFrameHeightRef = useRef(0) const canSend = connState === 'connected' && activeHandle != null @@ -189,7 +193,9 @@ export default function SessionScreen() { const measureViewportOnce = useCallback( async (handle: string) => { if (viewportMeasuredRef.current) return - const dims = await getTerminalRef(handle)?.measureFitDimensions() + const dims = await getTerminalRef(handle)?.measureFitDimensions( + terminalFrameHeightRef.current || undefined + ) if (dims) { viewportRef.current = dims viewportMeasuredRef.current = true @@ -255,7 +261,9 @@ export default function SessionScreen() { // resubscribe so the server gets the viewport and phone-fits. if (!viewportMeasuredRef.current) { void (async () => { - const dims = await getTerminalRef(handle)?.measureFitDimensions() + const dims = await getTerminalRef(handle)?.measureFitDimensions( + terminalFrameHeightRef.current || undefined + ) if (dims && !viewportMeasuredRef.current) { viewportRef.current = dims viewportMeasuredRef.current = true @@ -816,7 +824,12 @@ export default function SessionScreen() { ) : ( - + { + terminalFrameHeightRef.current = e.nativeEvent.layout.height + }} + > {terminals.map((terminal) => ( void init: (cols: number, rows: number, initialData?: string) => void clear: () => void - measureFitDimensions: () => Promise<{ cols: number; rows: number } | null> + measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null> resetZoom: () => void } @@ -21,7 +21,7 @@ type TerminalMessage = | { type: 'write'; id?: number; data: string } | { type: 'init'; id?: number; cols: number; rows: number; initialData?: string } | { type: 'clear'; id?: number } - | { type: 'measure'; id?: number } + | { type: 'measure'; id?: number; containerHeight?: number } | { type: 'reset-zoom'; id?: number } // Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor @@ -287,7 +287,7 @@ const XTERM_HTML = ` } } - function measureFitDimensions() { + function measureFitDimensions(containerHeightPx) { if (!term || !term.element) { notify({ type: 'measure-result', cols: null, rows: null }); return; @@ -307,13 +307,16 @@ const XTERM_HTML = ` return; } var vpWidth = window.innerWidth; - var vpHeight = window.innerHeight; + // Why: prefer the container height passed from React Native over + // window.innerHeight. The RN layout system knows the exact pixel + // height of the terminal frame after the accessory/input bars are + // subtracted, whereas innerHeight can overstate the visible area + // due to layout timing or safe-area insets. + var vpHeight = (typeof containerHeightPx === 'number' && containerHeightPx > 0) + ? containerHeightPx + : window.innerHeight; var cols = Math.floor(vpWidth / cellWidth); - // Why: subtract 2 rows so the bottom terminal line is never covered - // by the accessory bar. The WebView's innerHeight can slightly - // overstate the visible area due to layout timing or safe-area - // insets, causing the last rows to render behind the overlay. - var rows = Math.max(8, Math.floor(vpHeight / cellHeight) - 2); + var rows = Math.max(8, Math.floor(vpHeight / cellHeight)); notify({ type: 'measure-result', cols: cols, rows: rows }); } @@ -334,7 +337,7 @@ const XTERM_HTML = ` writesDraining = false; if (term) { term.clear(); term.reset(); } } else if (msg.type === 'measure') { - measureFitDimensions(); + measureFitDimensions(msg.containerHeight); } else if (msg.type === 'reset-zoom') { applyFitScale(); } @@ -576,12 +579,14 @@ export const TerminalWebView = forwardRef(function clear() { postMessage({ type: 'clear' }) }, - measureFitDimensions(): Promise<{ cols: number; rows: number } | null> { + measureFitDimensions( + containerHeight?: number + ): Promise<{ cols: number; rows: number } | null> { if (!isWebReadyRef.current) return Promise.resolve(null) return new Promise((resolve) => { measureResolveRef.current?.(null) measureResolveRef.current = resolve - sendToWebView({ type: 'measure' }) + sendToWebView({ type: 'measure', containerHeight }) // Why: if the WebView doesn't respond within 2s (e.g., xterm // failed to load), resolve null so the caller can disable // Fit to Phone rather than hanging indefinitely.