fix(mobile): re-fit terminal PTY when the frame height settles (#8647)

* fix(mobile): re-fit terminal PTY when the frame height settles

A freshly-created agent terminal fits its PTY to rows = floor(frameHeight
/ cellHeight) before the accessory/live-input dock has laid out, so the
frame is briefly too tall and the PTY gets too many rows. Claude/Codex
pin their input box to the bottom of the grid, so those extra bottom rows
— the input box and status lines — render behind the dock and you can't
see what you're typing. Leaving and re-entering the workspace worked
around it by re-measuring against the settled layout.

The refit hook previously re-fit only on width changes and deliberately
ignored height-only changes, so the over-fit was never corrected. Track
the measured frame height and re-fit on its change too, mirroring the
width path. Safe because Expo SDK 55's edge-to-edge IME overlays instead
of resizing, so the frame height doesn't change on keyboard toggle and
the PTY is never reflowed while typing; the refit's row-count guard makes
sub-row jitter a no-op.

* fix(mobile): guard height refit against IME resize; test the decision

Address review on #8647:
- Extract shouldRefitOnFrameHeightChange (pure) and gate the height refit on
  keyboard-visible, so an IME that resizes the window (Android adjustResize)
  can never reflow the PTY while typing — no longer relies on the edge-to-edge
  no-resize assumption alone.
- Add a behavioral test for the decision helper (height transition, same-value
  no-op, keyboard-open skip) instead of only source-string assertions.
- Trim the added comments to 1-2 lines per AGENTS.md.
This commit is contained in:
Brennan Benson
2026-07-14 01:33:27 -07:00
committed by GitHub
parent 2b8cec36d9
commit e04ca97dc2
4 changed files with 110 additions and 4 deletions
+15 -2
View File
@@ -1080,6 +1080,13 @@ export default function SessionScreen() {
// the row) without any window-dim change. Tracking the measured width lets the
// refit hook re-fit the PTY on those resizes — see terminal-viewport-refit.ts.
const [terminalFrameWidth, setTerminalFrameWidth] = useState(0)
// Why: a new agent terminal can fit before the accessory/live-input dock lays
// out, over-fitting the PTY so its bottom-pinned input box hides behind the
// dock; tracking the settled height lets the refit hook correct it.
const [terminalFrameHeight, setTerminalFrameHeight] = useState(0)
// Why: lets the height refit skip keyboard-driven resizes (never reflow the
// PTY per keystroke); mirrors keyboardHeight but readable synchronously.
const keyboardVisibleRef = useRef(false)
const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null
const {
@@ -2602,15 +2609,19 @@ export default function SessionScreen() {
tabStripVisible: terminals.length > 1,
textScale: terminalTextScale,
terminalFrameWidth,
terminalFrameHeight,
keyboardVisibleRef,
unsubscribeTerminal,
subscribeToTerminal
})
useEffect(() => {
const onShow = (e: KeyboardEvent) => {
keyboardVisibleRef.current = true
setKeyboardHeight(e.endCoordinates?.height ?? 0)
}
const onHide = () => {
keyboardVisibleRef.current = false
setKeyboardHeight(0)
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
@@ -4831,10 +4842,12 @@ export default function SessionScreen() {
style={styles.terminalFrame}
onLayout={(e) => {
terminalFrameHeightRef.current = e.nativeEvent.layout.height
// Trigger a refit only when the width actually changes (sidebar
// resize, fold, rotation) — avoids churn on height-only changes.
// Track width AND height so the refit hook re-fits on sidebar/
// fold/rotation (width) and on the dock settling (height).
const nextWidth = Math.round(e.nativeEvent.layout.width)
const nextHeight = Math.round(e.nativeEvent.layout.height)
setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth))
setTerminalFrameHeight((prev) => (prev === nextHeight ? prev : nextHeight))
}}
>
{terminals.map((terminal) => (
@@ -24,6 +24,20 @@ export function isTerminalUpdateViewportApplied(response: RpcResponse): boolean
return (response.result as { applied?: unknown }).applied === true
}
// Why: re-fit when the flex-bounded frame height settles, but never while the
// keyboard is visible so an IME window resize (Android adjustResize) can't
// reflow the PTY per keystroke — it re-fits again once the keyboard closes.
export function shouldRefitOnFrameHeightChange(state: {
previousHeight: number
nextHeight: number
keyboardVisible: boolean
}): boolean {
if (state.keyboardVisible) {
return false
}
return state.previousHeight !== state.nextHeight
}
export function isTerminalViewportRefitTargetCurrent(
state: TerminalViewportRefitTargetState
): boolean {
@@ -4,7 +4,8 @@ import type { RpcResponse } from '../transport/types'
import {
isTerminalUpdateViewportApplied,
isTerminalUpdateViewportUpdated,
isTerminalViewportRefitTargetCurrent
isTerminalViewportRefitTargetCurrent,
shouldRefitOnFrameHeightChange
} from './terminal-viewport-refit-state'
const hookSource = readFileSync(new URL('./terminal-viewport-refit.ts', import.meta.url), 'utf8')
@@ -48,11 +49,59 @@ describe('terminal viewport refit', () => {
expect(textScaleEffect).toContain('[textScale, viewportMeasuredRef, scheduleViewportRefit]')
})
it('refits on a frame-height change only while the keyboard is closed', () => {
// The dock settling after a new agent terminal's first fit changes the frame
// height; refitting then stops the PTY over-fitting behind the dock. An IME
// that resizes the window (Android) must not reflow the PTY while typing.
// Height settled, keyboard closed → refit.
expect(
shouldRefitOnFrameHeightChange({
previousHeight: 600,
nextHeight: 520,
keyboardVisible: false
})
).toBe(true)
// Unchanged height → no refit (don't churn on unrelated re-layouts).
expect(
shouldRefitOnFrameHeightChange({
previousHeight: 520,
nextHeight: 520,
keyboardVisible: false
})
).toBe(false)
// Height changed while the keyboard is up → skip (never reflow while typing).
expect(
shouldRefitOnFrameHeightChange({
previousHeight: 600,
nextHeight: 320,
keyboardVisible: true
})
).toBe(false)
})
it('routes the height effect through the keyboard-guarded decision helper', () => {
const start = hookSource.indexOf('const prevFrameHeightRef = useRef(terminalFrameHeight)')
expect(start).toBeGreaterThanOrEqual(0)
const heightEffect = hookSource.slice(start, start + 500)
expect(heightEffect).toContain('shouldRefitOnFrameHeightChange({')
expect(heightEffect).toContain('keyboardVisible: keyboardVisibleRef.current')
expect(heightEffect).toContain('viewportMeasuredRef.current = false')
expect(heightEffect).toContain('scheduleViewportRefit()')
})
it('is wired into the session screen', () => {
expect(sessionSource).toContain('useTerminalViewportRefit({')
expect(sessionSource).toContain('tabStripVisible: terminals.length > 1')
expect(sessionSource).toContain('textScale: terminalTextScale')
expect(sessionSource).toContain('connState,')
// The session must feed the frame height and the keyboard-visible ref, or the
// guarded height effect never sees the dock settle / keyboard state.
expect(sessionSource).toContain('terminalFrameHeight,')
expect(sessionSource).toContain('keyboardVisibleRef,')
expect(sessionSource).toContain('keyboardVisibleRef.current = true')
expect(sessionSource).toContain(
'setTerminalFrameHeight((prev) => (prev === nextHeight ? prev : nextHeight))'
)
})
it('forces a refit on iOS foreground and connection recovery', () => {
+31 -1
View File
@@ -7,7 +7,8 @@ import { shouldRecoverTerminalOnAppStateChange } from './terminal-foreground-rec
import {
isTerminalUpdateViewportApplied,
isTerminalUpdateViewportUpdated,
isTerminalViewportRefitTargetCurrent
isTerminalViewportRefitTargetCurrent,
shouldRefitOnFrameHeightChange
} from './terminal-viewport-refit-state'
export type TerminalViewportDims = { cols: number; rows: number }
@@ -32,6 +33,12 @@ type TerminalViewportRefitOptions = {
// tab-strip change. Carries that measured width so those resizes re-fit the PTY;
// the 150ms debounce coalesces the stream of drag widths into one settle-time refit.
terminalFrameWidth: number
// Why: the frame height settles when the accessory/live-input dock lays out
// after a new agent terminal's first fit; carrying it re-fits the PTY so its
// rows stop overflowing behind the dock. See shouldRefitOnFrameHeightChange.
terminalFrameHeight: number
// Why: gate the height refit so a keyboard-driven resize never reflows the PTY.
keyboardVisibleRef: RefObject<boolean>
unsubscribeTerminal: (handle: string) => void
subscribeToTerminal: (handle: string) => void
}
@@ -56,6 +63,8 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
tabStripVisible,
textScale,
terminalFrameWidth,
terminalFrameHeight,
keyboardVisibleRef,
unsubscribeTerminal,
subscribeToTerminal
} = options
@@ -226,6 +235,27 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
scheduleViewportRefit()
}, [terminalFrameWidth, viewportMeasuredRef, scheduleViewportRefit])
// Why: re-fit when the frame height settles after a new agent terminal's
// first fit so its rows stop overflowing behind the dock; the keyboard guard
// keeps an IME resize from reflowing the PTY. The refit's row-count guard
// makes a same-row height change a no-op.
const prevFrameHeightRef = useRef(terminalFrameHeight)
useEffect(() => {
const previousHeight = prevFrameHeightRef.current
prevFrameHeightRef.current = terminalFrameHeight
if (
!shouldRefitOnFrameHeightChange({
previousHeight,
nextHeight: terminalFrameHeight,
keyboardVisible: keyboardVisibleRef.current
})
) {
return
}
viewportMeasuredRef.current = false
scheduleViewportRefit()
}, [terminalFrameHeight, keyboardVisibleRef, viewportMeasuredRef, scheduleViewportRefit])
useEffect(() => {
if (Platform.OS !== 'ios') {
return