Polish mobile terminal keyboard behavior

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-11 21:31:09 -04:00
co-authored by Orca
parent aa177cf573
commit efa739e408
2 changed files with 91 additions and 4 deletions
+52 -2
View File
@@ -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}
/>
</View>
@@ -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<Map<string, MobileDisplayMode>>(new Map())
const [terminalKeyboardMetrics, setTerminalKeyboardMetrics] = useState<
Map<string, TerminalKeyboardAvoidanceMetrics>
>(new Map())
const [selectModeActive, setSelectModeActive] = useState(false)
const [canPaste, setCanPaste] = useState(false)
const [toastMessage, setToastMessage] = useState<string | null>(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 (
<View style={styles.container}>
@@ -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}
/>
))}
+39 -2
View File
@@ -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 = `<!DOCTYPE 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<TerminalWebViewHandle, Props>(function
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic
},
ref
@@ -1371,6 +1399,14 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(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<TerminalWebViewHandle, Props>(function
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onKeyboardAvoidanceMetrics,
onHaptic
]
)