From 0498f80a976a1f840f48cdbad2863bf84ef0b9df Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 30 Apr 2026 02:41:14 -0400 Subject: [PATCH] Revert "Keep fitted terminal bottom row visible" This reverts commit 4be9e88e018d8f10917f970324e84c3bd9c23463. --- .../app/h/[hostId]/session/[worktreeId].tsx | 48 ++------------- mobile/src/terminal/TerminalWebView.tsx | 59 +++++-------------- 2 files changed, 18 insertions(+), 89 deletions(-) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 8105db40226..60fccf736b9 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -8,8 +8,7 @@ import { Pressable, KeyboardAvoidingView, Platform, - ActivityIndicator, - type LayoutChangeEvent + ActivityIndicator } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useLocalSearchParams, useRouter } from 'expo-router' @@ -47,7 +46,6 @@ type TerminalCreateResult = { } type AccessoryKey = { label: string; bytes: string; accessibilityLabel?: string } -type TerminalViewport = { width: number; height: number } const ACCESSORY_KEYS: AccessoryKey[] = [ { label: 'Esc', bytes: '\x1b' }, @@ -73,7 +71,6 @@ const ACCESSORY_KEYS: AccessoryKey[] = [ // Capped to avoid unbounded growth over long app sessions. const MAX_PERSISTED_WORKTREES = 50 const persistedFittedHandles = new Map>() -const FIT_LAYOUT_SETTLE_MS = 250 const STATUS_LABELS: Record = { connecting: 'Connecting', @@ -84,28 +81,16 @@ const STATUS_LABELS: Record = { 'auth-failed': 'Auth failed' } -function waitForFitLayoutSettle(): Promise { - return new Promise((resolve) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - setTimeout(resolve, FIT_LAYOUT_SETTLE_MS) - }) - }) - }) -} - function TerminalPaneView({ handle, active, onRef, - onWebReady, - onLayout + onWebReady }: { handle: string active: boolean onRef: (handle: string, ref: TerminalWebViewHandle | null) => void onWebReady: (handle: string) => void - onLayout: (handle: string, viewport: TerminalViewport) => void }) { const setRef = useCallback( (ref: TerminalWebViewHandle | null) => { @@ -113,19 +98,9 @@ function TerminalPaneView({ }, [handle, onRef] ) - const handleLayout = useCallback( - (event: LayoutChangeEvent) => { - const { width, height } = event.nativeEvent.layout - if (width > 0 && height > 0) { - onLayout(handle, { width, height }) - } - }, - [handle, onLayout] - ) return ( @@ -197,7 +172,6 @@ export default function SessionScreen() { // Without this, switching away and back would auto-fit them again. const manuallyRestoredRef = useRef>(new Set()) const terminalRefs = useRef>(new Map()) - const terminalViewportRef = useRef>(new Map()) const terminalUnsubsRef = useRef void>>(new Map()) const subscribingHandlesRef = useRef>(new Set()) const initializedHandlesRef = useRef>(new Set()) @@ -212,10 +186,6 @@ export default function SessionScreen() { return handle ? terminalRefs.current.get(handle) : undefined }, []) - const setTerminalViewport = useCallback((handle: string, viewport: TerminalViewport) => { - terminalViewportRef.current.set(handle, viewport) - }, []) - const unsubscribeTerminal = useCallback((handle: string) => { terminalUnsubsRef.current.get(handle)?.() terminalUnsubsRef.current.delete(handle) @@ -316,13 +286,7 @@ export default function SessionScreen() { if (!clientId) return const seq = subscribeSeqRef.current.get(handle) - // Why: the first session render can measure the WebView before the - // accessory/input chrome has fully settled. Waiting matches the remount - // path, where phone-fit already gets the correct unobscured height. - await waitForFitLayoutSettle() - const dims = await getTerminalRef(handle)?.measureFitDimensions( - terminalViewportRef.current.get(handle) - ) + const dims = await getTerminalRef(handle)?.measureFitDimensions() if (!dims) return if ( clientRef.current !== rpcClient || @@ -541,9 +505,7 @@ export default function SessionScreen() { // is already at the correct phone size. if (shouldAutoFit && !fitPendingRef.current) { void (async () => { - const dims = await getTerminalRef(handle)?.measureFitDimensions( - terminalViewportRef.current.get(handle) - ) + const dims = await getTerminalRef(handle)?.measureFitDimensions() if (!dims) return if (cols !== dims.cols || rows !== dims.rows) { if ( @@ -718,7 +680,6 @@ export default function SessionScreen() { subscribeToTerminal(handle) } else { terminalRefs.current.delete(handle) - terminalViewportRef.current.delete(handle) } }, [subscribeToTerminal] @@ -974,7 +935,6 @@ export default function SessionScreen() { active={terminal.handle === activeHandle} onRef={setTerminalWebViewRef} onWebReady={handleTerminalWebReady} - onLayout={setTerminalViewport} /> ))} diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 7a248d16d7c..e2461ce8180 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -8,10 +8,7 @@ export type TerminalWebViewHandle = { write: (data: string) => void init: (cols: number, rows: number, initialData?: string) => void clear: () => void - measureFitDimensions: (viewport?: { width: number; height: number }) => Promise<{ - cols: number - rows: number - } | null> + measureFitDimensions: () => Promise<{ cols: number; rows: number } | null> resetZoom: () => void } @@ -24,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; viewportWidth?: number; viewportHeight?: number } + | { type: 'measure'; id?: number } | { type: 'reset-zoom'; id?: number } // Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor @@ -142,6 +139,11 @@ const XTERM_HTML = ` // managed by xterm, not a separate HTML gap. Never shrink below the // original init row count to avoid clipping active terminal content. function adjustRowsForViewport() { + // Why: mobile replays a live PTY snapshot and then applies live cursor- + // relative chunks from that same PTY. Resizing only the WebView xterm + // changes cursor coordinates and makes TUI repaint chunks duplicate or + // overlap existing frames. Keep xterm rows identical to the PTY. + return; if (!term || !term.element) return; // Why: active alternate-screen TUIs (Claude Code, vim, etc.) are exact // screen snapshots. Locally resizing the mobile xterm after replay can @@ -177,14 +179,6 @@ const XTERM_HTML = ` return on !== -1 && on > off; } - function getAltScreenTransition(data) { - if (typeof data !== 'string') return null; - var on = data.lastIndexOf(ESC + '[?1049h'); - var off = data.lastIndexOf(ESC + '[?1049l'); - if (on === -1 && off === -1) return null; - return on > off; - } - function normalizeInitialData(data) { if (!isAltScreenActive(data)) return data; var on = data.lastIndexOf(ESC + '[?1049h'); @@ -283,18 +277,7 @@ const XTERM_HTML = ` } function write(data) { - var nextAltScreen = getAltScreenTransition(data); - if (nextAltScreen === true && !activeAltScreenSnapshot) { - activeAltScreenSnapshot = true; - // Why: alternate-screen chunks are generated for the PTY row count. - // Undo display-only row growth before xterm parses TUI coordinates. - if (term && term.rows !== initRows) term.resize(term.cols, initRows); - } writeQueue.push(data); - if (nextAltScreen === false && activeAltScreenSnapshot) { - activeAltScreenSnapshot = false; - afterWritesDrained(adjustRowsForViewport); - } pumpWrites(terminalGeneration); } @@ -304,7 +287,7 @@ const XTERM_HTML = ` } } - function measureFitDimensions(request) { + function measureFitDimensions() { if (!term || !term.element) { notify({ type: 'measure-result', cols: null, rows: null }); return; @@ -323,17 +306,10 @@ const XTERM_HTML = ` notify({ type: 'measure-result', cols: null, rows: null }); return; } - var vpWidth = request && typeof request.viewportWidth === 'number' - ? request.viewportWidth - : window.innerWidth; - var vpHeight = request && typeof request.viewportHeight === 'number' - ? request.viewportHeight - : window.innerHeight; + var vpWidth = window.innerWidth; + var vpHeight = window.innerHeight; var cols = Math.floor(vpWidth / cellWidth); - // Why: Android WebView can report a viewport that is a little taller than - // the visibly unobscured terminal area next to the native input chrome. - // Reserve one row so max scroll leaves the final line fully visible. - var rows = Math.max(8, Math.floor(vpHeight / cellHeight) - 1); + var rows = Math.floor(vpHeight / cellHeight); notify({ type: 'measure-result', cols: cols, rows: rows }); } @@ -354,7 +330,7 @@ const XTERM_HTML = ` writesDraining = false; if (term) { term.clear(); term.reset(); } } else if (msg.type === 'measure') { - measureFitDimensions(msg); + measureFitDimensions(); } else if (msg.type === 'reset-zoom') { applyFitScale(); } @@ -596,19 +572,12 @@ export const TerminalWebView = forwardRef(function clear() { postMessage({ type: 'clear' }) }, - measureFitDimensions(viewport?: { - width: number - height: number - }): Promise<{ cols: number; rows: number } | null> { + measureFitDimensions(): 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', - viewportWidth: viewport?.width, - viewportHeight: viewport?.height - }) + sendToWebView({ type: 'measure' }) // 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.