diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 425badb68cc..1aa036f2c86 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -40,6 +40,7 @@ import { } from '../../../../src/platform/haptics' import { TerminalWebView, + type TerminalKeyboardAvoidanceMetrics, type TerminalModes, type TerminalWebViewHandle } from '../../../../src/terminal/TerminalWebView' @@ -157,6 +158,7 @@ function TerminalPaneView({ onSelectionCopy, onSelectionEvicted, onModesChanged, + onKeyboardAvoidanceMetrics, onHaptic }: { handle: string @@ -168,6 +170,7 @@ function TerminalPaneView({ onSelectionCopy: (handle: string, text: string) => void onSelectionEvicted: (handle: string) => void onModesChanged: (handle: string, modes: TerminalModes) => void + onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void }) { const setRef = useCallback( @@ -194,6 +197,7 @@ function TerminalPaneView({ onSelectionCopy={(t) => onSelectionCopy(handle, t)} onSelectionEvicted={() => onSelectionEvicted(handle)} onModesChanged={(m) => onModesChanged(handle, m)} + onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)} onHaptic={onHaptic} /> @@ -349,6 +353,9 @@ export default function SessionScreen() { // Why: server-authoritative display mode per terminal. The runtime is the // single source of truth — this state is populated from subscribe responses. const [terminalModes, setTerminalModes] = useState>(new Map()) + const [terminalKeyboardMetrics, setTerminalKeyboardMetrics] = useState< + Map + >(new Map()) const [selectModeActive, setSelectModeActive] = useState(false) const [canPaste, setCanPaste] = useState(false) const [toastMessage, setToastMessage] = useState(null) @@ -441,6 +448,7 @@ export default function SessionScreen() { webReadyHandlesRef.current.clear() subscribeSeqRef.current.clear() layoutSeqRef.current.clear() + setTerminalKeyboardMetrics(new Map()) for (const term of terminalRefs.current.values()) { term.clear() } @@ -744,6 +752,12 @@ export default function SessionScreen() { unsubscribeTerminal(handle) terminalRefs.current.delete(handle) initializedHandlesRef.current.delete(handle) + setTerminalKeyboardMetrics((prev) => { + if (!prev.has(handle)) return prev + const next = new Map(prev) + next.delete(handle) + return next + }) } } lastKnownTerminalCountRef.current = result.terminals.length @@ -1614,6 +1628,24 @@ export default function SessionScreen() { initialModesSeenRef.current.add(handle) }, []) + const handleKeyboardAvoidanceMetrics = useCallback( + (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => { + setTerminalKeyboardMetrics((prev) => { + const current = prev.get(handle) + if ( + current && + current.cursorY === metrics.cursorY && + current.rows === metrics.rows && + current.altScreen === metrics.altScreen + ) { + return prev + } + return new Map(prev).set(handle, metrics) + }) + }, + [] + ) + const handleHaptic = useCallback((kind: 'selection' | 'success' | 'error' | 'edge-bump') => { if (kind === 'selection') triggerSelection() else if (kind === 'success') triggerSuccess() @@ -1809,7 +1841,7 @@ export default function SessionScreen() { connState === 'connected' && terminalsLoaded && visibleTabs.length === 0 && !activeHandle const terminalSummary = connState === 'connected' - ? !terminalsLoaded + ? showLoadingState ? 'Loading terminals' : visibleTabs.length === 1 ? '1 tab' @@ -1825,6 +1857,23 @@ export default function SessionScreen() { ? Math.max(0, keyboardHeight - insets.bottom) : keyboardHeight : 0 + const activeTerminalKeyboardLift = (() => { + if (keyboardLift <= 0 || !activeHandle) return 0 + const metrics = terminalKeyboardMetrics.get(activeHandle) + if (!metrics || metrics.rows <= 0 || terminalFrameHeightRef.current <= 0) { + return keyboardLift + } + if (metrics.altScreen) { + return keyboardLift + } + const rowHeight = terminalFrameHeightRef.current / metrics.rows + const cursorBottom = (metrics.cursorY + 1) * rowHeight + const dockTop = terminalFrameHeightRef.current - keyboardLift + const margin = rowHeight + // Why: only move the terminal when the active cursor would sit under the + // raised input dock. Short shell output near the top should stay put. + return Math.min(keyboardLift, Math.max(0, cursorBottom + margin - dockTop)) + })() return ( @@ -1970,13 +2019,14 @@ export default function SessionScreen() { key={terminal.handle} handle={terminal.handle} active={terminal.handle === activeHandle} - keyboardLift={keyboardLift} + keyboardLift={terminal.handle === activeHandle ? activeTerminalKeyboardLift : 0} onRef={setTerminalWebViewRef} onWebReady={handleTerminalWebReady} onSelectionMode={handleSelectionMode} onSelectionCopy={handleSelectionCopy} onSelectionEvicted={handleSelectionEvicted} onModesChanged={handleModesChanged} + onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics} onHaptic={handleHaptic} /> ))} diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 91ff7b92b46..0504c4523f2 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -9,11 +9,18 @@ export type TerminalModes = { altScreen: boolean } +export type TerminalKeyboardAvoidanceMetrics = { + cursorY: number + rows: number + altScreen: boolean +} + export type TerminalSelectionEvents = { onSelectionMode?: (active: boolean) => void onSelectionCopy?: (text: string) => void onSelectionEvicted?: () => void onModesChanged?: (modes: TerminalModes) => void + onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void } @@ -716,14 +723,34 @@ const XTERM_HTML = ` } var lastEmittedModes = { bracketedPasteMode: false, altScreen: false }; + function emitKeyboardAvoidanceMetrics() { + if (!term) return; + var alt = false; + try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} + notify({ + type: 'keyboard-avoidance-metrics', + cursorY: term.buffer && term.buffer.active ? term.buffer.active.cursorY : 0, + rows: term.rows || 0, + altScreen: alt + }); + } + function attachTermObservers() { if (!term) return; try { term.onLineFeed(logFeedAndEvict); } catch (e) {} // Why: emit modes on every parsed write so RN's mirror stays current // without round-trip; covers \\x1b[?2004h/l and alt-screen toggles. - try { term.onWriteParsed && term.onWriteParsed(emitModesIfChanged); } catch (e) {} + try { + term.onWriteParsed && term.onWriteParsed(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + }); + } catch (e) {} // Initial emit once buffer settles. - afterWritesDrained(function() { emitModesIfChanged(); }); + afterWritesDrained(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + }); } function viewportToCell(clientX, clientY) { @@ -1282,6 +1309,7 @@ export const TerminalWebView = forwardRef(function onSelectionCopy, onSelectionEvicted, onModesChanged, + onKeyboardAvoidanceMetrics, onHaptic }, ref @@ -1371,6 +1399,14 @@ export const TerminalWebView = forwardRef(function bracketedPasteMode: !!msg.bracketedPasteMode, altScreen: !!msg.altScreen }) + } else if (msg.type === 'keyboard-avoidance-metrics') { + const cursorY = typeof msg.cursorY === 'number' ? msg.cursorY : 0 + const rows = typeof msg.rows === 'number' ? msg.rows : 0 + onKeyboardAvoidanceMetrics?.({ + cursorY, + rows, + altScreen: !!msg.altScreen + }) } else if (msg.type === 'haptic') { const kind = msg.kind if ( @@ -1393,6 +1429,7 @@ export const TerminalWebView = forwardRef(function onSelectionCopy, onSelectionEvicted, onModesChanged, + onKeyboardAvoidanceMetrics, onHaptic ] )