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 <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-04-30 18:03:57 -04:00
co-authored by Orca
parent 3b66647aa0
commit 856fec166e
2 changed files with 33 additions and 15 deletions
+16 -3
View File
@@ -155,6 +155,10 @@ export default function SessionScreen() {
const activeHandleRef = useRef<string | null>(null)
const subscribeSeqRef = useRef<Map<string, number>>(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<number>(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() {
</Pressable>
</View>
) : (
<View style={styles.terminalFrame}>
<View
style={styles.terminalFrame}
onLayout={(e) => {
terminalFrameHeightRef.current = e.nativeEvent.layout.height
}}
>
{terminals.map((terminal) => (
<TerminalPaneView
key={terminal.handle}
+17 -12
View File
@@ -8,7 +8,7 @@ export type TerminalWebViewHandle = {
write: (data: string) => 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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<TerminalWebViewHandle, Props>(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.