diff --git a/config/scripts/mobile-web-app-terminal-probe-route.mjs b/config/scripts/mobile-web-app-terminal-probe-route.mjs index 57cc92bbb0e..042c0b31bb4 100644 --- a/config/scripts/mobile-web-app-terminal-probe-route.mjs +++ b/config/scripts/mobile-web-app-terminal-probe-route.mjs @@ -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) } diff --git a/config/scripts/mobile-web-app-terminal-render.test.mjs b/config/scripts/mobile-web-app-terminal-render.test.mjs index 2165e602965..4a08bcece53 100644 --- a/config/scripts/mobile-web-app-terminal-render.test.mjs +++ b/config/scripts/mobile-web-app-terminal-render.test.mjs @@ -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) diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index e2d31e32153..27a48af3547 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -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 diff --git a/mobile/src/session/mobile-terminal-first-subscribe-viewport.test.tsx b/mobile/src/session/mobile-terminal-first-subscribe-viewport.test.tsx index 12ea43fc076..020ef149e47 100644 --- a/mobile/src/session/mobile-terminal-first-subscribe-viewport.test.tsx +++ b/mobile/src/session/mobile-terminal-first-subscribe-viewport.test.tsx @@ -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 () => { diff --git a/mobile/src/session/mobile-terminal-first-subscribe-viewport.ts b/mobile/src/session/mobile-terminal-first-subscribe-viewport.ts index 3df90b9249c..9455be3eb15 100644 --- a/mobile/src/session/mobile-terminal-first-subscribe-viewport.ts +++ b/mobile/src/session/mobile-terminal-first-subscribe-viewport.ts @@ -3,7 +3,7 @@ import type { MutableRef, TerminalViewportDims } from './mobile-terminal-viewpor export type TerminalViewportFromCellBoxArgs = { handle: string - ref: Pick | undefined + ref: Pick | undefined viewportRef: MutableRef viewportMeasuredRef: MutableRef terminalFrameWidthRef: MutableRef @@ -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 } diff --git a/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts b/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts index 1877b82c5c4..bddac38c414 100644 --- a/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts +++ b/mobile/src/session/mobile-terminal-viewport-resubscribe.test.ts @@ -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) diff --git a/mobile/src/session/mobile-terminal-viewport-resubscribe.ts b/mobile/src/session/mobile-terminal-viewport-resubscribe.ts index f7da169f834..ace7e0988cb 100644 --- a/mobile/src/session/mobile-terminal-viewport-resubscribe.ts +++ b/mobile/src/session/mobile-terminal-viewport-resubscribe.ts @@ -177,10 +177,7 @@ export type MutableRef = { current: T } type TerminalFitWebView = { awaitReady: () => Promise - measureFitDimensions: ( - frameHeight: number, - frameWidth: number - ) => Promise + 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 diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.ts b/mobile/src/session/use-mobile-session-terminal-subscription.ts index e5c9b6dcf28..ce5e3a30b2d 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription.ts +++ b/mobile/src/session/use-mobile-session-terminal-subscription.ts @@ -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 diff --git a/mobile/src/terminal/document/document-host-root.test.ts b/mobile/src/terminal/document/document-host-root.test.ts index b1a0934f6e7..8a3e066bbe9 100644 --- a/mobile/src/terminal/document/document-host-root.test.ts +++ b/mobile/src/terminal/document/document-host-root.test.ts @@ -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 } diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index a9dfdd61c38..36194def1d2 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -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 diff --git a/mobile/src/terminal/document/host-message-router.ts b/mobile/src/terminal/document/host-message-router.ts index 02a53c3b406..ab03f7158b3 100644 --- a/mobile/src/terminal/document/host-message-router.ts +++ b/mobile/src/terminal/document/host-message-router.ts @@ -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') { diff --git a/mobile/src/terminal/document/host-seams.test.ts b/mobile/src/terminal/document/host-seams.test.ts index cac551598f8..17d709ddc42 100644 --- a/mobile/src/terminal/document/host-seams.test.ts +++ b/mobile/src/terminal/document/host-seams.test.ts @@ -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[] = [] + 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[] = [] - 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', () => { diff --git a/mobile/src/terminal/document/laid-out-cell-box.test.ts b/mobile/src/terminal/document/laid-out-cell-box.test.ts index 1533d714e48..323a8f306c8 100644 --- a/mobile/src/terminal/document/laid-out-cell-box.test.ts +++ b/mobile/src/terminal/document/laid-out-cell-box.test.ts @@ -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 { diff --git a/mobile/src/terminal/terminal-cell-box-refit.test.tsx b/mobile/src/terminal/terminal-cell-box-refit.test.tsx index 688c5620c6e..d632aaec7c3 100644 --- a/mobile/src/terminal/terminal-cell-box-refit.test.tsx +++ b/mobile/src/terminal/terminal-cell-box-refit.test.tsx @@ -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 }) }) }) diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts index 7a54c70c868..b675fe74957 100644 --- a/mobile/src/terminal/terminal-viewport-refit.test.ts +++ b/mobile/src/terminal/terminal-viewport-refit.test.ts @@ -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. diff --git a/mobile/src/terminal/terminal-viewport-refit.ts b/mobile/src/terminal/terminal-viewport-refit.ts index 9dfca337493..5327efc59a6 100644 --- a/mobile/src/terminal/terminal-viewport-refit.ts +++ b/mobile/src/terminal/terminal-viewport-refit.ts @@ -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 } diff --git a/mobile/src/terminal/terminal-web-document-mount.test.ts b/mobile/src/terminal/terminal-web-document-mount.test.ts index 936e9a2ed9e..242624ebe07 100644 --- a/mobile/src/terminal/terminal-web-document-mount.test.ts +++ b/mobile/src/terminal/terminal-web-document-mount.test.ts @@ -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. diff --git a/mobile/src/terminal/terminal-webview-cell-metrics.test.ts b/mobile/src/terminal/terminal-webview-cell-metrics.test.ts index 6c112269f2a..2f4aa221752 100644 --- a/mobile/src/terminal/terminal-webview-cell-metrics.test.ts +++ b/mobile/src/terminal/terminal-webview-cell-metrics.test.ts @@ -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') }) diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index 9ad42798a12..6b8d907b244 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -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 } diff --git a/mobile/src/terminal/terminal-webview-messages.ts b/mobile/src/terminal/terminal-webview-messages.ts index 38ea53d4ca0..8de248a4e9c 100644 --- a/mobile/src/terminal/terminal-webview-messages.ts +++ b/mobile/src/terminal/terminal-webview-messages.ts @@ -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 } diff --git a/mobile/src/terminal/terminal-webview-query-reply.test.ts b/mobile/src/terminal/terminal-webview-query-reply.test.ts index 49b8fb63539..48de712bcaa 100644 --- a/mobile/src/terminal/terminal-webview-query-reply.test.ts +++ b/mobile/src/terminal/terminal-webview-query-reply.test.ts @@ -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)') }) diff --git a/mobile/src/terminal/terminal-webview-ready-promises.ts b/mobile/src/terminal/terminal-webview-ready-promises.ts index bde434291e6..0eab14849dc 100644 --- a/mobile/src/terminal/terminal-webview-ready-promises.ts +++ b/mobile/src/terminal/terminal-webview-ready-promises.ts @@ -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 | 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 { - return new Promise((resolve) => { - measureResolve?.(null) - let timeout: ReturnType | 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) { - 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() { diff --git a/mobile/src/terminal/terminal-webview-reflow.test.ts b/mobile/src/terminal/terminal-webview-reflow.test.ts index 29d241cd4c9..82948f541eb 100644 --- a/mobile/src/terminal/terminal-webview-reflow.test.ts +++ b/mobile/src/terminal/terminal-webview-reflow.test.ts @@ -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 })" - ) }) }) diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index f62bfdf1ee2..bb0cc649412 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -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') }) diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index ce9a269a56e..a1945cc07d8 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -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) diff --git a/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts b/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts index 22e76581a0e..efbc1fff0ff 100644 --- a/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts +++ b/mobile/src/terminal/terminal-write-coalescer-boundaries.test.ts @@ -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()') diff --git a/mobile/src/terminal/use-terminal-webview-controller.ts b/mobile/src/terminal/use-terminal-webview-controller.ts index 200fcd882d3..606b06b4b8c 100644 --- a/mobile/src/terminal/use-terminal-webview-controller.ts +++ b/mobile/src/terminal/use-terminal-webview-controller.ts @@ -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(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(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' }) diff --git a/mobile/src/test-support/rpc-recording/adapters/terminal-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/terminal-mount-adapters.ts index 0cb0d559cbc..533b83e8579 100644 --- a/mobile/src/test-support/rpc-recording/adapters/terminal-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/terminal-mount-adapters.ts @@ -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 }) }