mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user