Revert "Keep fitted terminal bottom row visible"

This reverts commit 4be9e88e018d8f10917f970324e84c3bd9c23463.
This commit is contained in:
Jinwoo-H
2026-04-30 18:03:57 -04:00
parent 85187ae4d3
commit 0498f80a97
2 changed files with 18 additions and 89 deletions
+4 -44
View File
@@ -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<string, Set<string>>()
const FIT_LAYOUT_SETTLE_MS = 250
const STATUS_LABELS: Record<ConnectionState, string> = {
connecting: 'Connecting',
@@ -84,28 +81,16 @@ const STATUS_LABELS: Record<ConnectionState, string> = {
'auth-failed': 'Auth failed'
}
function waitForFitLayoutSettle(): Promise<void> {
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 (
<View
onLayout={handleLayout}
pointerEvents={active ? 'auto' : 'none'}
style={[styles.terminalPane, !active && styles.terminalPaneHidden]}
>
@@ -197,7 +172,6 @@ export default function SessionScreen() {
// Without this, switching away and back would auto-fit them again.
const manuallyRestoredRef = useRef<Set<string>>(new Set())
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
const terminalViewportRef = useRef<Map<string, TerminalViewport>>(new Map())
const terminalUnsubsRef = useRef<Map<string, () => void>>(new Map())
const subscribingHandlesRef = useRef<Set<string>>(new Set())
const initializedHandlesRef = useRef<Set<string>>(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}
/>
))}
</View>
+14 -45
View File
@@ -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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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 = `<!DOCTYPE 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<TerminalWebViewHandle, Props>(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.