refactor(mobile): fit terminals in the app from the reported box; drop the measure round trip

The app already holds the box the document reported, so the refit and
the fit pass await the init's ready and call `handle.fitDimensions`
instead of posting `measure` and waiting on `measure-result`. The
document's measure, its retries, and the measure promise and timeout go.
The document still resizes locally on a text-size change, so every grid
the app sends (init, resize, reflow) carries the laid-out frame it was
fitted to. `holdSubscribedGrid` replaces `subscribeFitDimensions`, so
the only fits are `fitDimensionsFromCell` and `handle.fitDimensions`.
The render rig reads its fit from the ready box. The recorder adapter
mounts the new handle with the same recorded effects; the goldens it
mounts move on their adapterSha256 header only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-28 02:07:08 -04:00
parent 3ecc2265ce
commit 0471152fb6
28 changed files with 152 additions and 344 deletions
@@ -57,7 +57,7 @@ import { TerminalWebView } from ${JSON.stringify(componentPath)}
export default function TerminalProbeRoute() {
const handleRef = useRef(null)
// The terminal frame as React Native laid it out, which is what the session measures with.
// The terminal frame as React Native laid it out, which is what the session fits.
const frameRef = useRef({ width: 0, height: 0 })
const [mounted, setMounted] = useState(true)
const onSelectionCopy = useCallback((text) => {
@@ -76,8 +76,7 @@ export default function TerminalProbeRoute() {
init: (cols, rows, data) => handleRef.current?.init(cols, rows, data, false, []),
write: (data) => handleRef.current?.write(data),
selectAll: () => handleRef.current?.doSelectAll(),
measure: () =>
handleRef.current?.measureFitDimensions(frameRef.current.height, frameRef.current.width),
fit: () => handleRef.current?.fitDimensions(frameRef.current),
awaitReady: () => handleRef.current?.awaitReady(),
setMounted: (next) => setMounted(next)
}
@@ -635,15 +635,14 @@ describeRender(
await page.close()
}, 300_000)
it('measures a fit through the handle and records what beforeinput reports', async () => {
it('fits through the handle from the ready box and records what beforeinput reports', async () => {
const { page } = await openTerminal()
await openProbeTerminal(page)
// The handle's own round trip: a measure is a command in and a notify back, and on the page
// both halves are direct calls rather than a bridge. It carries the frame React Native laid
// out, as the session's does. Null would mean the document answered nothing, or answered a
// grid too small to fit.
const fit = await page.evaluate(() => globalThis.__orcaTerminalProbe.measure())
// The fit is the app's own, from the cell box the document put in web-ready, against the
// frame React Native laid out, as the session's is. Null would mean the ready carried no box,
// or a grid too small to fit.
const fit = await page.evaluate(() => globalThis.__orcaTerminalProbe.fit())
expect(fit).not.toBeNull()
expect(fit.cols).toBeGreaterThanOrEqual(20)
expect(fit.rows).toBeGreaterThanOrEqual(8)
@@ -125,8 +125,8 @@ const HEAD_CALLBACK_IDENTITY_SHA256 =
// `reportDictationFailure` instead of each choosing between the setup sheet and a toast.
// Moved when the metrics handler compared every field, the row pitch included (main).
// Again when the subscribe waited for web-ready and `handleTerminalWebReady` lost `documentHasInit`.
// Again when the subscribe's inits began carrying the laid-out terminal frame.
const HEAD_CALLBACK_BODY_SHA256 = '9149532b4c5947f081a96b1cd4a1728fb316b0b9ed96007f34664beaa4674861'
// Again when the subscribe's inits began carrying the laid-out terminal frame, and its resize too.
const HEAD_CALLBACK_BODY_SHA256 = '47b8673445f3a7ff0fc7198a70b2c91b8bbebf675307fecf1c521d031f60c599'
// Refreshed for the startup effect: both `worktree.activate` sends became `worktreeActivate`, and
// the sleeping-agent check reads that operation's verdict instead of the reply envelope. Refreshed
// again when the reporter took the reply and interpreted it itself, retiring the hand-built
@@ -15,12 +15,12 @@ const PHONE = { cols: 55, rows: 44 }
describe('sizeTerminalViewportFromCellBox', () => {
function sizeArgs(
subscribeFitDimensions: (frame: { width: number; height: number }) => typeof PHONE | null
fitDimensions: (frame: { width: number; height: number }) => typeof PHONE | null
) {
const viewportRef: { current: typeof PHONE | null } = { current: null }
return {
handle: HANDLE,
ref: { subscribeFitDimensions: vi.fn(subscribeFitDimensions) },
ref: { fitDimensions: vi.fn(fitDimensions), holdSubscribedGrid: vi.fn() },
viewportRef,
viewportMeasuredRef: { current: false },
terminalFrameWidthRef: { current: 427.5 },
@@ -32,10 +32,11 @@ describe('sizeTerminalViewportFromCellBox', () => {
it('sizes an unmeasured route from the reported cell box against the laid-out frame', () => {
const args = sizeArgs(() => PHONE)
sizeTerminalViewportFromCellBox(args)
expect(args.ref.subscribeFitDimensions).toHaveBeenCalledWith({ width: 427.5, height: 751 })
expect(args.ref.fitDimensions).toHaveBeenCalledWith({ width: 427.5, height: 751 })
expect(args.viewportRef.current).toEqual(PHONE)
expect(args.viewportMeasuredRef.current).toBe(true)
expect(args.onMeasured).toHaveBeenCalledWith(HANDLE, PHONE, 751)
expect(args.ref.holdSubscribedGrid).toHaveBeenCalledWith(PHONE)
})
it('leaves the route unmeasured when the document reported no cell box', () => {
@@ -49,7 +50,7 @@ describe('sizeTerminalViewportFromCellBox', () => {
const args = sizeArgs(() => PHONE)
args.viewportMeasuredRef.current = true
sizeTerminalViewportFromCellBox(args)
expect(args.ref.subscribeFitDimensions).not.toHaveBeenCalled()
expect(args.ref.fitDimensions).not.toHaveBeenCalled()
})
})
@@ -67,13 +68,16 @@ function subscriptionHarness(opts: {
const terminal: TerminalWebViewHandle = {
prepareForForegroundRecovery: vi.fn(),
write: vi.fn(),
init: vi.fn((cols: number, rows: number) => order.push(`init ${cols}x${rows}`)),
init: vi.fn((cols: number, rows: number) => {
order.push(`init ${cols}x${rows}`)
// An init's document reports its laid-out box before its ready.
fit = fit ?? PHONE
}),
resize: vi.fn(),
reflow: vi.fn(),
clear: vi.fn(),
fitDimensions: vi.fn(() => fit),
subscribeFitDimensions: vi.fn(() => fit),
measureFitDimensions: vi.fn(async () => fit ?? PHONE),
holdSubscribedGrid: vi.fn(),
resetZoom: vi.fn(),
cancelSelect: vi.fn(),
doSelectAll: vi.fn(),
@@ -186,7 +190,6 @@ describe('a terminal first subscribe', () => {
harness.scrollback(0, PHONE.cols, PHONE.rows)
await act(async () => {})
expect(harness.order).toEqual(['subscribe {"cols":55,"rows":44}', 'init 55x44'])
expect(harness.terminal.measureFitDimensions).not.toHaveBeenCalled()
})
it('resubscribes a document reloaded after its first ready, which lost its terminal', async () => {
@@ -3,7 +3,7 @@ import type { MutableRef, TerminalViewportDims } from './mobile-terminal-viewpor
export type TerminalViewportFromCellBoxArgs = {
handle: string
ref: Pick<TerminalWebViewHandle, 'subscribeFitDimensions'> | undefined
ref: Pick<TerminalWebViewHandle, 'fitDimensions' | 'holdSubscribedGrid'> | undefined
viewportRef: MutableRef<TerminalViewportDims | null>
viewportMeasuredRef: MutableRef<boolean>
terminalFrameWidthRef: MutableRef<number>
@@ -25,12 +25,13 @@ export function sizeTerminalViewportFromCellBox(args: TerminalViewportFromCellBo
return
}
const frameHeight = args.terminalFrameHeightRef.current
const dims = args.ref.subscribeFitDimensions({
const dims = args.ref.fitDimensions({
width: args.terminalFrameWidthRef.current,
height: frameHeight
})
args.onMeasured(args.handle, dims, frameHeight)
if (dims) {
args.ref.holdSubscribedGrid(dims)
args.viewportRef.current = dims
args.viewportMeasuredRef.current = true
}
@@ -252,7 +252,7 @@ describe('runTerminalViewportFitPass', () => {
const scheduled: { fn: () => void; ms: number }[] = []
const webView = {
awaitReady: () => Promise.resolve(),
measureFitDimensions: () => Promise.resolve(overrides.measured ?? PHONE)
fitDimensions: () => (overrides.measured === undefined ? PHONE : overrides.measured)
}
const unsubscribeTerminal = vi.fn((handle: string) => {
terminalUnsubsRef.current.delete(handle)
@@ -177,10 +177,7 @@ export type MutableRef<T> = { current: T }
type TerminalFitWebView = {
awaitReady: () => Promise<unknown>
measureFitDimensions: (
frameHeight: number,
frameWidth: number
) => Promise<TerminalViewportDims | null | undefined>
fitDimensions: (frame: { width: number; height: number }) => TerminalViewportDims | null
}
export type TerminalViewportFitPassArgs = {
@@ -239,7 +236,7 @@ export function runTerminalViewportFitPass(args: TerminalViewportFitPassArgs): v
// Why: a subscribe that carried a viewport already told the host one; a fresh measure that matches it needs no round trip.
const viewportWasMeasured = args.viewportMeasuredRef.current || args.sentViewport != null
void (async () => {
// Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06).
// Why: wait for init()'s rAF chain, which reports the box the fit reads (log dump 2026-05-06).
await args.getTerminalRef(handle)?.awaitReady()
if (
args.subscribeSeqRef.current.get(handle) !== seq ||
@@ -247,20 +244,11 @@ export function runTerminalViewportFitPass(args: TerminalViewportFitPassArgs): v
) {
return
}
const dims = await args
.getTerminalRef(handle)
?.measureFitDimensions(
args.terminalFrameHeightRef.current,
args.terminalFrameWidthRef.current
)
// Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation.
if (
args.subscribeSeqRef.current.get(handle) !== seq ||
!budget.isRetryGenerationCurrent(handle, retryGeneration)
) {
return
}
if (!args.getTerminalRef(handle) || !dims) {
const dims = args.getTerminalRef(handle)?.fitDimensions({
width: args.terminalFrameWidthRef.current,
height: args.terminalFrameHeightRef.current
})
if (!dims) {
return
}
args.viewportRef.current = dims
@@ -273,7 +273,7 @@ export function useMobileSessionTerminalSubscription(
if (serialized != null) {
getTerminalRef(handle)?.init(cols, rows, serialized, true, oscLinks, terminalFrame())
} else {
getTerminalRef(handle)?.resize(cols, rows)
getTerminalRef(handle)?.resize(cols, rows, terminalFrame())
}
if (data.displayMode) {
const displayMode = data.displayMode as MobileDisplayMode
@@ -273,8 +273,7 @@ function startMeasuredDocument() {
rows: 24,
initialData: '',
preserveScroll: false,
containerWidth: 412,
containerHeight: 600
frame: { width: 412, height: 600 }
})
const write = () => parsedWrites.forEach((listener) => listener())
return { started, posted, surface: surfaceOf(host), write }
@@ -187,7 +187,7 @@ export type TerminalDocumentState = {
removeWebglRecovery: (() => void) | null
/** `fit-scale`: the generation of the retry loop; a bump abandons the one in flight. */
fitRetryToken: number
/** `host-message-router`: the terminal frame React Native last measured with, or null before one. */
/** `host-message-router`: the terminal frame the app last sent with a grid, or null before one. */
hostFrame: { width: number; height: number } | null
/** `fit-scale`: the reason of a fit held while the host is hidden, or null when none is owed. */
fitPending: string | null
@@ -1,7 +1,5 @@
import type { TerminalDocumentScope } from './document-scope'
import { scheduleDocumentFrame } from './document-frame-registry'
import { applyFitScale } from './fit-scale'
import { fitDimensionsFromCell } from '../terminal-grid-fit'
import { notify } from './host-notify'
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
import { emitModesIfChanged } from './mode-mirroring'
@@ -13,7 +11,6 @@ import { resetEvictionCounter } from './selection-state-and-eviction'
import { applyTerminalTheme } from './terminal-theme'
import { init, resize, write } from './terminal-init'
import { applyTextScale } from './text-scaling'
import { flog } from './viewport-transform'
import { resetWriteQueue } from './write-queue'
/** One message from the host. Every field is optional because the router reads them by type. */
@@ -28,76 +25,20 @@ export type TerminalHostMessage = {
preserveScroll?: boolean
oscLinks?: unknown
data?: string
containerHeight?: number
containerWidth?: number
frame?: { width: number; height: number }
}
/** The frame React Native laid out, from an init or a measure; null when it has no size. */
/** The frame React Native laid out, sent with every grid; null when absent or without a size. */
function hostFrameOf(msg: TerminalHostMessage) {
const width = typeof msg.containerWidth === 'number' ? msg.containerWidth : 0
const height = typeof msg.containerHeight === 'number' ? msg.containerHeight : 0
return width > 0 && height > 0 ? { width, height } : null
const frame = msg.frame
return frame && frame.width > 0 && frame.height > 0
? { width: frame.width, height: frame.height }
: null
}
export function measureFitDimensions(
scope: TerminalDocumentScope,
frame: { width: number; height: number },
retriesLeft?: number
) {
if (typeof retriesLeft !== 'number') {
retriesLeft = 30
}
// Why: init and measure are posted back-to-back from React, but
// init has an async rAF chain. A measure that runs synchronously
// after init can find term null, disposed, lacking element, or
// with cells size 0. Retry the whole gate for ~500ms.
const notReady = !scope.term || !scope.term.element
let cellWidth = 0
let cellHeight = 0
if (!notReady) {
const core = scope.term!._core
if (core && core._renderService && core._renderService.dimensions) {
cellWidth = core._renderService.dimensions.css.cell.width
cellHeight = core._renderService.dimensions.css.cell.height
}
}
if (notReady || cellWidth <= 0 || cellHeight <= 0) {
if (retriesLeft > 0) {
// Ruling 21: a retry that outlives its mount would answer the next mount's measure.
const gen = scope.terminalGeneration
scheduleDocumentFrame(scope, function () {
if (gen !== scope.terminalGeneration) {
return
}
measureFitDimensions(scope, frame, retriesLeft - 1)
})
return
}
flog(scope, 'measure-fail', {
notReady: notReady,
cellWidth: cellWidth,
cellHeight: cellHeight,
retriesLeft: retriesLeft
})
notify(scope, { type: 'measure-result', cols: null, rows: null })
return
}
// Why: the frame box React Native laid out, fitted by the app's own formula, so this measure and
// the first subscribe's fit agree to the column.
const fit = fitDimensionsFromCell({ cellWidth, cellHeight }, frame.width, frame.height)
if (!fit) {
flog(scope, 'measure-skip-small-width', {
frameWidth: frame.width,
cellWidth: cellWidth
})
notify(scope, { type: 'measure-result', cols: null, rows: null })
return
}
// Why: the rows we report become the PTY's actual row count after the
// server fits to viewport, and xterm renders exactly that many lines
// anchored top-left of the WebView. Any safety margin between the prompt
// and the accessory bar must come from RN layout, not from undersizing the PTY.
notify(scope, { type: 'measure-result', cols: fit.cols, rows: fit.rows })
/** Keeps the frame a grid was fitted to, for a later text-scale change to fit. */
function holdHostFrame(scope: TerminalDocumentScope, msg: TerminalHostMessage) {
scope.hostFrame = hostFrameOf(msg) ?? scope.hostFrame
}
export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage) {
@@ -114,7 +55,7 @@ export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage
if (msg.type === 'ping') {
notify(scope, { type: 'pong', pingId: msg.id })
} else if (msg.type === 'init') {
scope.hostFrame = hostFrameOf(msg) ?? scope.hostFrame
holdHostFrame(scope, msg)
init(
scope,
msg.cols!,
@@ -139,8 +80,10 @@ export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage
applyTextScale(scope, msg.fontScale)
}
} else if (msg.type === 'resize') {
holdHostFrame(scope, msg)
resize(scope, msg.cols!, msg.rows!)
} else if (msg.type === 'reflow') {
holdHostFrame(scope, msg)
reflow(scope, msg.cols!, msg.rows!)
} else if (msg.type === 'write') {
write(scope, msg.data!)
@@ -169,15 +112,6 @@ export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage
notify(scope, { type: 'selection-evicted' })
cancelSelect(scope)
}
} else if (msg.type === 'measure') {
const frame = hostFrameOf(msg)
// Why: the frame React Native laid out is the only box a fit reads; without it there is none.
if (frame) {
scope.hostFrame = frame
measureFitDimensions(scope, frame)
} else {
notify(scope, { type: 'measure-result', cols: null, rows: null })
}
} else if (msg.type === 'reset-zoom') {
applyFitScale(scope, 'reset-zoom-msg')
} else if (msg.type === 'set-theme') {
+12 -47
View File
@@ -1,6 +1,5 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { fitDimensionsFromCell } from '../terminal-grid-fit'
import { createTerminalDocumentScope, type TerminalDocumentScope } from './document-scope'
import { startTerminalDocument, stopTerminalDocument } from './create-terminal-document'
import { handleMsg } from './host-message-router'
@@ -206,56 +205,22 @@ describe('the document host seams, by default', () => {
})
describe('the document host seams, once the page sets them', () => {
it('answers no fit for a measure without the frame, rather than one read off the host', () => {
// The page's host is one element on a page that is taller and wider than it; the window is
// happy-dom's 1024x768. Neither is the frame React Native laid out, so a measure that does not
// carry both of the frame's dimensions has nothing to fit.
const cell = { width: 7.5, height: 15 }
const terminal = Object.assign(terminalDouble(), {
_core: { _renderService: { dimensions: { css: { cell } } } }
})
const posted: Record<string, unknown>[] = []
it('keeps the frame React Native laid out from each grid, not the viewport CSS rounded', () => {
// 1080 device px at a 2.75 pixel ratio: React Native lays the frame out at 392.73 and the
// document's viewport reads 393. A grid without a whole frame leaves the last one in place.
const frame = { width: 1080 / 2.75, height: 600 }
const scope = startedScope({
createTerminal: () => terminal,
postToHost: (message) => posted.push(message),
viewportRect: () => ({ left: 0, top: 82, width: 390, height: 600 })
})
handleMsg(scope, { type: 'init', cols: 80, rows: 24, initialData: '', preserveScroll: false })
handleMsg(scope, { type: 'measure', containerWidth: 390 })
handleMsg(scope, { type: 'measure', containerHeight: 600 })
handleMsg(scope, { type: 'measure', containerWidth: 390, containerHeight: 600 })
expect(posted.filter((message) => message.type === 'measure-result')).toEqual([
{ type: 'measure-result', cols: null, rows: null },
{ type: 'measure-result', cols: null, rows: null },
{ type: 'measure-result', cols: 52, rows: 40 }
])
})
it('fits the frame width React Native laid out, not the viewport CSS rounded, as the app does', () => {
// 1080 device px at a 2.75 pixel ratio: React Native lays the frame out at 392.73, the
// document's viewport reads 393, and a cell of 393/51 sits between the two.
const frameWidth = 1080 / 2.75
const cell = { width: 393 / 51, height: 15 }
const terminal = Object.assign(terminalDouble(), {
_core: { _renderService: { dimensions: { css: { cell } } } }
})
const posted: Record<string, unknown>[] = []
const scope = startedScope({
createTerminal: () => terminal,
postToHost: (message) => posted.push(message),
createTerminal: () => terminalDouble(),
viewportRect: () => ({ left: 0, top: 0, width: 393, height: 600 })
})
handleMsg(scope, { type: 'init', cols: 80, rows: 24, initialData: '', preserveScroll: false })
handleMsg(scope, { type: 'measure', containerWidth: frameWidth, containerHeight: 600 })
const appFit = fitDimensionsFromCell(
{ cellWidth: cell.width, cellHeight: cell.height },
frameWidth,
600
)
expect(appFit).toEqual({ cols: 50, rows: 40 })
expect(posted.filter((message) => message.type === 'measure-result')).toEqual([
{ type: 'measure-result', ...appFit }
])
expect(scope.hostFrame).toBeNull()
handleMsg(scope, { type: 'resize', cols: 50, rows: 40, frame: { width: 390, height: 0 } })
expect(scope.hostFrame).toBeNull()
handleMsg(scope, { type: 'resize', cols: 50, rows: 40, frame })
expect(scope.hostFrame).toEqual(frame)
handleMsg(scope, { type: 'reflow', cols: 50, rows: 40 })
expect(scope.hostFrame).toEqual(frame)
})
it('routes every notify to the field and nothing to the bridge', () => {
@@ -142,8 +142,7 @@ describe('a started document', () => {
rows: 47,
initialData: '',
preserveScroll: false,
containerWidth: 427.5,
containerHeight: 710
frame: { width: 427.5, height: 710 }
})
expect(scope.hostFrame).toEqual({ width: 427.5, height: 710 })
} finally {
@@ -12,8 +12,8 @@ vi.mock('react-native', () => ({
const HANDLE = 'term-1'
/** The session's refit over one open terminal whose document now fits `measured`. */
function refitHarness(measured: TerminalViewportDims) {
/** The session's refit over one open terminal whose document reported a `cellWidth` box. */
function refitHarness(cellWidth: number) {
const terminal: TerminalWebViewHandle = {
prepareForForegroundRecovery: vi.fn(),
write: vi.fn(),
@@ -21,13 +21,12 @@ function refitHarness(measured: TerminalViewportDims) {
resize: vi.fn(),
reflow: vi.fn(),
clear: vi.fn(),
// The reported box: 23/3 px cells, 47 rows.
// The reported box, 47 rows high in the 710 px frame.
fitDimensions: vi.fn((frame: { width: number }) => ({
cols: Math.floor(frame.width / (23 / 3)),
cols: Math.floor(frame.width / cellWidth),
rows: 47
})),
subscribeFitDimensions: vi.fn(),
measureFitDimensions: vi.fn(async () => measured),
holdSubscribedGrid: vi.fn(),
resetZoom: vi.fn(),
cancelSelect: vi.fn(),
doSelectAll: vi.fn(),
@@ -82,41 +81,43 @@ afterEach(() => {
describe('a new cell box for the open terminal', () => {
it('refits the PTY to the grid the new box fits, as after a renderer swap', async () => {
const harness = refitHarness({ cols: 54, rows: 47 })
const harness = refitHarness(7.8)
harness.report(HANDLE)
await act(async () => {
await vi.advanceTimersByTimeAsync(150)
})
expect(harness.terminal.measureFitDimensions).toHaveBeenCalledWith(710, 427)
expect(harness.terminal.fitDimensions).toHaveBeenLastCalledWith({ width: 427, height: 710 })
expect(harness.viewportRef.current).toEqual({ cols: 54, rows: 47 })
expect(harness.subscribeToTerminal).toHaveBeenCalledWith(HANDLE)
})
it('leaves a terminal that is not on screen alone', async () => {
const harness = refitHarness({ cols: 54, rows: 47 })
const harness = refitHarness(7.8)
harness.report('term-2')
await act(async () => {
await vi.advanceTimersByTimeAsync(150)
})
expect(harness.terminal.measureFitDimensions).not.toHaveBeenCalled()
expect(harness.subscribeToTerminal).not.toHaveBeenCalled()
expect(harness.viewportRef.current).toEqual({ cols: 55, rows: 47 })
})
it('leaves the PTY alone when a new frame width holds the same grid, as sub-pixel jitter does', async () => {
const harness = refitHarness({ cols: 55, rows: 47 })
const harness = refitHarness(23 / 3)
harness.layOut(427.3)
await act(async () => {
await vi.advanceTimersByTimeAsync(150)
})
expect(harness.terminal.measureFitDimensions).not.toHaveBeenCalled()
expect(harness.subscribeToTerminal).not.toHaveBeenCalled()
expect(harness.viewportRef.current).toEqual({ cols: 55, rows: 47 })
})
it('refits when a new frame width holds a different grid', async () => {
const harness = refitHarness({ cols: 54, rows: 47 })
const harness = refitHarness(23 / 3)
harness.layOut(420)
await act(async () => {
await vi.advanceTimersByTimeAsync(150)
})
expect(harness.terminal.measureFitDimensions).toHaveBeenCalledWith(710, 420)
expect(harness.terminal.fitDimensions).toHaveBeenLastCalledWith({ width: 420, height: 710 })
expect(harness.viewportRef.current).toEqual({ cols: 54, rows: 47 })
})
})
@@ -140,10 +140,10 @@ describe('terminal viewport refit', () => {
// reflow the desktop PTY to phone dims the user never sees.
const timerStart = hookSource.indexOf('refitTimerRef.current = setTimeout(')
const coveredCheck = hookSource.indexOf('if (nativeChatCoveredRef.current)', timerStart)
const measureIndex = hookSource.indexOf('measureFitDimensions', timerStart)
const fitIndex = hookSource.indexOf('ref.fitDimensions(', timerStart)
expect(timerStart).toBeGreaterThanOrEqual(0)
expect(coveredCheck).toBeGreaterThan(timerStart)
expect(measureIndex).toBeGreaterThan(coveredCheck)
expect(fitIndex).toBeGreaterThan(coveredCheck)
expect(sessionSource).toContain('nativeChatCoveredRef: showNativeChatRef')
})
@@ -253,7 +253,7 @@ describe('terminal viewport refit', () => {
// desktop mode. Reflow local scrollback only after the server says it
// actually applied phone-fit to the PTY.
const appliedIndex = hookSource.indexOf('if (outcome.applied)')
const reflowIndex = hookSource.indexOf('ref.reflow(dims.cols, dims.rows)')
const reflowIndex = hookSource.indexOf('ref.reflow(dims.cols, dims.rows, frame)')
const cacheUpdateIndex = hookSource.indexOf('updateTerminalSubscriptionViewport(handle, dims)')
// Assert each anchor exists before ordering: a missing marker yields -1 and would
// let the ordering comparisons pass vacuously.
@@ -126,13 +126,12 @@ export function useTerminalViewportRefit(
currentRunSeq: refitRunSeqRef.current
})
void (async () => {
const dims = await ref.measureFitDimensions(
terminalFrameHeightRef.current,
frameWidthRef.current
)
await ref.awaitReady()
if (!isCurrentTarget()) {
return
}
const frame = { width: frameWidthRef.current, height: terminalFrameHeightRef.current }
const dims = ref.fitDimensions(frame)
if (!dims) {
return
}
@@ -163,7 +162,7 @@ export function useTerminalViewportRefit(
rpc.updateTerminalSubscriptionViewport(handle, dims)
if (outcome.applied) {
// Why: updateViewport re-streams only the visible screen, so local scrollback stays wrapped at the old width — reflow it locally.
ref.reflow(dims.cols, dims.rows)
ref.reflow(dims.cols, dims.rows, frame)
}
return
}
@@ -371,8 +371,7 @@ async function mountedOverGrid({ laidOutFirst = true } = {}) {
rows: 40,
initialData: '',
preserveScroll: false,
containerWidth: 390,
containerHeight: 600
frame: { width: 390, height: 600 }
})
await framesUntil(() => scales.at(-1) === FIT_390)
return { mounted, scales, send, layOut, cells, grid: () => grids.at(-1)! }
@@ -489,8 +488,8 @@ describe("the page pushes its terminal frame's box into the document", () => {
it('fits a text scale too large to resize the grid while visible', async () => {
const { mounted, scales, send, layOut, grid } = await mountedOverGrid()
layOut(280, 600)
// The app's refit for the new width measures, which gives the document the frame.
send({ type: 'measure', containerWidth: 280, containerHeight: 600 })
// The app's refit for the new width sends its grid with the frame.
send({ type: 'resize', cols: 55, rows: 40, frame: { width: 280, height: 600 } })
await framesUntil(() => scales.at(-1) === 280 / (7.5 * 55))
// fontPxForScale(2) = 26 px: 280 / (7.5 x 2) = 18 columns, under MIN_FIT_COLS, so no resize.
send({ type: 'set-font-scale', fontScale: 2 })
@@ -502,8 +501,8 @@ describe("the page pushes its terminal frame's box into the document", () => {
it('refits on show after a text scale too large to resize the grid while hidden', async () => {
const { mounted, scales, send, layOut } = await mountedOverGrid()
layOut(280, 600)
// The app's refit for the new width measures, which gives the document the frame.
send({ type: 'measure', containerWidth: 280, containerHeight: 600 })
// The app's refit for the new width sends its grid with the frame.
send({ type: 'resize', cols: 55, rows: 40, frame: { width: 280, height: 600 } })
await framesUntil(() => scales.at(-1) === 280 / (7.5 * 55))
layOut(0, 0)
// fontPxForScale(2) = 26 px: 280 / (7.5 x 2) = 18 columns, under MIN_FIT_COLS, so no resize.
@@ -143,7 +143,8 @@ describe('the cell box xterm laid out', () => {
it("refits a DOM subscribe once on its first report, and not again on the refit's own report", () => {
const { handle, notify, onCellBoxChange } = mount()
notify({ type: 'web-ready', cellBox: cellAt(scale, 7.8) })
expect(handle().subscribeFitDimensions(FRAME)).toEqual({ cols: 54, rows: 47 })
expect(handle().fitDimensions(FRAME)).toEqual({ cols: 54, rows: 47 })
handle().holdSubscribedGrid({ cols: 54, rows: 47 })
// The DOM renderer's box at the subscribed grid: its width follows cols, so it differs.
notify(cellMetrics(8, 54))
expect(onCellBoxChange).toHaveBeenCalledTimes(1)
@@ -181,24 +182,19 @@ describe('the cell box xterm laid out', () => {
expect(onCellBoxChange).not.toHaveBeenCalled()
})
it('measures the live document for a refit, against the frame the app laid out', async () => {
it('fits in the app from the reported box, and sends the frame with every grid', () => {
const { handle, notify } = mount()
notify({ type: 'web-ready', cellBox: cellAt(scale) })
const pending = handle().measureFitDimensions(710, 427.5)
expect(
nativeWebViewMethods.postMessage.mock.calls
.map(([message]) => JSON.parse(message))
.find((message) => message.type === 'measure')
).toMatchObject({ containerHeight: 710, containerWidth: 427.5 })
notify({ type: 'measure-result', cols: 55, rows: 47 })
await expect(pending).resolves.toEqual({ cols: 55, rows: 47 })
})
it('does not measure before the frame is laid out: the fit waits for layout', async () => {
const { handle, notify } = mount()
notify({ type: 'web-ready', cellBox: cellAt(scale) })
await expect(handle().measureFitDimensions(710, 0)).resolves.toBeNull()
await expect(handle().measureFitDimensions(0, 427)).resolves.toBeNull()
expect(handle().fitDimensions({ width: 427.5, height: 0 })).toBeNull()
expect(handle().fitDimensions({ width: 427.5, height: 710 })).toEqual({ cols: 55, rows: 47 })
const frame = { width: 427.5, height: 710 }
handle().init(55, 47, '', false, undefined, frame)
handle().resize(55, 47, frame)
handle().reflow(55, 47, frame)
const grids = nativeWebViewMethods.postMessage.mock.calls
.map(([message]) => JSON.parse(message))
.filter((message) => ['init', 'resize', 'reflow'].includes(message.type))
expect(grids.map((message) => message.frame)).toEqual([frame, frame, frame])
expect(postedTypes()).not.toContain('measure')
})
@@ -1,6 +1,8 @@
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
import type { StyleProp, ViewStyle } from 'react-native'
import type { TerminalFitDimensions } from './terminal-grid-fit'
import type { TerminalFrame } from './terminal-webview-messages'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
@@ -104,33 +106,24 @@ export type TerminalWebViewHandle = {
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinkRange[],
// Why: the frame the app laid out; the document fits a later text-size change to it.
frame?: { width: number; height: number }
frame?: TerminalFrame
) => void
resize: (cols: number, rows: number) => void
resize: (cols: number, rows: number, frame?: TerminalFrame) => void
// Why: reflow the local xterm buffer (scrollback included) to a new width
// after a server-side PTY reflow, so older wrapped lines rewrap to match the
// latest output. No-op on the alternate screen.
reflow: (cols: number, rows: number) => void
reflow: (cols: number, rows: number, frame?: TerminalFrame) => void
clear: () => void
/** The fit for this frame from the cell box this view's document reported; null until its ready. */
fitDimensions: (frame: { width: number; height: number }) => { cols: number; rows: number } | null
/** `fitDimensions` for the subscribe after ready; holds that grid for the document's first report. */
subscribeFitDimensions: (frame: {
width: number
height: number
}) => { cols: number; rows: number } | null
// Why: the frame box React Native laid out; the document fits it with the app's own formula.
measureFitDimensions: (
frameHeight: number,
frameWidth: number
) => Promise<{ cols: number; rows: number } | null>
/** The grid this frame holds at the cell box the document reported; null without one at this text size. */
fitDimensions: (frame: TerminalFrame) => TerminalFitDimensions | null
/** The subscribe went out at this grid; the document's first report at it is checked against it. */
holdSubscribedGrid: (grid: TerminalFitDimensions) => void
resetZoom: () => void
cancelSelect: () => void
doSelectAll: () => void
// Why: lets callers await the WebView-side `init` rAF chain (term.open
// → renderService population → first paint) so a follow-up measure
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
// the next 'ready' notify after the most recent init.
// → renderService population → first paint → the init's cell box) so a
// follow-up fit reads that box. Resolves on the next 'ready' notify after
// the most recent init.
awaitReady: () => Promise<void>
}
@@ -1,6 +1,9 @@
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
/** The frame React Native laid out, sent with each grid so the document fits a text-size change to it. */
export type TerminalFrame = { width: number; height: number }
export type TerminalWebViewCommand =
| { type: 'ping'; id?: number }
| { type: 'write'; id?: number; data: string }
@@ -16,15 +19,12 @@ export type TerminalWebViewCommand =
// Why: width-reflow re-streams replay the same content rewrapped at new
// cols; preserve the reader's scroll position instead of jumping to bottom.
preserveScroll?: boolean
// Why: the frame React Native laid out, so a text-size change fits it before any measure.
containerWidth?: number
containerHeight?: number
frame?: TerminalFrame
}
| { type: 'set-font-scale'; id?: number; fontScale: number }
| { type: 'resize'; id?: number; cols: number; rows: number }
| { type: 'reflow'; id?: number; cols: number; rows: number }
| { type: 'resize'; id?: number; cols: number; rows: number; frame?: TerminalFrame }
| { type: 'reflow'; id?: number; cols: number; rows: number; frame?: TerminalFrame }
| { type: 'clear'; id?: number }
| { type: 'measure'; id?: number; containerHeight?: number; containerWidth?: number }
| { type: 'reset-zoom'; id?: number }
| { type: 'cancel-select'; id?: number }
| { type: 'do-select-all'; id?: number }
@@ -119,7 +119,7 @@ describe('mobile terminal query replies', () => {
expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }])
const router = documentModuleSource('host-message-router')
const clearStart = router.indexOf("} else if (msg.type === 'clear') {")
const clearEnd = router.indexOf("} else if (msg.type === 'measure')", clearStart)
const clearEnd = router.indexOf("} else if (msg.type === 'reset-zoom')", clearStart)
expect(clearStart).toBeGreaterThanOrEqual(0)
expect(router.slice(clearStart, clearEnd)).toContain('resumeTerminalDataReplyAuthority(scope)')
})
@@ -1,31 +1,24 @@
import { useMemo } from 'react'
import { MIN_FIT_COLS, MIN_FIT_ROWS, type TerminalFitDimensions } from './terminal-grid-fit'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
/**
* The two promises the terminal handle hands out, and the notifies that settle them.
* The ready promise the terminal handle hands out, and the notify that settles it.
*
* `awaitReady` waits for the document's `init` rAF chain — `term.open`, renderService population,
* first paint — because a measure that runs synchronously after init finds `term` null or cells
* of size zero. `measureFitDimensions` waits for the document's answer. Both are promises held
* across a message round trip, both have a timeout for the case where the document never answers,
* and both are the same on either host, so they live here rather than in the controller that owns
* the readiness handshake.
* first paint, and the cell box that init reports — so a fit after it reads the init's own box. It
* has a timeout for a document that never answers, and is the same on either host.
*/
const READY_TIMEOUT_MS = 3000
const MEASURE_TIMEOUT_MS = 2000
export function createTerminalWebViewReadyPromises() {
let readyPromise: Promise<void> | null = null
let readyResolve: (() => void) | null = null
let measureResolve: ((result: TerminalFitDimensions | null) => void) | null = null
/**
* Arms a fresh ready promise, resolving any prior one first.
*
* Why: an awaiter from the previous generation would otherwise sit on the timeout below — each
* leaked timer and closure pinned an awaiting measure caller for the full 3s under rapid
* leaked timer and closure pinned an awaiting fit caller for the full 3s under rapid
* re-init (orientation change, multiple resubscribes), delaying cold-start fit chains.
*/
function armReady() {
@@ -66,48 +59,7 @@ export function createTerminalWebViewReadyPromises() {
})
}
function measure(
send: (command: TerminalWebViewCommand) => void,
containerHeight: number,
containerWidth: number
): Promise<TerminalFitDimensions | null> {
return new Promise((resolve) => {
measureResolve?.(null)
let timeout: ReturnType<typeof setTimeout> | null = null
const finish = (result: TerminalFitDimensions | null) => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
if (measureResolve === finish) {
measureResolve = null
}
resolve(result)
}
measureResolve = finish
send({ type: 'measure', containerHeight, containerWidth })
// Why: if the document doesn't respond (e.g., xterm failed to load), resolve null so the
// caller can disable Fit to Phone rather than hanging indefinitely.
timeout = setTimeout(() => {
if (measureResolve === finish) {
finish(null)
}
}, MEASURE_TIMEOUT_MS)
})
}
function resolveMeasure(msg: Record<string, unknown>) {
const resolve = measureResolve
measureResolve = null
if (!resolve) {
return
}
const cols = typeof msg.cols === 'number' ? msg.cols : null
const rows = typeof msg.rows === 'number' ? msg.rows : null
resolve(cols && rows && cols >= MIN_FIT_COLS && rows >= MIN_FIT_ROWS ? { cols, rows } : null)
}
return { armReady, awaitReady, measure, resolveMeasure, resolveReady }
return { armReady, awaitReady, resolveReady }
}
export function useTerminalWebViewReadyPromises() {
@@ -50,7 +50,7 @@ describe('terminal WebView reflow', () => {
it('is dispatched by the reflow WebView message and exposed on the handle', () => {
expect(DOCUMENT_SOURCE).toContain("} else if (msg.type === 'reflow') {")
expect(DOCUMENT_SOURCE).toContain('reflow(scope, msg.cols!, msg.rows!)')
expect(handleSource).toContain("postMessage({ type: 'reflow', cols, rows })")
expect(handleSource).toContain("postMessage({ type: 'reflow', cols, rows, frame })")
})
it('does not locally resize hidden WebViews to a one-column grid', () => {
@@ -58,9 +58,5 @@ describe('terminal WebView reflow', () => {
const gridFitSource = readFileSync(new URL('./terminal-grid-fit.ts', import.meta.url), 'utf8')
expect(gridFitSource).toContain('export const MIN_FIT_COLS = 20')
expect(gridFitSource).toContain('if (!(cols >= MIN_FIT_COLS)) {')
expect(DOCUMENT_SOURCE).toContain("flog(scope, 'measure-skip-small-width'")
expect(DOCUMENT_SOURCE).toContain(
"notify(scope, { type: 'measure-result', cols: null, rows: null })"
)
})
})
@@ -140,14 +140,10 @@ describe('TerminalWebView scroll routing', () => {
expect(source).toContain('pendingMessages.clear()')
})
it('clears WebView await timers when the real response wins', () => {
// C7.5 moved both promises into `terminal-webview-ready-promises.ts`, which both components
// reach through the controller; the two blocks are the same code in their new home.
const measureBlock = sliceBetween('function measure(', 'function resolveMeasure')
expect(measureBlock).toContain('clearTimeout(timeout)')
expect(measureBlock).toContain('measureResolve === finish')
const readyBlock = sliceBetween('async function awaitReady()', 'function measure(')
it('clears the ready await timer when the real response wins', () => {
// C7.5 moved the promise into `terminal-webview-ready-promises.ts`, which both components
// reach through the controller.
const readyBlock = sliceBetween('async function awaitReady()', 'return { armReady')
expect(readyBlock).toContain('clearTimeout(timeout)')
expect(readyBlock).toContain('void pending.finally')
})
@@ -122,7 +122,7 @@ describe('TerminalWebView text zoom', () => {
'const replayData = normalizeInitialData(initialData)'
)
const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {")
const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart)
const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'reset-zoom')", clearStart)
expect(initStart).toBeGreaterThanOrEqual(0)
expect(initReplay).toBeGreaterThan(initStart)
expect(clearStart).toBeGreaterThanOrEqual(0)
@@ -142,7 +142,9 @@ describe('terminal write coalescer boundaries', () => {
it('flushes pending writes before resize and reflow so boundaries observe prior bytes', () => {
for (const method of ['resize', 'reflow'] as const) {
const start = controllerSource.indexOf(`${method}(cols: number, rows: number) {`)
const start = controllerSource.indexOf(
`${method}(cols: number, rows: number, frame?: TerminalFrame) {`
)
expect(start).toBeGreaterThanOrEqual(0)
const body = controllerSource.slice(start, start + 300)
const flushIndex = body.indexOf('writeCoalescer.flushNow()')
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import type { TerminalOscLinkRange } from '../../../src/shared/terminal-osc-link-ranges'
import { readTerminalCellBox, type TerminalCellBox } from './terminal-cell-box'
import { fitDimensionsFromCell } from './terminal-grid-fit'
import { fitDimensionsFromCell, type TerminalFitDimensions } from './terminal-grid-fit'
import { holdGrid } from './terminal-held-grid'
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
import { useTerminalWebViewEngineErrorState } from './terminal-webview-engine-error-state'
import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import type { TerminalFrame, TerminalWebViewCommand } from './terminal-webview-messages'
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
import { dispatchTerminalWebViewNotification } from './terminal-webview-notification-dispatch'
import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing'
@@ -18,7 +18,7 @@ import { createTerminalWriteCoalescer } from './terminal-write-coalescer'
*
* The document is the same program on both platforms — inside the WebView it is the generated
* script, on the page it is the modules that script is generated from — so the readiness
* handshake, the pending queue, the write coalescer, the ready and measure promises and the whole
* handshake, the pending queue, the write coalescer, the ready promise and the whole
* imperative handle are the same too. What differs is only how a command reaches the document and
* how a notify comes back: a `postMessage` across the WebView bridge, or a direct call.
*
@@ -74,8 +74,8 @@ export function useTerminalWebViewController(
const pendingPingIdRef = useRef<number | null>(null)
const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme])
// Why: each init() call posts 'init' to the document and arms a fresh ready promise. The
// document's init() rAF chain ends with a 'ready' notify that resolves it. measureFitDimensions
// awaits this so it doesn't race ahead of term.open() / renderService population.
// document's init() rAF chain ends with a 'ready' notify that resolves it. A fit awaits this so
// it reads the box the init reported.
const promises = useTerminalWebViewReadyPromises()
// The box the current document last reported; its re-reports cover a text-size change.
const cellBoxRef = useRef<TerminalCellBox | null>(null)
@@ -169,8 +169,8 @@ export function useTerminalWebViewController(
confirmWebReady(false)
} else if (msg.type === 'ready') {
// Why: the document's init() rAF chain has run — term is open, renderService is
// populated, first paint has happened. Resolve any pending awaitReady() so a queued
// measure can now safely read cell dims.
// populated, first paint has happened, and its box was reported. Resolve any pending
// awaitReady() so a queued fit reads that box.
promises.resolveReady()
} else if (msg.type === 'cell-metrics') {
const laidOut = readTerminalCellBox(msg)
@@ -186,8 +186,6 @@ export function useTerminalWebViewController(
if (changed && sameGrid) {
onCellBoxChange?.()
}
} else if (msg.type === 'measure-result') {
promises.resolveMeasure(msg)
} else {
dispatchTerminalWebViewNotification(msg, {
reportEngineError,
@@ -252,7 +250,7 @@ export function useTerminalWebViewController(
}, [postMessage, textScale])
const fitDimensions = useCallback(
(frame: { width: number; height: number }) => {
(frame: TerminalFrame) => {
const cell = cellBoxRef.current
// Why: a box at another scale (a reload keeps the mount's) fits nothing; the route stays unmeasured.
return cell && cell.fontScale === textScale && frame.width > 0 && frame.height > 0
@@ -283,7 +281,7 @@ export function useTerminalWebViewController(
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinkRange[],
frame?: { width: number; height: number }
frame?: TerminalFrame
) {
// Why: arm a fresh ready promise BEFORE posting init. The document resolves it via the
// 'ready' notify at the end of its rAF chain.
@@ -300,38 +298,26 @@ export function useTerminalWebViewController(
terminalTheme,
fontScale: textScale,
preserveScroll,
containerWidth: frame?.width,
containerHeight: frame?.height
frame
})
},
resize(cols: number, rows: number) {
resize(cols: number, rows: number, frame?: TerminalFrame) {
// Why: resize/reflow must observe all prior writes or bytes reorder.
writeCoalescer.flushNow()
postMessage({ type: 'resize', cols, rows })
postMessage({ type: 'resize', cols, rows, frame })
},
reflow(cols: number, rows: number) {
reflow(cols: number, rows: number, frame?: TerminalFrame) {
writeCoalescer.flushNow()
postMessage({ type: 'reflow', cols, rows })
postMessage({ type: 'reflow', cols, rows, frame })
},
clear() {
writeCoalescer.clear()
postMessage({ type: 'clear' })
},
fitDimensions,
subscribeFitDimensions(frame: { width: number; height: number }) {
const fit = fitDimensions(frame)
holdSubscribedGrid(grid: TerminalFitDimensions) {
// Why: the DOM renderer's first report at this grid then reads as a new box, refit once.
if (fit) {
holdGrid(lastGridRef, fit.cols, fit.rows)
}
return fit
},
measureFitDimensions(frameHeight: number, frameWidth: number) {
// Why: no fit until the frame is laid out; the layout's own refit measures then.
if (!isWebReadyRef.current || !(frameHeight > 0 && frameWidth > 0)) {
return Promise.resolve(null)
}
return promises.measure(sendToDocument, frameHeight, frameWidth)
holdGrid(lastGridRef, grid.cols, grid.rows)
},
resetZoom() {
postMessage({ type: 'reset-zoom' })
@@ -12,9 +12,10 @@ function terminalWebViewHandle(
dims: { cols: number; rows: number }
) {
return {
measureFitDimensions: (frameHeight?: number) => {
effect('measure-fit', { frameHeight: frameHeight ?? null })
return Promise.resolve(dims)
awaitReady: () => Promise.resolve(),
fitDimensions: (frame: { height: number }) => {
effect('measure-fit', { frameHeight: frame.height })
return dims
},
reflow: (cols: number, rows: number) => effect('reflow', { cols, rows })
}