fix(mobile): keep main-buffer TUI footer above the iOS keyboard (#9178)

* fix(mobile): keep main-buffer TUI footer above the iOS keyboard

The iOS keyboard-avoidance lift anchored on the terminal cursor row. Pi's
TUI renders in the main screen buffer (not the alternate screen) with its
footer/status rows below the input caret, so the altScreen full-lift branch
was skipped and those rows stayed under the raised dock / keyboard.

Anchor the lift on the bottom-most non-blank viewport row instead of just
the cursor: the WebView now emits contentBottomRow, and the lift uses
max(cursorY, contentBottomRow). This generalizes the alt-screen case,
keeps short output at the top put, and matches prior behavior for a
scrolled shell prompt.

Extracted the lift into a pure, unit-tested function
(terminal-keyboard-avoidance-lift.ts) and moved metrics parsing into a
tested helper on the contract.

* fix(mobile): preserve keyboard metrics through notification dispatch

* fix(mobile): harden terminal keyboard metrics

* fix(mobile): ignore unstyled terminal whitespace

* fix(mobile): preserve decorated terminal whitespace

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
Shahar Mor
2026-08-03 20:25:46 -07:00
committed by GitHub
co-authored by OrcaWin
parent a7ed5a45c2
commit 026ed921c1
11 changed files with 542 additions and 37 deletions
+7 -18
View File
@@ -107,6 +107,7 @@ import type {
TerminalWebViewHandle
} from '../../../../src/terminal/terminal-webview-contract'
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift'
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
import {
getDefaultTerminalAccessoryBuiltInIds,
@@ -3599,6 +3600,7 @@ export default function SessionScreen() {
if (
current &&
current.cursorY === metrics.cursorY &&
current.contentBottomRow === metrics.contentBottomRow &&
current.rows === metrics.rows &&
current.altScreen === metrics.altScreen
) {
@@ -4280,24 +4282,11 @@ 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 cursor would sit under the raised input dock; short top output stays put.
return Math.min(keyboardLift, Math.max(0, cursorBottom + margin - dockTop))
})()
const activeTerminalKeyboardLift = computeActiveTerminalKeyboardLift({
keyboardLift,
metrics: activeHandle ? terminalKeyboardMetrics.get(activeHandle) : undefined,
terminalFrameHeight: terminalFrameHeightRef.current
})
const toastAnimatedStyle = {
opacity: toastOpacityRef.current,
transform: [{ translateY: -keyboardLift }]
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import { computeActiveTerminalKeyboardLift } from './terminal-keyboard-avoidance-lift'
import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract'
import type { TerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract'
const FRAME_HEIGHT = 800
const ROWS = 40
const KEYBOARD_LIFT = 300
function metrics(
overrides: Partial<TerminalKeyboardAvoidanceMetrics> = {}
): TerminalKeyboardAvoidanceMetrics {
return { cursorY: 0, contentBottomRow: 0, rows: ROWS, altScreen: false, ...overrides }
}
describe('computeActiveTerminalKeyboardLift', () => {
it('returns 0 when the keyboard is closed', () => {
expect(
computeActiveTerminalKeyboardLift({
keyboardLift: 0,
metrics: metrics({ cursorY: 30, contentBottomRow: 34 }),
terminalFrameHeight: FRAME_HEIGHT
})
).toBe(0)
})
it('falls back to the full lift when metrics are missing', () => {
expect(
computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: undefined,
terminalFrameHeight: FRAME_HEIGHT
})
).toBe(KEYBOARD_LIFT)
})
it('falls back to the full lift when rows or frame height are unmeasured', () => {
expect(
computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: metrics({ rows: 0 }),
terminalFrameHeight: FRAME_HEIGHT
})
).toBe(KEYBOARD_LIFT)
expect(
computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: metrics(),
terminalFrameHeight: 0
})
).toBe(KEYBOARD_LIFT)
})
it('lifts fully for alt-screen TUIs', () => {
expect(
computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: metrics({ cursorY: 10, contentBottomRow: 10, altScreen: true }),
terminalFrameHeight: FRAME_HEIGHT
})
).toBe(KEYBOARD_LIFT)
})
it('clears a main-buffer footer while an old payload retains cursor-only behavior', () => {
const candidate = computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: metrics({ cursorY: 30, contentBottomRow: 34 }),
terminalFrameHeight: FRAME_HEIGHT
})
const oldPayload = parseTerminalKeyboardAvoidanceMetrics({ cursorY: 30, rows: ROWS })
const cursorOnly = computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: oldPayload,
terminalFrameHeight: FRAME_HEIGHT
})
expect({ candidate, cursorOnly }).toEqual({ candidate: 220, cursorOnly: 140 })
})
it('keeps short output near the top put (no lift)', () => {
expect(
computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: metrics({ cursorY: 2, contentBottomRow: 5 }),
terminalFrameHeight: FRAME_HEIGHT
})
).toBe(0)
})
it('matches cursor-clearing behavior for a scrolled shell (prompt at the bottom)', () => {
const lift = computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: metrics({ cursorY: 38, contentBottomRow: 38 }),
terminalFrameHeight: FRAME_HEIGHT
})
expect(lift).toBe(KEYBOARD_LIFT)
})
it('never exceeds the keyboard lift', () => {
const lift = computeActiveTerminalKeyboardLift({
keyboardLift: KEYBOARD_LIFT,
metrics: metrics({ cursorY: 39, contentBottomRow: 39 }),
terminalFrameHeight: FRAME_HEIGHT
})
expect(lift).toBeLessThanOrEqual(KEYBOARD_LIFT)
})
it('uses the platform-adjusted lift proportionally on iOS and Android', () => {
const tuiMetrics = metrics({ cursorY: 30, contentBottomRow: 34 })
const android = computeActiveTerminalKeyboardLift({
keyboardLift: 300,
metrics: tuiMetrics,
terminalFrameHeight: FRAME_HEIGHT
})
const ios = computeActiveTerminalKeyboardLift({
keyboardLift: 266,
metrics: tuiMetrics,
terminalFrameHeight: FRAME_HEIGHT
})
expect({ android, ios }).toEqual({ android: 220, ios: 186 })
})
})
@@ -0,0 +1,29 @@
import type { TerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract'
type ActiveTerminalKeyboardLiftParams = {
keyboardLift: number
metrics: TerminalKeyboardAvoidanceMetrics | undefined
terminalFrameHeight: number
}
export function computeActiveTerminalKeyboardLift(
params: ActiveTerminalKeyboardLiftParams
): number {
const { keyboardLift, metrics, terminalFrameHeight } = params
if (keyboardLift <= 0) {
return 0
}
if (!metrics || metrics.rows <= 0 || terminalFrameHeight <= 0) {
return keyboardLift
}
if (metrics.altScreen) {
return keyboardLift
}
const rowHeight = terminalFrameHeight / metrics.rows
// Main-buffer TUI footer rows can sit below the caret.
const anchorRow = Math.max(metrics.cursorY, metrics.contentBottomRow)
const anchorBottom = (anchorRow + 1) * rowHeight
const dockTop = terminalFrameHeight - keyboardLift
const margin = rowHeight
return Math.min(keyboardLift, Math.max(0, anchorBottom + margin - dockTop))
}
@@ -0,0 +1,43 @@
export const TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS = `
function lineHasVisibleContent(line, cell) {
if (line.translateToString(true).trim().length > 0) return true;
if (!cell || !line.getCell) return false;
var limit = Math.min(term.cols || 0, line.length || 0);
for (var x = 0; x < limit; x++) {
var current = line.getCell(x, cell);
if (!current) continue;
if (!current.isBgDefault() || current.isInverse()) return true;
if (typeof current.isUnderline === 'function' && current.isUnderline()) return true;
if (typeof current.isStrikethrough === 'function' && current.isStrikethrough()) return true;
if (typeof current.isOverline === 'function' && current.isOverline()) return true;
}
return false;
}
function computeContentBottomRow() {
if (!term || !term.buffer || !term.buffer.active) return 0;
var buffer = term.buffer.active;
var top = buffer.viewportY || 0;
var cell = buffer.getNullCell ? buffer.getNullCell() : null;
for (var y = (term.rows || 0) - 1; y >= 0; y--) {
try {
var line = buffer.getLine(top + y);
if (line && lineHasVisibleContent(line, cell)) return y;
} catch (e) {}
}
return 0;
}
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,
contentBottomRow: alt ? 0 : computeContentBottomRow(),
rows: term.rows || 0,
altScreen: alt
});
}
`
@@ -0,0 +1,215 @@
import { readFileSync } from 'node:fs'
import { Script } from 'node:vm'
import { Terminal } from '@xterm/xterm'
import { describe, expect, it, vi } from 'vitest'
import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected'
import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract'
const terminalHtmlSource = readFileSync(
new URL('./terminal-webview-html.ts', import.meta.url),
'utf8'
)
const reflowSource = readFileSync(
new URL('./terminal-webview-reflow-injected.ts', import.meta.url),
'utf8'
)
type Cell = { isBgDefault: () => boolean; isInverse: () => number }
type MetricsNotification = {
type: string
cursorY: number
contentBottomRow: number
rows: number
altScreen: boolean
}
function makeLine(text = '', styledColumns: number[] = []) {
const styled = new Set(styledColumns)
return {
isWrapped: false,
length: 10,
translateToString: vi.fn(() => text),
getCell: (column: number): Cell => ({
isBgDefault: () => !styled.has(column),
isInverse: () => 0
})
}
}
function runMetrics(lines: (ReturnType<typeof makeLine> | undefined)[], altScreen = false) {
const notifications: Record<string, unknown>[] = []
const buffer = {
cursorY: 2,
viewportY: 3,
type: altScreen ? 'alternate' : 'normal',
getLine: (index: number) => lines[index - 3],
getNullCell: () => ({})
}
const context = {
notifications,
notify: (message: Record<string, unknown>) => notifications.push(message),
term: { buffer: { active: buffer }, cols: 10, rows: lines.length }
}
new Script(
`${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();`
).runInNewContext(context)
return notifications[0] as MetricsNotification
}
function runTerminalMetrics(term: Terminal) {
const notifications: Record<string, unknown>[] = []
new Script(
`${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();`
).runInNewContext({
notify: (message: Record<string, unknown>) => notifications.push(message),
term
})
return notifications[0] as MetricsNotification
}
function write(term: Terminal, data: string): Promise<void> {
return new Promise((resolve) => term.write(data, resolve))
}
describe('terminal keyboard-avoidance WebView metrics', () => {
it('finds text on wrapped rows using the visible viewport offset', () => {
const lines = [makeLine('header'), makeLine(''), makeLine('wrapped footer')]
lines[2]!.isWrapped = true
expect(runMetrics(lines)).toMatchObject({ contentBottomRow: 2 })
})
it('supports cells without decoration APIs and keeps background-only ANSI chrome visible', () => {
expect(runMetrics([makeLine('header'), makeLine(''), makeLine('')])).toMatchObject({
contentBottomRow: 0
})
expect(runMetrics([makeLine('header'), makeLine(''), makeLine('', [4])])).toMatchObject({
contentBottomRow: 2
})
})
it('classifies real xterm text and styled whitespace by rendered visibility', async () => {
const cases = [
{ name: 'default spaces', data: ' ', expected: 0 },
{ name: 'text', data: 'footer', expected: 7 },
{ name: 'background', data: '\x1b[41m \x1b[0m', expected: 7 },
{ name: 'inverse', data: '\x1b[7m \x1b[0m', expected: 7 },
{ name: 'underline', data: '\x1b[4m \x1b[0m', expected: 7 },
{ name: 'strikethrough', data: '\x1b[9m \x1b[0m', expected: 7 },
{ name: 'overline', data: '\x1b[53m \x1b[0m', expected: 7 },
// Hidden text still reserves TUI layout, so keyboard avoidance treats it as content.
{ name: 'invisible text', data: '\x1b[8mfooter\x1b[0m', expected: 7 }
]
for (const { name, data, expected } of cases) {
const term = new Terminal({ cols: 10, rows: 8 })
try {
await write(term, `\x1b[8;1H${data}`)
expect(runTerminalMetrics(term), name).toMatchObject({ contentBottomRow: expected })
} finally {
term.dispose()
}
}
})
it('tracks the real xterm viewport and alternate screen', async () => {
const term = new Terminal({ cols: 10, rows: 4, scrollback: 100 })
try {
await write(term, 'header\r\n\r\n\r\n\r\nfooter')
expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 3, altScreen: false })
term.scrollLines(-2)
expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 0, altScreen: false })
await write(term, '\x1b[?1049h\x1b[4m \x1b[0m')
expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 0, altScreen: true })
} finally {
term.dispose()
}
})
it('follows real xterm resize and reset state', async () => {
const term = new Terminal({ cols: 10, rows: 8 })
try {
await write(term, '\x1b[8;1Hfooter')
expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 7 })
term.resize(10, 4)
expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 3 })
term.reset()
expect(runTerminalMetrics(term)).toMatchObject({ contentBottomRow: 0 })
} finally {
term.dispose()
}
})
it('keeps real xterm metrics compatible with old payloads', async () => {
const term = new Terminal({ cols: 10, rows: 8 })
try {
await write(term, '\x1b[8;1Hfooter\x1b[2;1H')
const { cursorY, rows, altScreen } = runTerminalMetrics(term)
expect(parseTerminalKeyboardAvoidanceMetrics({ cursorY, rows, altScreen })).toEqual({
cursorY: 1,
contentBottomRow: 1,
rows: 8,
altScreen: false
})
} finally {
term.dispose()
}
})
it('releases real xterm metric observers across terminal lifecycles', async () => {
for (let cycle = 0; cycle < 25; cycle += 1) {
const term = new Terminal({ cols: 10, rows: 4 })
let emissions = 0
const observer = term.onWriteParsed(() => {
runTerminalMetrics(term)
emissions += 1
})
try {
await write(term, `cycle ${cycle}`)
expect(emissions).toBeGreaterThan(0)
observer.dispose()
const disposedAt = emissions
await write(term, ' after dispose')
expect(emissions).toBe(disposedAt)
} finally {
observer.dispose()
term.dispose()
}
}
})
it('stops at the first bottom-up match and skips scans on alternate screen', () => {
const footer = makeLine('footer')
const header = makeLine('header')
expect(runMetrics([header, makeLine(''), footer])).toMatchObject({ contentBottomRow: 2 })
expect(footer.translateToString).toHaveBeenCalledTimes(1)
expect(header.translateToString).not.toHaveBeenCalled()
footer.translateToString.mockImplementation(() => {
throw new Error('alternate screen must not scan')
})
expect(runMetrics([header, makeLine(''), footer], true)).toMatchObject({
altScreen: true,
contentBottomRow: 0
})
})
it('refreshes metrics after every buffer geometry reset', () => {
const resizeStart = terminalHtmlSource.indexOf(' function resize(cols, rows)')
const resizeEnd = terminalHtmlSource.indexOf('\n // reflow()', resizeStart)
const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {")
const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart)
const textScaleStart = terminalHtmlSource.indexOf(' function applyTextScale(scale)')
const textScaleEnd = terminalHtmlSource.indexOf('\n var panX', textScaleStart)
for (const block of [
terminalHtmlSource.slice(resizeStart, resizeEnd),
terminalHtmlSource.slice(clearStart, clearEnd),
terminalHtmlSource.slice(textScaleStart, textScaleEnd),
reflowSource
]) {
expect(block.indexOf('emitKeyboardAvoidanceMetrics()')).toBeGreaterThan(
block.includes('term.resize') ? block.indexOf('term.resize') : block.indexOf('term.reset')
)
}
})
})
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract'
describe('parseTerminalKeyboardAvoidanceMetrics', () => {
it('parses a full payload', () => {
expect(
parseTerminalKeyboardAvoidanceMetrics({
cursorY: 30,
contentBottomRow: 34,
rows: 40,
altScreen: true
})
).toEqual({ cursorY: 30, contentBottomRow: 34, rows: 40, altScreen: true })
})
it('defaults contentBottomRow to cursorY when absent (older WebView bundles)', () => {
expect(parseTerminalKeyboardAvoidanceMetrics({ cursorY: 12, rows: 40 })).toEqual({
cursorY: 12,
contentBottomRow: 12,
rows: 40,
altScreen: false
})
})
it('defaults non-numeric fields to zero', () => {
expect(parseTerminalKeyboardAvoidanceMetrics({})).toEqual({
cursorY: 0,
contentBottomRow: 0,
rows: 0,
altScreen: false
})
})
it('bounds untrusted numeric fields to the reported viewport', () => {
expect(
parseTerminalKeyboardAvoidanceMetrics({
cursorY: Number.POSITIVE_INFINITY,
contentBottomRow: 99.8,
rows: 40.7,
altScreen: 'true'
})
).toEqual({ cursorY: 0, contentBottomRow: 39, rows: 40, altScreen: false })
expect(
parseTerminalKeyboardAvoidanceMetrics({
cursorY: -4,
contentBottomRow: Number.NaN,
rows: -1
})
).toEqual({ cursorY: 0, contentBottomRow: 0, rows: 0, altScreen: false })
})
})
@@ -14,10 +14,34 @@ export type TerminalModes = {
export type TerminalKeyboardAvoidanceMetrics = {
cursorY: number
// Main-buffer TUIs can render footer rows below the caret.
contentBottomRow: number
rows: number
altScreen: boolean
}
export function parseTerminalKeyboardAvoidanceMetrics(
msg: Record<string, unknown>
): TerminalKeyboardAvoidanceMetrics {
const rows = toNonNegativeInteger(msg.rows)
const maxRow = Math.max(0, rows - 1)
const cursorY = Math.min(toNonNegativeInteger(msg.cursorY), maxRow)
const contentBottomRow =
msg.contentBottomRow === undefined
? cursorY
: Math.min(toNonNegativeInteger(msg.contentBottomRow), maxRow)
return {
cursorY,
contentBottomRow,
rows,
altScreen: msg.altScreen === true
}
}
function toNonNegativeInteger(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0
}
export type MobileTerminalTheme = RuntimeMobileTerminalTheme
export type TerminalSelectionEvents = {
+5 -11
View File
@@ -3,6 +3,7 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-typ
import { colors } from '../theme/mobile-theme'
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
import { TERMINAL_SURFACE_SWAP_JS } from './terminal-webview-surface-swap-injected'
@@ -287,6 +288,7 @@ window.onerror = function(msg) {
if (cols < MIN_FIT_COLS) return;
var rows = Math.max(8, Math.floor(window.innerHeight / cellH));
term.resize(cols, rows);
emitKeyboardAvoidanceMetrics();
}
applyFitScale('text-scale');
});
@@ -802,6 +804,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
if (!term) return;
initRows = rows || initRows;
term.resize(cols || term.cols, rows || term.rows);
emitKeyboardAvoidanceMetrics();
applyFitScale('resize-msg');
notify({ type: 'ready', cols: cols, rows: rows });
}
@@ -958,6 +961,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
initialOscLinkEvictionReady = false;
if (term) { term.clear(); term.reset(); }
emitModesIfChanged();
emitKeyboardAvoidanceMetrics();
resetEvictionCounter();
if (selMode === 'select') {
notify({ type: 'selection-evicted' });
@@ -1100,17 +1104,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
sgrMousePixelsMode: 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
});
}
${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}
function attachTermObservers() {
if (!term) return;
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest'
import { dispatchTerminalWebViewNotification } from './terminal-webview-notification-dispatch'
describe('dispatchTerminalWebViewNotification', () => {
it('preserves bounded keyboard metrics through the dispatcher', () => {
const onKeyboardAvoidanceMetrics = vi.fn()
dispatchTerminalWebViewNotification(
{
type: 'keyboard-avoidance-metrics',
cursorY: 30,
contentBottomRow: 99,
rows: 40,
altScreen: true
},
{ onKeyboardAvoidanceMetrics, reportEngineError: vi.fn() }
)
expect(onKeyboardAvoidanceMetrics).toHaveBeenCalledWith({
cursorY: 30,
contentBottomRow: 39,
rows: 40,
altScreen: true
})
})
it('keeps old WebView payloads cursor-compatible', () => {
const onKeyboardAvoidanceMetrics = vi.fn()
dispatchTerminalWebViewNotification(
{ type: 'keyboard-avoidance-metrics', cursorY: 12, rows: 40 },
{ onKeyboardAvoidanceMetrics, reportEngineError: vi.fn() }
)
expect(onKeyboardAvoidanceMetrics).toHaveBeenCalledWith({
cursorY: 12,
contentBottomRow: 12,
rows: 40,
altScreen: false
})
})
})
@@ -1,4 +1,7 @@
import type { TerminalSelectionEvents } from './terminal-webview-contract'
import {
parseTerminalKeyboardAvoidanceMetrics,
type TerminalSelectionEvents
} from './terminal-webview-contract'
export type TerminalWebViewNotificationHandlers = Omit<
TerminalSelectionEvents,
@@ -63,13 +66,7 @@ export function dispatchTerminalWebViewNotification(
handlers.onOpenUrl?.(url)
}
} 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
handlers.onKeyboardAvoidanceMetrics?.({
cursorY,
rows,
altScreen: !!msg.altScreen
})
handlers.onKeyboardAvoidanceMetrics?.(parseTerminalKeyboardAvoidanceMetrics(msg))
} else if (msg.type === 'haptic') {
const kind = msg.kind
if (kind === 'selection' || kind === 'success' || kind === 'error' || kind === 'edge-bump') {
@@ -28,5 +28,6 @@ export const TERMINAL_REFLOW_JS = `
}
applyFitScale('reflow-msg');
updateScrollIndicator(false);
emitKeyboardAvoidanceMetrics();
}
`