fix(mobile): move the document's state onto the scope (ruling 21)

Round 2's blocking finding, and ruling 20's second half: moving parse-time
effects out of the module bodies left the state behind. Nine module-level
bindings survived a mount, so the second terminal inherited a spent non-fatal
error budget (reporting nothing however it failed), the first terminal as its
committed surface (disposing it twice), and the first mount's momentum loop.

Every mutable binding now lives on the scope, and the scope carries one reset
the start sequence calls first: native once at parse, the page once per mount.
Moved, by module: query-reply 1, surface-swap 3, text-scaling 2, fit-scale 1,
host-notify 2, selection-state-and-eviction 1, mouse-click-drag 1,
tap-dispatch 1, surface-touch-gestures 1 — thirteen fields, two of them the
objects tap-dispatch and surface-touch-gestures used to own outright.

Because the reset is now the one initialiser, the start functions keep only
what it cannot do: element reads, listener installs and the reporter install.
Four start functions emptied and went; terminal-handle held nothing else and
is deleted from the order list. The scope type splits into state and host
seams, because a reset must restore the first and never the second.

Every stop function cancels what its module scheduled. Timers go back through
the handles the scope already held; frames go through the scope's own
scheduleDocumentFrame, so dispose can take back the ones no module tracks by
id. terminalGeneration and fitRetryToken carry forward across a reset, because
a stale callback tests itself against them and a reset to zero would make the
old number match again.

L2: the seams-before-scope case asserts the order in the emitted document, not
just non-membership. L3: the style docstring says what is true — one scope per
page, so mount refuses a second live document and gives the page back when a
mount fails.

Golden: 108134 -> 108047 bytes; payload 726168 -> 726081, sha256
6a5a3216aab7b99daeb26bcdcfe6e325c415e5ef60c16405eea329ca141405fe.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-20 14:46:27 -04:00
parent bbae7dec1e
commit efeede31e0
35 changed files with 994 additions and 629 deletions
@@ -384,3 +384,76 @@ export async function createBundleServer({ outDir, cspHeader, transformChunk })
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return { server, origin: `http://127.0.0.1:${String(server.address().port)}` }
}
/**
* A handler of the page's own, installed before the bundle so the terminal meets a `window.onerror`
* that belongs to someone else.
*
* Reading `null` three times would pass on a terminal that assigned `null` over a real handler,
* which is the failure this seam exists to prevent. The sentinel is identity-checked in the page
* rather than marshalled out of it — a function does not survive `evaluate` — and it returns
* false so the browser still reports the error normally.
*/
export function installPageErrorSentinel() {
globalThis.__orcaSentinelCalls = []
const sentinel = (message) => {
globalThis.__orcaSentinelCalls.push(String(message))
return false
}
globalThis.__orcaSentinel = sentinel
window.onerror = sentinel
}
/**
* Every animation frame and timer, tagged with the mount that scheduled it.
*
* Installed before the bundle loads, so the document's own scheduling goes through it. The test
* bumps `mount` at dispose; a callback that was scheduled under the previous number and still runs
* is a frame or timer of the first mount firing into the second, which is the whole finding. React
* schedules its work on the microtask queue rather than on frames, and xterm's frames belong to the
* terminal being disposed, so what this records is the document's.
*/
export function installSchedulerRecorder() {
globalThis.__orcaScheduler = { mount: 0, watching: false, pending: [], leaked: [] }
const state = globalThis.__orcaScheduler
const wrap = (schedule, kind) =>
function (callback, ...rest) {
if (!state.watching || typeof callback !== 'function') {
return schedule(callback, ...rest)
}
// The line that called this, which is the script the work belongs to. Line 0 is the error's
// own header and line 1 is this wrapper.
const caller = ((new Error('scheduled').stack ?? '').split('\n')[2] ?? '').trim()
const entry = { kind, caller, mount: state.mount }
state.pending.push(entry)
return schedule(
(...args) => {
const at = state.pending.indexOf(entry)
if (at !== -1) {
state.pending.splice(at, 1)
}
if (entry.mount !== state.mount) {
state.leaked.push(`${kind} from ${caller}`)
}
return callback(...args)
},
...rest
)
}
globalThis.requestAnimationFrame = wrap(
globalThis.requestAnimationFrame.bind(globalThis),
'frame'
)
globalThis.setTimeout = wrap(globalThis.setTimeout.bind(globalThis), 'timer')
globalThis.setInterval = wrap(globalThis.setInterval.bind(globalThis), 'interval')
}
/** Recorded before anything else runs, so a refusal during the page's own boot is counted. */
export function installCspViolationRecorder() {
globalThis.__orcaCspViolations = []
document.addEventListener('securitypolicyviolation', (event) => {
globalThis.__orcaCspViolations.push(
`${event.violatedDirective}: ${event.blockedURI || 'inline'} @ ${event.sourceFile ?? '?'}:${String(event.lineNumber ?? 0)}`
)
})
}
@@ -9,6 +9,9 @@ import { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
createBundleServer,
installCspViolationRecorder,
installPageErrorSentinel,
installSchedulerRecorder,
installShellDouble,
readBridgeFaultGrant,
readBridgeProtocolVersion,
@@ -172,35 +175,6 @@ export default function ProbeLayout() {
}
`
/**
* A handler of the page's own, installed before the bundle so the terminal meets a `window.onerror`
* that belongs to someone else.
*
* Reading `null` three times would pass on a terminal that assigned `null` over a real handler,
* which is the failure this seam exists to prevent. The sentinel is identity-checked in the page
* rather than marshalled out of it — a function does not survive `evaluate` — and it returns
* false so the browser still reports the error normally.
*/
function installPageErrorSentinel() {
globalThis.__orcaSentinelCalls = []
const sentinel = (message) => {
globalThis.__orcaSentinelCalls.push(String(message))
return false
}
globalThis.__orcaSentinel = sentinel
window.onerror = sentinel
}
/** Recorded before anything else runs, so a refusal during the page's own boot is counted. */
function installCspViolationRecorder() {
globalThis.__orcaCspViolations = []
document.addEventListener('securitypolicyviolation', (event) => {
globalThis.__orcaCspViolations.push(
`${event.violatedDirective}: ${event.blockedURI || 'inline'} @ ${event.sourceFile ?? '?'}:${String(event.lineNumber ?? 0)}`
)
})
}
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
@@ -256,9 +230,15 @@ afterAll(async () => {
}
})
async function openPage(pathname, { errorSentinel = false, beforeNavigate } = {}) {
async function openPage(
pathname,
{ errorSentinel = false, scheduler = false, beforeNavigate } = {}
) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
await beforeNavigate?.(page)
if (scheduler) {
await page.addInitScript(installSchedulerRecorder)
}
await page.addInitScript(installCspViolationRecorder)
if (errorSentinel) {
await page.addInitScript(installPageErrorSentinel)
@@ -600,6 +580,115 @@ describeRender(
await page.close()
}, 300_000)
it('still reports runtime errors after a first mount spent the non-fatal budget', async () => {
// Ruling 21's finding, end to end. `reportEngineError` caps non-fatal notifies at five so a
// per-frame thrower cannot flood the host. That counter is the document's, not the mount's:
// a first terminal that spends it leaves the second one mute, reporting nothing however it
// fails, while every other signal — readiness, paint, selection — says the terminal is fine.
const { page } = await openTerminal()
await openProbeTerminal(page)
await page.evaluate(() => {
for (let index = 0; index < 6; index++) {
setTimeout(() => {
throw new Error(`orca-budget-burn-${String(index)}`)
}, 0)
}
})
await page.waitForFunction(
() =>
globalThis.__orcaTerminalEngineErrors.filter((entry) =>
entry.includes('orca-budget-burn')
).length >= 5,
{ timeout: 30_000, polling: 100 }
)
await page.evaluate(() => globalThis.__orcaTerminalProbe.setMounted(false))
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
await page.evaluate(() => {
globalThis.__orcaTerminalReady = false
globalThis.__orcaTerminalProbe.setMounted(true)
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
await page.evaluate(() => {
globalThis.__orcaTerminalEngineErrors = []
setTimeout(() => {
throw new Error('orca-second-mount-error')
}, 0)
})
await page.waitForFunction(
() =>
globalThis.__orcaTerminalEngineErrors.some((entry) =>
entry.includes('orca-second-mount-error')
),
{ timeout: 30_000, polling: 100 }
)
await page.close()
}, 300_000)
it('cancels its frames and timers, so none of the first mount runs into the second', async () => {
// The other half of the same rule. A frame or timer the first terminal scheduled has no
// owner after dispose, and on the second mount it acts on the terminal that replaced it —
// refitting a grid nobody resized, scrolling a buffer nobody touched.
// The document is its own chunk, and the point is what *it* scheduled: xterm's renderer
// schedules frames of its own that a disposed terminal simply ignores, and the browser
// cannot unschedule those. So the chunk is identified on the wire, by a literal only
// `host-notify` carries, and a leak is a callback that chunk scheduled.
let documentChunk = null
const { page } = await openPage(PROBE_ROUTE, {
scheduler: true,
beforeNavigate: async (opened) => {
await opened.route('**/*.js', async (route) => {
const response = await route.fetch()
const body = await response.text()
if (body.includes('terminal runtime error')) {
documentChunk = new URL(route.request().url()).pathname
}
await route.fulfill({ response, body })
})
}
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
expect(documentChunk, 'the document was served as its own chunk').not.toBe(null)
// One task, so the frame cannot run before the dispose that should take it back. A resize
// refits synchronously — the mount arms that listener itself — and the refit asks for a
// frame on the spot. React unmounts after this task, so at dispose the frame is owed.
await page.evaluate(() => {
globalThis.__orcaScheduler.watching = true
globalThis.dispatchEvent(new Event('resize'))
globalThis.__orcaScheduler.pendingAtDispose = globalThis.__orcaScheduler.pending.length
globalThis.__orcaScheduler.mount += 1
globalThis.__orcaTerminalProbe.setMounted(false)
})
await page.locator('#terminal-container').waitFor({ state: 'detached', timeout: 30_000 })
await page.evaluate(() => {
globalThis.__orcaTerminalReady = false
globalThis.__orcaTerminalProbe.setMounted(true)
})
await page.waitForFunction(() => globalThis.__orcaTerminalReady === true, {
timeout: 60_000,
polling: 100
})
await openProbeTerminal(page)
// Long enough for any frame or timer of the first mount to have fired if it survived.
await page.evaluate(() => new Promise((resolve) => globalThis.setTimeout(resolve, 3000)))
const scheduler = await page.evaluate(() => globalThis.__orcaScheduler)
// The precondition: there was something to leak. A run where the refit asked for no frame
// would agree with the empty list below for the wrong reason.
expect(scheduler.pendingAtDispose).toBeGreaterThan(0)
expect(scheduler.leaked.filter((entry) => entry.includes(documentChunk))).toEqual([])
await page.unrouteAll({ behavior: 'ignoreErrors' })
await page.close()
}, 300_000)
it('measures a fit through the handle and records what beforeinput reports', async () => {
const { page } = await openTerminal()
await openProbeTerminal(page)
@@ -5,8 +5,10 @@ import { importTypeScriptModule } from './import-typescript-module.mjs'
import {
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_MODULE_ORDER,
TERMINAL_DOCUMENT_RESET_CALL,
TERMINAL_DOCUMENT_SCOPE_MODULE,
terminalDocumentStartFunctionName
terminalDocumentStartFunctionName,
terminalDocumentStopFunctionName
} from './terminal-document-module-order.mjs'
/**
@@ -169,15 +171,24 @@ export const TERMINAL_DOCUMENT_SCRIPT_PATH = path.join(
* how every module in this directory writes it.
*/
export async function terminalDocumentStartCalls(moduleNames) {
const calls = []
return await declaredFunctions(moduleNames, terminalDocumentStartFunctionName)
}
/** The stop functions, in module order. The page runs them in reverse; the WebView never stops. */
export async function terminalDocumentStopCalls(moduleNames) {
return await declaredFunctions(moduleNames, terminalDocumentStopFunctionName)
}
async function declaredFunctions(moduleNames, nameFor) {
const found = []
for (const name of moduleNames) {
const source = await readFile(path.join(documentDirectory, `${name}.ts`), 'utf8')
const startName = terminalDocumentStartFunctionName(name)
if (new RegExp(`^export function ${startName}\\(\\) \\{$`, 'm').test(source)) {
calls.push(startName)
const declared = nameFor(name)
if (new RegExp(`^export function ${declared}\\(\\) \\{$`, 'm').test(source)) {
found.push(declared)
}
}
return calls
return found
}
/**
@@ -198,10 +209,12 @@ export async function buildTerminalDocumentScript() {
for (const name of order) {
emitted.push(await emitTerminalDocumentModule(path.join(documentDirectory, `${name}.ts`)))
}
// Ruling 20: the modules above only declare. Every element read, listener and reporter install
// they used to do at parse time is in a start function, and this is where they run — once here,
// per mount on the page, in the one order both hosts share.
const calls = (await terminalDocumentStartCalls(order)).map((name) => `${INDENT}${name}();`)
// Rulings 20 and 21: the modules above only declare. The scope's reset comes first, so the
// state every module reads is the state a fresh parse has; then every element read, listener
// and reporter install runs, once here and per mount on the page, in the order both hosts share.
const calls = [TERMINAL_DOCUMENT_RESET_CALL, ...(await terminalDocumentStartCalls(order))].map(
(name) => `${INDENT}${name}();`
)
return `(function() {\n${emitted.join('\n')}\n${calls.join('\n')}\n})();`
}
@@ -16,7 +16,6 @@ export const TERMINAL_DOCUMENT_SCOPE_MODULE = 'document-scope'
export const TERMINAL_DOCUMENT_MODULE_ORDER = [
'runtime-constants',
'terminal-handle',
'query-reply',
'surface-swap',
'text-scaling',
@@ -70,3 +69,16 @@ export function terminalDocumentStartFunctionName(moduleName) {
.join('')
)
}
/** The per-module stop function's name, by the same convention (ruling 21). */
export function terminalDocumentStopFunctionName(moduleName) {
return terminalDocumentStartFunctionName(moduleName).replace(/^start/, 'stop')
}
/**
* The scope's reset, called ahead of every start (ruling 21).
*
* Module top level holds no mutable state, so a second mount's state comes from here and nowhere
* else. The WebView runs it once at parse, where it restores what the factory just built.
*/
export const TERMINAL_DOCUMENT_RESET_CALL = 'resetTerminalDocumentScope'
@@ -1,7 +1,10 @@
import { readdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs'
import {
buildTerminalDocumentScript,
emitTerminalDocumentModule
} from '../../../scripts/build-terminal-document-script.mjs'
import {
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
TERMINAL_DOCUMENT_MODULE_ORDER,
@@ -71,11 +74,25 @@ describe('the document module order', () => {
expect(emitted).toBe('')
})
it('emits the host seams ahead of the scope, whose defaults are those five functions', () => {
// Order, not just membership: `createTerminalDocumentScope()` runs as the script is parsed and
// reads the five by name, so a seams module emitted after it would throw on the first line of
// the document. The generator's own list is asserted in its test; this is the reason.
it('emits the host seams ahead of the scope, whose defaults are those five functions', async () => {
// Order in the emitted document, not membership in a list: `createTerminalDocumentScope()`
// runs as the script is parsed and reads the five by name, so a seams module emitted after it
// would throw on the document's first line. Non-membership cannot see that — it is satisfied
// by any arrangement — so the two texts are located in the document the generator produces.
expect(TERMINAL_DOCUMENT_MODULE_ORDER).not.toContain(TERMINAL_DOCUMENT_HOST_SEAMS_MODULE)
expect(TERMINAL_DOCUMENT_HOST_SEAMS_MODULE).not.toBe(TERMINAL_DOCUMENT_SCOPE_MODULE)
const script = await buildTerminalDocumentScript()
const emittedAt = async (name: string) => {
const text = await emitTerminalDocumentModule(
fileURLToPath(new URL(`./${name}.ts`, import.meta.url))
)
const at = script.indexOf(text)
expect(at, `${name} is not in the emitted document`).toBeGreaterThanOrEqual(0)
return at
}
expect(await emittedAt(TERMINAL_DOCUMENT_HOST_SEAMS_MODULE)).toBeLessThan(
await emittedAt(TERMINAL_DOCUMENT_SCOPE_MODULE)
)
})
})
@@ -8,7 +8,7 @@ import {
} from '../../../scripts/terminal-document-module-order.mjs'
/**
* Ruling 20: no module in the document does work as it is parsed.
* Rulings 20 and 21: no module in the document does work as it is parsed, and none owns state.
*
* ES module bodies run once per page. Inside the WebView that was invisible — the script is
* parsed once per document and the document is the page — but the web component mounts these same
@@ -20,6 +20,12 @@ import {
* emitted module may declare; none may run. What used to run lives in that module's start
* function, which both hosts call — the generated script once at the foot of the document, the
* page once per mount.
*
* Ruling 21 is the same argument about state rather than about effects. A module-level `let`
* survives a mount just as a module body does, so the second terminal inherited a spent error
* budget, the first terminal's committed surface and its momentum loop. Every mutable binding
* therefore lives on the scope, which the start sequence resets first, and module top level holds
* constants, functions and types only.
*/
const EMITTED = [
TERMINAL_DOCUMENT_HOST_SEAMS_MODULE,
@@ -125,6 +131,25 @@ function readsTheDocument(node: unknown): boolean {
return fieldsOf(node).some(([key, value]) => key !== 'type' && readsTheDocument(value))
}
/** Every top-level `let` or `var`: state the module owns, which a second mount would inherit. */
function mutableBindingsIn(name: string, source: string): string[] {
const { program } = parseSync(`${name}.ts`, source, { lang: 'ts' })
const found: string[] = []
for (const statement of program.body) {
const declaration =
statement.type === 'ExportNamedDeclaration' ? (statement.declaration ?? statement) : statement
if (declaration.type !== 'VariableDeclaration' || declaration.kind === 'const') {
continue
}
found.push(`${name}: ${source.slice(declaration.start, declaration.end).split('\n')[0]}`)
}
return found
}
function mutableBindings(name: string): string[] {
return mutableBindingsIn(name, moduleSource(name))
}
function parseTimeEffects(name: string): string[] {
const source = moduleSource(name)
const { program, errors } = parseSync(`${name}.ts`, source, { lang: 'ts' })
@@ -172,6 +197,17 @@ describe('the document modules at parse time', () => {
expect(topLevel.some(readsTheDocument)).toBe(false)
})
it('own no mutable state: no top-level let or var outside the scope', () => {
expect(EMITTED.filter((name) => name !== BUILDS_THE_SCOPE).flatMap(mutableBindings)).toEqual([])
})
it('would report a planted one, so the empty list above is a measurement', () => {
// The precondition for the case above, run against the same reader: a module body with a
// `let` in it is the exact shape the rule refuses, and the reader has to say so.
const planted = `import { scope } from './document-scope'\nlet spent = 0\nexport function n() {\n spent++\n return scope.term\n}\n`
expect(mutableBindingsIn('planted', planted)).toEqual(['planted: let spent = 0'])
})
it('would report one, so the empty list above is a measurement', () => {
// The precondition. A walk that matched nothing would agree with an empty expectation just as
// happily, so the same reader is aimed at a module that does have a top-level effect: this
@@ -185,13 +221,16 @@ describe('the document modules at parse time', () => {
expect(running.length).toBeGreaterThan(0)
})
it('still start: every module that had an effect exports the function holding it', () => {
it('still start and stop: the functions holding what was moved out are exported', () => {
// The other half. Moving an effect out is only correct if something calls it, and the caller
// is pinned by `page-document-module-order.test.ts` against the generator's own sequence;
// this holds the shape of the name so that sequence can be derived rather than listed.
const withStart = EMITTED.filter((name) =>
/^export function start[A-Za-z]+\(\) \{$/m.test(moduleSource(name))
)
expect(withStart.length).toBe(14)
// this holds the shape of the names so that sequence can be derived rather than listed.
const declaring = (keyword: string) =>
EMITTED.filter((name) =>
new RegExp(`^export function ${keyword}[A-Za-z]+\\(\\) \\{$`, 'm').test(moduleSource(name))
)
expect(declaring('start').length).toBe(10)
// Ruling 21: a module that schedules a frame, a timer or a retry owes an undo for it.
expect(declaring('stop').length).toBe(9)
})
})
+135 -4
View File
@@ -14,6 +14,9 @@ import type {
TerminalDocumentWebglAddon,
TerminalInitialOscLink
} from './document-terminal-shape'
import type { TerminalMouseGesture } from './mouse-click-drag'
import type { TerminalTouchState } from './surface-touch-gestures'
import type { TerminalTouchDispatch } from './tap-dispatch'
import type { TerminalDocumentThemeMessage } from './terminal-theme'
// Re-exported so every module that reads the scope keeps naming one import for both: the split is
@@ -42,8 +45,8 @@ export type * from './document-terminal-shape'
* The table grows one group at a time as C7.1 extracts them; a field arrives with its group.
*/
export type TerminalDocumentScope = {
/** `terminal-handle`: the live xterm terminal, or null before the first init. */
export type TerminalDocumentState = {
/** `terminal-init`: the live xterm terminal, or null before the first init. */
term: TerminalDocumentTerminal | null
/** `viewport-transform`: the surface's pan offset, in viewport pixels. */
panX: number
@@ -188,6 +191,40 @@ export type TerminalDocumentScope = {
surface: HTMLElement | null
/** `surface-swap`: the terminal of a hidden replacement surface that has not committed. */
pendingTerm: TerminalDocumentTerminal | null
/** `surface-swap`: the terminal the committed surface is showing. */
committedTerm: TerminalDocumentTerminal | null
/** `surface-swap`: the surface the committed terminal is mounted on. */
committedSurface: HTMLElement | null
/** `surface-swap`: the hidden replacement surface, until it commits. */
pendingSurface: HTMLElement | null
/** `text-scaling`: the scroll indicator's track and its thumb. */
scrollIndicator: HTMLElement | null
scrollThumb: HTMLElement | null
/** `query-reply`: whether the host asked for terminal data replies. */
terminalDataRepliesEnabled: boolean
/** `selection-state-and-eviction`: rows written since the terminal opened. */
linesEverWritten: number
/** `host-notify`: non-fatal reports already sent, against the flood cap. */
nonFatalErrorNotifies: number
/** `host-notify`: undoes the host's reporter install, or null before one. */
uninstallErrorReporter: (() => void) | null
/** `fit-scale`: the generation of the retry loop; a bump abandons the one in flight. */
fitRetryToken: number
/** `mouse-click-drag`: the mouse gesture in progress, or null. */
mouseGesture: TerminalMouseGesture | null
/** `tap-dispatch`: what the document-level dispatcher has latched onto. */
touchDispatch: TerminalTouchDispatch
/** `surface-touch-gestures`: the surface touch, its velocity and its momentum frame. */
touchGesture: TerminalTouchState
/** Every animation frame the document has asked for and not yet run. */
scheduledFrames: number[]
}
/**
* The five host seams, kept out of the state above because they are the one thing a reset must
* not touch: the page sets them once per mount, before the start sequence runs.
*/
export type TerminalDocumentHostSeams = {
/** `host-notify`, `viewport-transform`: where a message for the host goes. */
postToHost: (message: Record<string, unknown>) => void
/** `terminal-init`: builds the xterm terminal. */
@@ -200,6 +237,9 @@ export type TerminalDocumentScope = {
installErrorReporter: (report: TerminalDocumentErrorReporter) => () => void
}
/** The document's whole scope: its state, and the seams to whatever is hosting it. */
export type TerminalDocumentScope = TerminalDocumentState & TerminalDocumentHostSeams
/** The live selection; only the dragged handle is read outside the overlay slice. */
export type TerminalDocumentSelection = {
anchor: { row: number; col: number }
@@ -236,7 +276,7 @@ const statusDot = String.fromCharCode(0x23fa)
const textPresentationSelector = String.fromCharCode(0xfe0e)
const emojiPresentationSelector = String.fromCharCode(0xfe0f)
export function createTerminalDocumentScope(): TerminalDocumentScope {
function createTerminalDocumentState(): TerminalDocumentState {
return {
term: null,
panX: 0,
@@ -273,7 +313,7 @@ export function createTerminalDocumentScope(): TerminalDocumentScope {
handledMessageIds: [],
currentTextScale: 1,
terminalFontFamily: '',
firstDataPending: true,
firstDataPending: false,
activeAltScreenSnapshot: false,
currentScale: 1,
userScale: 1,
@@ -320,6 +360,43 @@ export function createTerminalDocumentScope(): TerminalDocumentScope {
wheelAccumDeltaY: 0,
surface: null,
pendingTerm: null,
committedTerm: null,
committedSurface: null,
pendingSurface: null,
scrollIndicator: null,
scrollThumb: null,
terminalDataRepliesEnabled: false,
linesEverWritten: 0,
nonFatalErrorNotifies: 0,
uninstallErrorReporter: null,
fitRetryToken: 0,
mouseGesture: null,
touchDispatch: {
mode: 'idle',
touchId: null,
touchIds: null,
longPressFingerInsideOverlay: false
},
scheduledFrames: [],
touchGesture: {
lastX: 0,
lastY: 0,
lastTime: 0,
velY: 0,
accumDelta: 0,
momentumId: null,
isPinching: false,
pinchDist: 0,
pinchScale: 0,
pinchSurfX: 0,
pinchSurfY: 0
}
}
}
/** The seams' defaults: the window reads and writes the document already did. */
function createTerminalDocumentHostSeams(): TerminalDocumentHostSeams {
return {
postToHost: postToReactNativeWebView,
createTerminal: createEngineTerminal,
createUnicode11Addon: createEngineUnicode11Addon,
@@ -328,5 +405,59 @@ export function createTerminalDocumentScope(): TerminalDocumentScope {
}
}
export function createTerminalDocumentScope(): TerminalDocumentScope {
return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams() }
}
/**
* The scope back at the state a freshly parsed document has (ruling 21).
*
* The page mounts these modules more than once and an ES module body runs once per page, so this
* is what makes a second mount a second document: the start sequence calls it first, and on the
* WebView it runs once at parse, where it changes nothing. The seams are left alone — the page
* sets them before the sequence runs, and they belong to the host rather than to the terminal.
*
* Two counters carry forward instead of resetting, because they are what a stale callback is
* tested against: a frame scheduled by the mount that just went away compares its captured number
* with the one here, and a reset to zero would make the old number match again.
*/
export function resetTerminalDocumentScope() {
const generations = {
terminalGeneration: scope.terminalGeneration + 1,
fitRetryToken: scope.fitRetryToken + 1
}
Object.assign(scope, createTerminalDocumentState(), generations)
}
/** The document's own scope. The generator emits this declaration at the top of the script. */
export const scope: TerminalDocumentScope = createTerminalDocumentScope()
/**
* An animation frame the document can take back (ruling 21).
*
* A generation guard makes a stale frame *do* nothing; it still runs, and inside a WebView that
* is the same thing. On the page it is not: the mount that scheduled the frame may be gone and
* the next one already up, and a callback that reads the scope reads the new mount's. Every frame
* the document asks for is registered here so `cancelDocumentFrames` can take the pending ones
* back, which is what the page's dispose does. The id is dropped as the frame runs, so the list
* holds only what is still owed.
*/
export function scheduleDocumentFrame(callback: FrameRequestCallback) {
const id = requestAnimationFrame(function (time) {
const at = scope.scheduledFrames.indexOf(id)
if (at !== -1) {
scope.scheduledFrames.splice(at, 1)
}
callback(time)
})
scope.scheduledFrames.push(id)
return id
}
/** Takes back every frame the document is still owed. */
export function cancelDocumentFrames() {
for (const id of scope.scheduledFrames) {
cancelAnimationFrame(id)
}
scope.scheduledFrames = []
}
+13 -6
View File
@@ -6,7 +6,7 @@ import {
getTotalScale,
updateTransform
} from './viewport-transform'
import { scope } from './document-scope'
import { scope, scheduleDocumentFrame } from './document-scope'
export function getCellHeight() {
if (!scope.term || !scope.term._core) {
@@ -61,16 +61,15 @@ export function adjustRowsForViewport() {}
// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
// so a backgrounded WebView never spins forever.
const FIT_RETRY_MAX_FRAMES = 60
let fitRetryToken = 0
export function applyFitScale(reason: string) {
if (!scope.term || !scope.term.element) {
return
}
const token = ++fitRetryToken
const token = ++scope.fitRetryToken
let attempts = 0
let lastScrollWidth = -1
function attempt() {
if (token !== fitRetryToken) {
if (token !== scope.fitRetryToken) {
return
}
if (!scope.term || !scope.term.element) {
@@ -99,9 +98,9 @@ export function applyFitScale(reason: string) {
commitFitScale(reason, attempts, 'timeout')
return
}
requestAnimationFrame(attempt)
scheduleDocumentFrame(attempt)
}
requestAnimationFrame(attempt)
scheduleDocumentFrame(attempt)
}
export function commitFitScale(reason: string, attempts: number, gate: string) {
@@ -144,3 +143,11 @@ export function commitFitScale(reason: string, attempts: number, gate: string) {
}
repositionOverlay()
}
/**
* Ruling 21: the retry loop is abandoned by bumping the token it compares itself against, which is
* how it already abandons a superseded attempt.
*/
export function stopFitScale() {
scope.fitRetryToken++
}
@@ -1,4 +1,4 @@
import { scope } from './document-scope'
import { scope, scheduleDocumentFrame } from './document-scope'
import { applyFitScale } from './fit-scale'
import { notify } from './host-notify'
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
@@ -49,7 +49,12 @@ export function measureFitDimensions(containerHeightPx: unknown, retriesLeft?: n
}
if (notReady || cellWidth <= 0 || cellHeight <= 0) {
if (retriesLeft > 0) {
requestAnimationFrame(function () {
// Ruling 21: a retry that outlives its mount would answer the next mount's measure.
const gen = scope.terminalGeneration
scheduleDocumentFrame(function () {
if (gen !== scope.terminalGeneration) {
return
}
measureFitDimensions(containerHeightPx, retriesLeft - 1)
})
return
+6 -10
View File
@@ -44,15 +44,13 @@ export function chromeVersionText() {
return match ? 'Chrome ' + match[1] : 'Chrome version unknown'
}
let nonFatalErrorNotifies = 0
export function reportEngineError(context: string, err: TerminalEngineError, fatal?: unknown) {
const isFatal = fatal === undefined ? !scope.everReady : !!fatal
if (!isFatal) {
// Why: a constructed-but-degraded engine can throw per frame; cap
// non-fatal notifies so RN isn't flooded. Fatal reports always emit.
nonFatalErrorNotifies++
if (nonFatalErrorNotifies > 5) {
scope.nonFatalErrorNotifies++
if (scope.nonFatalErrorNotifies > 5) {
return
}
}
@@ -72,10 +70,8 @@ export function reportEngineError(context: string, err: TerminalEngineError, fat
})
}
let uninstallErrorReporter: (() => void) | null = null
export function startHostNotify() {
uninstallErrorReporter = scope.installErrorReporter(function (
scope.uninstallErrorReporter = scope.installErrorReporter(function (
msg: string | (Event & { message?: unknown }),
source,
line,
@@ -90,8 +86,8 @@ export function startHostNotify() {
}
export function stopHostNotify() {
if (uninstallErrorReporter) {
uninstallErrorReporter()
uninstallErrorReporter = null
if (scope.uninstallErrorReporter) {
scope.uninstallErrorReporter()
scope.uninstallErrorReporter = null
}
}
@@ -37,13 +37,3 @@ export function emitModesIfChanged() {
})
}
}
export function startModeMirroring() {
scope.lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
}
}
@@ -20,8 +20,6 @@ export type TerminalMouseGesture = {
dismissedSelection: boolean
}
let mouseGesture: TerminalMouseGesture | null = null
// One report per transition, built with the same encoding ladder as
// buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns ''
// when the mode does not report this transition (x10 has no release, only
@@ -81,8 +79,8 @@ export function mouseReportCellKey(clientX: number, clientY: number) {
}
export function abandonMouseGesture() {
const gesture = mouseGesture
mouseGesture = null
const gesture = scope.mouseGesture
scope.mouseGesture = null
if (!gesture) {
return
}
@@ -141,7 +139,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
}
// Why: a pointerup lost outside the WebView must not leave the previous
// gesture latched (tracking press with no release) when the next one lands.
if (mouseGesture) {
if (scope.mouseGesture) {
abandonMouseGesture()
}
// Why: mouse pointers have no implicit capture; without it a drag that
@@ -151,7 +149,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
targetSurface.setPointerCapture(e.pointerId)
}
} catch {}
mouseGesture = {
scope.mouseGesture = {
startX: e.clientX,
startY: e.clientY,
lastX: e.clientX,
@@ -165,7 +163,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
// Why: touch parity — pressing outside the pill dismisses the current
// selection; the same press may still start a new drag selection.
cancelSelect()
mouseGesture.dismissedSelection = true
scope.mouseGesture.dismissedSelection = true
}
},
true
@@ -174,7 +172,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
targetSurface.addEventListener(
'pointermove',
function (e) {
const gesture = mouseGesture
const gesture = scope.mouseGesture
if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') {
return
}
@@ -220,11 +218,11 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
targetSurface.addEventListener(
'pointerup',
function (e) {
const gesture = mouseGesture
const gesture = scope.mouseGesture
if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) {
return
}
mouseGesture = null
scope.mouseGesture = null
if (gesture.mode === 'cancelled' || !scope.term) {
return
}
@@ -274,7 +272,7 @@ export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
targetSurface.addEventListener(
'touchstart',
function () {
if (mouseGesture) {
if (scope.mouseGesture) {
abandonMouseGesture()
}
},
@@ -1,6 +1,6 @@
import { getCellHeight } from './fit-scale'
import { getTotalScale, updateScrollIndicator } from './viewport-transform'
import { scope } from './document-scope'
import { scope, scheduleDocumentFrame } from './document-scope'
export function clampNormalScrollLines(lines: number) {
if (!scope.term || !scope.term.buffer || !scope.term.buffer.active || lines === 0) {
@@ -77,7 +77,7 @@ export function enqueueNormalBufferScrollDelta(deltaY: number) {
// Why: dense terminal rows are expensive to repaint. Coalesce touchmove
// deltas into one xterm row-scroll per frame instead of repainting from
// the input event stream.
scope.normalScrollFrameId = requestAnimationFrame(function () {
scope.normalScrollFrameId = scheduleDocumentFrame(function () {
scope.normalScrollFrameId = null
const delta = scope.pendingNormalScrollDeltaY
scope.pendingNormalScrollDeltaY = 0
@@ -100,3 +100,8 @@ export function resetSmoothScrollOffset() {
scope.smoothScrollOffsetY = 0
updateScrollIndicator(false)
}
/** Ruling 21: the smooth-scroll frame, which would otherwise scroll the next mount's buffer. */
export function stopNormalBufferSmoothScroll() {
resetSmoothScrollOffset()
}
@@ -1,93 +1,96 @@
/**
* The document's modules, and the one sequence that starts them.
* The document's modules, and the two sequences that start and stop them.
*
* The document is one function scope, not a dependency graph: `runtime-constants` takes the
* surface, `surface-swap` captures the surface it was handed, and `selection-state-and-eviction`
* takes the overlay elements. Ruling 20 moved each of those out of the module body and into a
* start function, so importing this file does nothing on its own; the order below is still the
* order, because the start calls follow it and the WebView's generated script emits the same
* sequence at the foot of the document.
* takes the overlay elements. Ruling 20 moved those out of the module bodies into start
* functions; ruling 21 moved every mutable binding onto the scope, so what is left at module top
* level is constants, functions and types. Importing this file therefore does nothing at all.
*
* That is what makes a second mount a second terminal. ES module bodies run once per page, so a
* remount re-imports nothing; it calls `startPageDocumentModules` again, and every element read
* and listener install happens against the markup the host has just replanted.
* remount re-imports nothing: it resets the scope, then runs the same start sequence the WebView's
* generated script runs once at parse, against the markup the host has just replanted.
*
* `message-bridge` is deliberately absent (ruling 19). It installs `window`/`document` `message`
* listeners, and on the page those frames belong to the shell: the document would read a bridge
* envelope as a terminal command. The component calls `handleMsg` instead, and re-arms the one
* other thing that module does, the window-resize refit.
*
* `page-document-module-order.test.ts` holds this list against
* `page-document-module-order.test.ts` holds these lists against
* `scripts/terminal-document-module-order.mjs`, so the page and the WebView cannot run different
* programs and a reordering edit cannot pass unread.
*/
import './document-host-seams'
import './document-scope'
import { cancelDocumentFrames, resetTerminalDocumentScope } from './document-scope'
import { startRuntimeConstants } from './runtime-constants'
import { startTerminalHandle } from './terminal-handle'
import './query-reply'
import { startSurfaceSwap } from './surface-swap'
import { startTextScaling } from './text-scaling'
import { startViewportTransform } from './viewport-transform'
import { stopViewportTransform } from './viewport-transform'
import './terminal-theme'
import './fit-scale'
import { stopFitScale } from './fit-scale'
import './mouse-mode-decset-scan'
import './write-queue'
import { startWebglRecovery, stopWebglRecovery } from './webgl-recovery'
import './terminal-init'
import { stopTerminalInit } from './terminal-init'
import './reflow'
import { startHostNotify, stopHostNotify } from './host-notify'
import './host-message-router'
import { startSelectionStateAndEviction } from './selection-state-and-eviction'
import { startModeMirroring } from './mode-mirroring'
import './mode-mirroring'
import './keyboard-avoidance-metrics'
import './term-observers'
import './viewport-cell'
import './mouse-report-cell'
import './mouse-input-encoding'
import './normal-buffer-smooth-scroll'
import { stopNormalBufferSmoothScroll } from './normal-buffer-smooth-scroll'
import './cell-geometry'
import './path-tap'
import './url-tap'
import './osc-link-tap'
import './surface-tap'
import './selection-range'
import './selection-overlay'
import { stopSelectionOverlay } from './selection-overlay'
import { startTapDispatch, stopTapDispatch } from './tap-dispatch'
import { startWheelScroll } from './wheel-scroll'
import './wheel-scroll'
import './mouse-click-drag'
import { startSelectionMenuButtons } from './selection-menu-buttons'
import { startSurfaceTouchGestures } from './surface-touch-gestures'
import { startSurfaceTouchGestures, stopSurfaceTouchGestures } from './surface-touch-gestures'
/**
* Every module's start function, in module order: what the WebView's document runs once as its
* script is parsed, run here once per mount.
* The scope's reset and every module's start function, in module order: what the WebView's
* document runs once as its script is parsed, run here once per mount.
*/
export function startPageDocumentModules() {
resetTerminalDocumentScope()
startRuntimeConstants()
startTerminalHandle()
startSurfaceSwap()
startTextScaling()
startViewportTransform()
startWebglRecovery()
startHostNotify()
startSelectionStateAndEviction()
startModeMirroring()
startTapDispatch()
startWheelScroll()
startSelectionMenuButtons()
startSurfaceTouchGestures()
}
/**
* The undo, in reverse. Only the three modules that reach past the host element need one: every
* other listener is on the surface or the menu buttons, which the host replaces wholesale, and
* every other start writes a scope field the next start overwrites.
* The undo, in reverse module order: every listener that outlives the host element, and every
* frame, timer and retry a module scheduled (ruling 21).
*/
export function stopPageDocumentModules() {
// Last frames first: a module's own stop nulls the handle it holds, and this takes back every
// frame the document is still owed, including the ones no module tracks by id.
cancelDocumentFrames()
stopSurfaceTouchGestures()
stopTapDispatch()
stopSelectionOverlay()
stopNormalBufferSmoothScroll()
stopHostNotify()
stopTerminalInit()
stopWebglRecovery()
stopFitScale()
stopViewportTransform()
}
export { scope } from './document-scope'
+4 -8
View File
@@ -18,20 +18,16 @@ export type QueryReplyTerminal = {
onData: (listener: (data: string) => void) => TerminalDocumentDisposable
}
// Written from four places, all of them here, so it is this module's state rather than the
// document's and stays a local.
let terminalDataRepliesEnabled = false
export function resetTerminalDataReplyAuthority() {
terminalDataRepliesEnabled = false
scope.terminalDataRepliesEnabled = false
}
export function resumeTerminalDataReplyAuthority() {
terminalDataRepliesEnabled = true
scope.terminalDataRepliesEnabled = true
}
export function forwardTerminalDataReply(data: string) {
if (terminalDataRepliesEnabled) {
if (scope.terminalDataRepliesEnabled) {
notify({ type: 'terminal-data', bytes: data })
}
}
@@ -39,7 +35,7 @@ export function forwardTerminalDataReply(data: string) {
export function enqueueTerminalDataReplyBoundary(gen: number) {
enqueueWriteBoundary(function () {
if (gen === scope.terminalGeneration) {
terminalDataRepliesEnabled = true
scope.terminalDataRepliesEnabled = true
}
})
}
@@ -9,18 +9,4 @@ import { scope } from './document-scope'
export function startRuntimeConstants() {
scope.surface = document.getElementById('terminal-surface')
scope.ESC = String.fromCharCode(27)
scope.C1_CSI = String.fromCharCode(155)
scope.CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa)
scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e)
scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f)
scope.CLAUDE_STATUS_DOT_PATTERN = new RegExp(
scope.CLAUDE_STATUS_DOT +
'[' +
scope.TEXT_PRESENTATION_SELECTOR +
scope.EMOJI_PRESENTATION_SELECTOR +
']*',
'g'
)
scope.statusDotPendingSelector = false
}
@@ -139,3 +139,8 @@ export function handleDragMove(handle: string, clientX: number, clientY: number)
}
// Latching document-level touch dispatcher: see tap-dispatch.ts.
/** Ruling 21: the edge-scroll interval, which outlives the selection that started it. */
export function stopSelectionOverlay() {
stopEdgeScroll()
}
@@ -23,20 +23,19 @@ import { scope } from './document-scope'
// the tap (which opens links/paths). {x,y,t,identifier} or null once the
// gesture is disqualified as a tap (moved too far or held too long).
// Eviction watchdog: linesEverWritten counts onLineFeed since last init.
// Eviction watchdog: linesEverWritten counts onLineFeed since the last init.
// Once buffer is full, every onLineFeed evicts the top row in xterm and
// we mirror that by decrementing stored absolute rows.
let linesEverWritten = 0
export function resetEvictionCounter() {
linesEverWritten = 0
scope.linesEverWritten = 0
}
export function isBufferFull() {
if (!scope.term) {
return false
}
return linesEverWritten >= 5000 + (scope.term.rows || 0)
return scope.linesEverWritten >= 5000 + (scope.term.rows || 0)
}
export function checkEviction() {
@@ -51,7 +50,7 @@ export function checkEviction() {
}
export function logFeedAndEvict() {
linesEverWritten++
scope.linesEverWritten++
if (scope.initialOscLinkEvictionReady && isBufferFull()) {
scope.initialOscLinkRowOffset += 1
}
@@ -64,26 +63,10 @@ export function logFeedAndEvict() {
}
export function startSelectionStateAndEviction() {
scope.WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u
scope.LONG_PRESS_MS = 500
scope.LONG_PRESS_SLOP = 10
scope.TAP_SLOP = 24
scope.TAP_MAX_MS = 700
scope.EDGE_SCROLL_PX = 40
scope.EDGE_SCROLL_INTERVAL = 60
scope.selectionOverlay = document.getElementById('selection-overlay')
scope.handleStart = document.getElementById('sel-handle-start')
scope.handleEnd = document.getElementById('sel-handle-end')
scope.selMenu = document.getElementById('sel-menu')
scope.btnCopy = document.getElementById('sel-menu-copy')
scope.btnSelAll = document.getElementById('sel-menu-all')
scope.selMode = 'navigate'
scope.sel = null
scope.longPressTimer = null
scope.longPressOrigin = null
scope.tapCandidate = null
scope.edgeScrollTimer = null
scope.edgeScrollDir = 0
scope.edgeScrollClientX = 0
scope.edgeScrollClientY = 0
}
+13 -18
View File
@@ -9,31 +9,24 @@ export type TerminalSurfaceSwap = {
nextSurface: HTMLElement
}
// Why: phone-fit startup can issue several init() calls before xterm finishes
// replaying. Track the last painted surface separately from its replacement.
let committedTerm: TerminalDocumentTerminal | null = null
let committedSurface: HTMLElement | null = null
let pendingSurface: HTMLElement | null = null
export function beginTerminalSurfaceSwap() {
// Why: a superseded hidden replacement must not remain between the last
// painted surface and the newest one, or the newest commits below the viewport.
if (pendingSurface) {
if (scope.pendingSurface) {
try {
pendingSurface.remove()
scope.pendingSurface.remove()
} catch {}
if (scope.pendingTerm) {
try {
scope.pendingTerm.dispose()
} catch {}
}
pendingSurface = null
scope.pendingSurface = null
scope.pendingTerm = null
}
const swap = {
oldTerm: committedTerm,
oldSurface: committedSurface,
oldTerm: scope.committedTerm,
oldSurface: scope.committedSurface,
nextSurface: document.createElement('div')
}
disposeTermObservers()
@@ -44,7 +37,7 @@ export function beginTerminalSurfaceSwap() {
swap.nextSurface.style.top = '0'
document.getElementById('terminal-container')!.appendChild(swap.nextSurface)
scope.surface = swap.nextSurface
pendingSurface = swap.nextSurface
scope.pendingSurface = swap.nextSurface
attachSurfaceEventHandlers(scope.surface)
swap.oldSurface!.removeAttribute('id')
return swap
@@ -62,13 +55,15 @@ export function commitTerminalSurfaceSwap(
if (swap.oldTerm) {
swap.oldTerm.dispose()
}
committedTerm = nextTerm
committedSurface = swap.nextSurface
scope.committedTerm = nextTerm
scope.committedSurface = swap.nextSurface
scope.pendingTerm = null
pendingSurface = null
scope.pendingSurface = null
}
// Why: phone-fit startup can issue several init() calls before xterm finishes replaying, so the
// last painted surface is tracked apart from its replacement — on the scope (ruling 21), because
// the page mounts this module more than once and a second mount must not inherit the first's.
export function startSurfaceSwap() {
committedSurface = scope.surface
scope.pendingTerm = null
scope.committedSurface = scope.surface
}
@@ -1,4 +1,4 @@
import { scope } from './document-scope'
import { scope, scheduleDocumentFrame } from './document-scope'
import { clampPan, getCellHeight } from './fit-scale'
import { notify } from './host-notify'
import { attachSurfaceMouseClickDragHandler } from './mouse-click-drag'
@@ -17,7 +17,7 @@ import { attachSurfaceWheelHandler } from './wheel-scroll'
type TerminalGestureSurface = HTMLElement & { __orcaSurfaceHandlersAttached?: boolean }
/** The live touch gesture: the last point, the velocity, and the pinch it may be in. */
type TerminalTouchState = {
export type TerminalTouchState = {
lastX: number
lastY: number
lastTime: number
@@ -31,20 +31,6 @@ type TerminalTouchState = {
pinchSurfY: number
}
const ts: TerminalTouchState = {
lastX: 0,
lastY: 0,
lastTime: 0,
velY: 0,
accumDelta: 0,
momentumId: null,
isPinching: false,
pinchDist: 0,
pinchScale: 0,
pinchSurfX: 0,
pinchSurfY: 0
}
export function updateTouchVelocity(deltaY: number, dt: number) {
if (dt <= 0) {
return
@@ -55,7 +41,10 @@ export function updateTouchVelocity(deltaY: number, dt: number) {
}
// Why: touchmove cadence is uneven in WebView. Blend recent samples so
// momentum launch doesn't inherit a one-frame spike or stall.
ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45
scope.touchGesture.velY =
scope.touchGesture.velY === 0
? instantVelocity
: scope.touchGesture.velY * 0.55 + instantVelocity * 0.45
}
export function getDistance(a: Touch, b: Touch) {
@@ -97,27 +86,27 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
if (dispatcherShouldBlockSurface()) {
return
}
if (ts.momentumId) {
cancelAnimationFrame(ts.momentumId)
ts.momentumId = null
if (scope.touchGesture.momentumId) {
cancelAnimationFrame(scope.touchGesture.momentumId)
scope.touchGesture.momentumId = null
}
if (e.touches.length === 2) {
ts.isPinching = true
scope.touchGesture.isPinching = true
scope.smoothScrollOffsetY = 0
ts.pinchDist = getDistance(e.touches[0], e.touches[1])
ts.pinchScale = scope.userScale
scope.touchGesture.pinchDist = getDistance(e.touches[0], e.touches[1])
scope.touchGesture.pinchScale = scope.userScale
const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2
const my = (e.touches[0].clientY + e.touches[1].clientY) / 2
const total = getTotalScale()
ts.pinchSurfX = (mx - scope.panX) / total
ts.pinchSurfY = (my - scope.panY) / total
scope.touchGesture.pinchSurfX = (mx - scope.panX) / total
scope.touchGesture.pinchSurfY = (my - scope.panY) / total
} else if (e.touches.length === 1) {
ts.isPinching = false
ts.lastX = e.touches[0].clientX
ts.lastY = e.touches[0].clientY
ts.lastTime = Date.now()
ts.velY = 0
ts.accumDelta = 0
scope.touchGesture.isPinching = false
scope.touchGesture.lastX = e.touches[0].clientX
scope.touchGesture.lastY = e.touches[0].clientY
scope.touchGesture.lastTime = Date.now()
scope.touchGesture.velY = 0
scope.touchGesture.accumDelta = 0
}
},
{ capture: true, passive: true }
@@ -136,28 +125,31 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
e.stopPropagation()
if (e.touches.length === 2) {
ts.isPinching = true
scope.touchGesture.isPinching = true
const dist = getDistance(e.touches[0], e.touches[1])
const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2
const my = (e.touches[0].clientY + e.touches[1].clientY) / 2
const ratio = dist / ts.pinchDist
const ratio = dist / scope.touchGesture.pinchDist
// Why: userScale is a CSS multiplier on the current font size; bound it so
// the resulting apparent size (currentTextScale × userScale) stays within
// the preset range, since release snaps to one of those presets.
const loScale = scope.MIN_TEXT_SCALE / scope.currentTextScale
const hiScale = scope.MAX_TEXT_SCALE / scope.currentTextScale
scope.userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio))
scope.userScale = Math.max(
loScale,
Math.min(hiScale, scope.touchGesture.pinchScale * ratio)
)
const total = getTotalScale()
scope.panX = mx - ts.pinchSurfX * total
scope.panY = my - ts.pinchSurfY * total
scope.panX = mx - scope.touchGesture.pinchSurfX * total
scope.panY = my - scope.touchGesture.pinchSurfY * total
clampPan()
updateTransform()
} else if (e.touches.length === 1 && !ts.isPinching) {
} else if (e.touches.length === 1 && !scope.touchGesture.isPinching) {
const x = e.touches[0].clientX,
y = e.touches[0].clientY
const now = Date.now(),
dt = now - ts.lastTime
dt = now - scope.touchGesture.lastTime
// Why: pan horizontally only when content overflows the viewport (larger
// than fit) — same check clampPan() uses. Vertical always drives buffer
@@ -168,32 +160,32 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
scope.term.element &&
scope.term.element.scrollWidth * getTotalScale() > window.innerWidth + 1
) {
scope.panX += x - ts.lastX
scope.panX += x - scope.touchGesture.lastX
clampPan()
updateTransform()
}
const deltaY = ts.lastY - y
ts.lastTime = now
const deltaY = scope.touchGesture.lastY - y
scope.touchGesture.lastTime = now
if (shouldRouteScrollToTerminalInput()) {
updateTouchVelocity(deltaY, dt)
resetSmoothScrollOffset()
const effectiveCellH = getCellHeight() * getTotalScale()
ts.accumDelta += deltaY
const lines = Math.trunc(ts.accumDelta / effectiveCellH)
scope.touchGesture.accumDelta += deltaY
const lines = Math.trunc(scope.touchGesture.accumDelta / effectiveCellH)
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH
scope.touchGesture.accumDelta -= lines * effectiveCellH
routeScrollLines(lines, x, y)
}
} else {
if (enqueueNormalBufferScrollDelta(deltaY)) {
updateTouchVelocity(deltaY, dt)
} else {
ts.velY = 0
scope.touchGesture.velY = 0
}
}
ts.lastX = x
ts.lastY = y
scope.touchGesture.lastX = x
scope.touchGesture.lastY = y
}
},
{ capture: true, passive: false }
@@ -209,8 +201,8 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
return
}
if (ts.isPinching && e.touches.length < 2) {
ts.isPinching = false
if (scope.touchGesture.isPinching && e.touches.length < 2) {
scope.touchGesture.isPinching = false
// Why: a finished pinch snaps to the nearest preset and becomes the new
// font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way
// to set the text size. The CSS pinch zoom (userScale) is reset; the real
@@ -227,45 +219,45 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
notify({ type: 'haptic', kind: 'selection' })
}
if (e.touches.length === 1) {
ts.lastX = e.touches[0].clientX
ts.lastY = e.touches[0].clientY
ts.lastTime = Date.now()
ts.velY = 0
ts.accumDelta = 0
scope.touchGesture.lastX = e.touches[0].clientX
scope.touchGesture.lastY = e.touches[0].clientY
scope.touchGesture.lastTime = Date.now()
scope.touchGesture.velY = 0
scope.touchGesture.accumDelta = 0
}
return
}
if (e.touches.length === 0) {
let vel = ts.velY
let vel = scope.touchGesture.velY
const FRICTION = 0.972
const MIN_VEL = 0.012
function momentumStep() {
vel *= FRICTION
if (Math.abs(vel) < MIN_VEL) {
ts.momentumId = null
scope.touchGesture.momentumId = null
return
}
const delta = vel * 16
if (shouldRouteScrollToTerminalInput()) {
resetSmoothScrollOffset()
const effectiveCellH = getCellHeight() * getTotalScale()
ts.accumDelta += delta
const lines = Math.trunc(ts.accumDelta / effectiveCellH)
scope.touchGesture.accumDelta += delta
const lines = Math.trunc(scope.touchGesture.accumDelta / effectiveCellH)
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH
routeScrollLines(lines, ts.lastX, ts.lastY)
scope.touchGesture.accumDelta -= lines * effectiveCellH
routeScrollLines(lines, scope.touchGesture.lastX, scope.touchGesture.lastY)
}
} else {
if (!applyNormalBufferScrollDelta(delta)) {
ts.momentumId = null
scope.touchGesture.momentumId = null
return
}
}
ts.momentumId = requestAnimationFrame(momentumStep)
scope.touchGesture.momentumId = scheduleDocumentFrame(momentumStep)
}
if (Math.abs(vel) > MIN_VEL) {
ts.momentumId = requestAnimationFrame(momentumStep)
scope.touchGesture.momentumId = scheduleDocumentFrame(momentumStep)
}
}
},
@@ -276,3 +268,11 @@ export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface
export function startSurfaceTouchGestures() {
attachSurfaceEventHandlers(scope.surface!)
}
/** Ruling 21: the momentum loop, which would keep scrolling into the terminal that replaced it. */
export function stopSurfaceTouchGestures() {
if (scope.touchGesture.momentumId !== null) {
cancelAnimationFrame(scope.touchGesture.momentumId)
scope.touchGesture.momentumId = null
}
}
+27 -33
View File
@@ -20,13 +20,6 @@ export type TerminalTouchDispatch = {
/** An element a target can be tested against; a method so a real element satisfies it. */
type TerminalDocumentTargetContainer = { contains(other: EventTarget | null): boolean }
const dispatch: TerminalTouchDispatch = {
mode: 'idle',
touchId: null,
touchIds: null,
longPressFingerInsideOverlay: false
}
export function touchById(touches: TouchList, id: number | null) {
for (let i = 0; i < touches.length; i++) {
if (touches[i].identifier === id) {
@@ -81,7 +74,7 @@ export function touchSlopExceeded(t: Touch) {
// Why: existing surface handlers stay attached to surface but we wrap
// their entry to no-op when the dispatcher latches into select-drag.
export function dispatcherShouldBlockSurface() {
return dispatch.mode === 'select-drag'
return scope.touchDispatch.mode === 'select-drag'
}
/**
@@ -108,8 +101,8 @@ function onDocumentTouchStart(e: TouchEvent) {
notify({ type: 'mobile-clip-cancel-by-pinch' })
cancelSelect()
}
dispatch.mode = 'pinch'
dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier]
scope.touchDispatch.mode = 'pinch'
scope.touchDispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier]
clearLongPress()
return
}
@@ -118,8 +111,8 @@ function onDocumentTouchStart(e: TouchEvent) {
// start handle drag
const handleName = target === scope.handleStart ? 'start' : 'end'
scope.sel!.activeHandle = handleName
dispatch.mode = 'select-drag'
dispatch.touchId = t.identifier
scope.touchDispatch.mode = 'select-drag'
scope.touchDispatch.touchId = t.identifier
e.preventDefault()
return
}
@@ -134,22 +127,22 @@ function onDocumentTouchStart(e: TouchEvent) {
// selection clears it. We cancel immediately and latch to 'surface' so
// the same gesture still drives scroll/pan without a second touch.
cancelSelect()
dispatch.mode = 'surface'
dispatch.touchId = t.identifier
scope.touchDispatch.mode = 'surface'
scope.touchDispatch.touchId = t.identifier
return
}
if (inSurface) {
dispatch.mode = 'surface'
dispatch.touchId = t.identifier
scope.touchDispatch.mode = 'surface'
scope.touchDispatch.touchId = t.identifier
scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier }
armLongPress(t)
}
}
function onDocumentTouchMove(e: TouchEvent) {
if (dispatch.mode === 'select-drag') {
const t = touchById(e.touches, dispatch.touchId)
if (scope.touchDispatch.mode === 'select-drag') {
const t = touchById(e.touches, scope.touchDispatch.touchId)
if (!t || !scope.sel || !scope.sel.activeHandle) {
return
}
@@ -157,7 +150,7 @@ function onDocumentTouchMove(e: TouchEvent) {
handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY)
return
}
if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') {
if (scope.touchDispatch.mode === 'surface' || scope.touchDispatch.mode === 'pinch') {
// long-press slop check
if (scope.longPressTimer && e.touches.length === 1) {
if (touchSlopExceeded(e.touches[0])) {
@@ -184,26 +177,26 @@ function onDocumentTouchMove(e: TouchEvent) {
}
function onDocumentTouchEnd(e: TouchEvent) {
if (dispatch.mode === 'select-drag') {
if (scope.touchDispatch.mode === 'select-drag') {
if (scope.sel) {
scope.sel.activeHandle = null
}
stopEdgeScroll()
dispatch.mode = 'idle'
dispatch.touchId = null
scope.touchDispatch.mode = 'idle'
scope.touchDispatch.touchId = null
return
}
if (dispatch.mode === 'pinch') {
if (scope.touchDispatch.mode === 'pinch') {
if (e.touches.length < 2) {
dispatch.mode = e.touches.length === 1 ? 'surface' : 'idle'
dispatch.touchIds = null
scope.touchDispatch.mode = e.touches.length === 1 ? 'surface' : 'idle'
scope.touchDispatch.touchIds = null
if (e.touches.length === 1) {
dispatch.touchId = e.touches[0].identifier
scope.touchDispatch.touchId = e.touches[0].identifier
}
}
return
}
if (dispatch.mode === 'surface') {
if (scope.touchDispatch.mode === 'surface') {
// Why: fire the tap from the tap-candidate origin (survives jitter under
// TAP_SLOP) rather than longPressOrigin, which the press-to-select slop
// can null mid-tap — that was dropping URL/file taps that moved a few px.
@@ -218,8 +211,8 @@ function onDocumentTouchEnd(e: TouchEvent) {
clearLongPress()
scope.tapCandidate = null
if (e.touches.length === 0) {
dispatch.mode = 'idle'
dispatch.touchId = null
scope.touchDispatch.mode = 'idle'
scope.touchDispatch.touchId = null
}
}
}
@@ -228,14 +221,14 @@ function onDocumentTouchCancel() {
clearLongPress()
scope.tapCandidate = null
stopEdgeScroll()
if (dispatch.mode === 'select-drag') {
if (scope.touchDispatch.mode === 'select-drag') {
if (scope.sel) {
scope.sel.activeHandle = null
}
}
dispatch.mode = 'idle'
dispatch.touchId = null
dispatch.touchIds = null
scope.touchDispatch.mode = 'idle'
scope.touchDispatch.touchId = null
scope.touchDispatch.touchIds = null
}
/**
@@ -256,4 +249,5 @@ export function stopTapDispatch() {
document.removeEventListener('touchmove', onDocumentTouchMove, CAPTURE_ACTIVE)
document.removeEventListener('touchend', onDocumentTouchEnd, CAPTURE_PASSIVE)
document.removeEventListener('touchcancel', onDocumentTouchCancel, CAPTURE_PASSIVE)
clearLongPress()
}
@@ -1,13 +0,0 @@
import { scope } from './document-scope'
/**
* The two fields the document declares before the query-reply bridge that follows it.
*
* They are one module because the emitted document puts them on one line, ahead of an injected
* group; nothing else joins them.
*/
export function startTerminalHandle() {
scope.PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
scope.term = null
}
+10 -2
View File
@@ -7,7 +7,7 @@ import {
} from './document-constants'
import { notify } from './host-notify'
import { fontPxForScale } from './text-scaling'
import { scope } from './document-scope'
import { scope, scheduleDocumentFrame } from './document-scope'
import { applyFitScale } from './fit-scale'
import {
isAltScreenActive,
@@ -128,7 +128,7 @@ export function init(
attachTermObservers()
attachTerminalQueryReplyBridge(scope.term, gen)
requestAnimationFrame(function () {
scheduleDocumentFrame(function () {
if (gen !== scope.terminalGeneration) {
return
}
@@ -189,3 +189,11 @@ export function resize(cols: number, rows: number) {
}
// reflow(): see reflow.ts.
/**
* Ruling 21: init's own frames carry the generation they were scheduled under, so bumping it is
* what abandons them — the same guard a re-init already uses against its predecessor.
*/
export function stopTerminalInit() {
scope.terminalGeneration++
}
+8 -22
View File
@@ -1,12 +1,9 @@
import { terminalTextScalePresets } from './document-constants'
import { scope } from './document-scope'
import { scope, scheduleDocumentFrame } from './document-scope'
import { applyFitScale, getCellHeight } from './fit-scale'
import { getCellWidth } from './viewport-transform'
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
export let scrollIndicator: HTMLElement | null = null
export let scrollThumb: HTMLElement | null = null
// Why: init() flips ready false on every re-init (live width reflow included)
// while the old surface stays visible; a document-scoped latch drives the
// fatal/non-fatal decision so a transient reflow cannot blank a live terminal.
@@ -64,8 +61,11 @@ export function applyTextScale(scale: number) {
return
}
scope.term.options.fontSize = px
requestAnimationFrame(function () {
if (!scope.term) {
// Ruling 21: the generation this frame was scheduled under. `scope.term` alone is not enough —
// a mount that came and went leaves a live terminal here, and this would resize that one.
const gen = scope.terminalGeneration
scheduleDocumentFrame(function () {
if (!scope.term || gen !== scope.terminalGeneration) {
return
}
const cellW = getCellWidth()
@@ -84,22 +84,8 @@ export function applyTextScale(scale: number) {
}
export function startTextScaling() {
scrollIndicator = document.getElementById('scroll-indicator')
scrollThumb = document.getElementById('scroll-thumb')
scope.scrollIndicatorHideTimer = null
scope.writeQueue = []
scope.writeQueueHead = 0
scope.writesDraining = false
scope.afterDrainCallbacks = []
scope.termObserverDisposables = []
scope.ready = false
scope.everReady = false
scope.currentScale = 1
scope.userScale = 1
scope.MIN_FIT_COLS = 20
scope.currentTextScale = 1
scope.MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0]
scope.MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1]
scope.scrollIndicator = document.getElementById('scroll-indicator')
scope.scrollThumb = document.getElementById('scroll-thumb')
scope.terminalFontFamily =
(isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS
}
@@ -1,8 +1,6 @@
import { terminalDefaultTheme } from './document-constants'
import { repositionOverlay } from './selection-overlay'
import { shouldRouteScrollToTerminalInput } from './mouse-input-encoding'
import { scope } from './document-scope'
import { scrollIndicator, scrollThumb } from './text-scaling'
// Why: after init() the initial scrollback applyFitScale may have run
// against an empty buffer (or one without the widest line yet). Re-fit
@@ -71,8 +69,8 @@ export function updateTransform() {
export function updateScrollIndicator(reveal: boolean) {
if (
!scrollIndicator ||
!scrollThumb ||
!scope.scrollIndicator ||
!scope.scrollThumb ||
!scope.term ||
!scope.term.buffer ||
!scope.term.buffer.active
@@ -82,7 +80,7 @@ export function updateScrollIndicator(reveal: boolean) {
const buffer = scope.term.buffer.active
const maxViewportY = buffer.baseY || 0
if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) {
scrollIndicator.classList.remove('visible')
scope.scrollIndicator.classList.remove('visible')
return
}
const trackHeight = Math.max(0, window.innerHeight - 8)
@@ -93,43 +91,25 @@ export function updateScrollIndicator(reveal: boolean) {
const thumbHeight = Math.max(24, (trackHeight * (scope.term.rows || 0)) / totalRows)
const maxTop = Math.max(0, trackHeight - thumbHeight)
const top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0
scrollThumb.style.height = thumbHeight + 'px'
scrollThumb.style.transform = 'translateY(' + top + 'px)'
scope.scrollThumb.style.height = thumbHeight + 'px'
scope.scrollThumb.style.transform = 'translateY(' + top + 'px)'
if (!reveal) {
return
}
scrollIndicator.classList.add('visible')
scope.scrollIndicator.classList.add('visible')
if (scope.scrollIndicatorHideTimer) {
clearTimeout(scope.scrollIndicatorHideTimer)
}
scope.scrollIndicatorHideTimer = setTimeout(function () {
scrollIndicator!.classList.remove('visible')
scope.scrollIndicator!.classList.remove('visible')
scope.scrollIndicatorHideTimer = null
}, 550)
}
export function startViewportTransform() {
scope.panX = 0
scope.panY = 0
scope.smoothScrollOffsetY = 0
scope.pendingNormalScrollDeltaY = 0
scope.normalScrollFrameId = null
scope.initRows = 24
scope.terminalGeneration = 0
scope.defaultTheme = terminalDefaultTheme
scope.terminalThemeInput = null
scope.terminalTheme = scope.defaultTheme
scope.terminalMinimumContrastRatio = 3
scope.webglAddon = null
scope.webglRecoveryTimer = null
scope.activeAltScreenSnapshot = false
scope.trackedMouseTrackingMode = 'none'
scope.sgrMouseMode = false
scope.sgrMousePixelsMode = false
scope.initialOscLinks = []
scope.initialOscLinkRowOffset = 0
scope.initialOscLinkEvictionReady = false
scope.mouseModeScanTail = ''
scope.handledMessageIds = []
scope.firstDataPending = false
/** Ruling 21: the hide timer is the one thing this module schedules. */
export function stopViewportTransform() {
if (scope.scrollIndicatorHideTimer) {
clearTimeout(scope.scrollIndicatorHideTimer)
scope.scrollIndicatorHideTimer = null
}
}
@@ -106,4 +106,5 @@ export function startWebglRecovery() {
export function stopWebglRecovery() {
document.removeEventListener('visibilitychange', onDocumentVisibilityChange)
cancelWebglContextRecovery()
}
@@ -71,7 +71,3 @@ export function attachSurfaceWheelHandler(targetSurface: HTMLElement) {
{ capture: true, passive: false }
)
}
export function startWheelScroll() {
scope.wheelAccumDeltaY = 0
}
+220 -239
View File
@@ -183,7 +183,7 @@ window.onerror = function(msg) {
const statusDot = String.fromCharCode(9210);
const textPresentationSelector = String.fromCharCode(65038);
const emojiPresentationSelector = String.fromCharCode(65039);
function createTerminalDocumentScope() {
function createTerminalDocumentState() {
return {
term: null,
panX: 0,
@@ -220,7 +220,7 @@ window.onerror = function(msg) {
handledMessageIds: [],
currentTextScale: 1,
terminalFontFamily: "",
firstDataPending: true,
firstDataPending: false,
activeAltScreenSnapshot: false,
currentScale: 1,
userScale: 1,
@@ -267,6 +267,41 @@ window.onerror = function(msg) {
wheelAccumDeltaY: 0,
surface: null,
pendingTerm: null,
committedTerm: null,
committedSurface: null,
pendingSurface: null,
scrollIndicator: null,
scrollThumb: null,
terminalDataRepliesEnabled: false,
linesEverWritten: 0,
nonFatalErrorNotifies: 0,
uninstallErrorReporter: null,
fitRetryToken: 0,
mouseGesture: null,
touchDispatch: {
mode: "idle",
touchId: null,
touchIds: null,
longPressFingerInsideOverlay: false
},
scheduledFrames: [],
touchGesture: {
lastX: 0,
lastY: 0,
lastTime: 0,
velY: 0,
accumDelta: 0,
momentumId: null,
isPinching: false,
pinchDist: 0,
pinchScale: 0,
pinchSurfX: 0,
pinchSurfY: 0
}
};
}
function createTerminalDocumentHostSeams() {
return {
postToHost: postToReactNativeWebView,
createTerminal: createEngineTerminal,
createUnicode11Addon: createEngineUnicode11Addon,
@@ -274,40 +309,52 @@ window.onerror = function(msg) {
installErrorReporter: installWindowErrorReporter
};
}
function createTerminalDocumentScope() {
return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams() };
}
function resetTerminalDocumentScope() {
const generations = {
terminalGeneration: scope.terminalGeneration + 1,
fitRetryToken: scope.fitRetryToken + 1
};
Object.assign(scope, createTerminalDocumentState(), generations);
}
const scope = createTerminalDocumentScope();
function scheduleDocumentFrame(callback) {
const id = requestAnimationFrame(function(time) {
const at = scope.scheduledFrames.indexOf(id);
if (at !== -1) {
scope.scheduledFrames.splice(at, 1);
}
callback(time);
});
scope.scheduledFrames.push(id);
return id;
}
function cancelDocumentFrames() {
for (const id of scope.scheduledFrames) {
cancelAnimationFrame(id);
}
scope.scheduledFrames = [];
}
function startRuntimeConstants() {
scope.surface = document.getElementById("terminal-surface");
scope.ESC = String.fromCharCode(27);
scope.C1_CSI = String.fromCharCode(155);
scope.CLAUDE_STATUS_DOT = String.fromCharCode(9210);
scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038);
scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039);
scope.CLAUDE_STATUS_DOT_PATTERN = new RegExp(
scope.CLAUDE_STATUS_DOT + "[" + scope.TEXT_PRESENTATION_SELECTOR + scope.EMOJI_PRESENTATION_SELECTOR + "]*",
"g"
);
scope.statusDotPendingSelector = false;
}
function startTerminalHandle() {
scope.PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
scope.term = null;
}
let terminalDataRepliesEnabled = false;
function resetTerminalDataReplyAuthority() {
terminalDataRepliesEnabled = false;
scope.terminalDataRepliesEnabled = false;
}
function resumeTerminalDataReplyAuthority() {
terminalDataRepliesEnabled = true;
scope.terminalDataRepliesEnabled = true;
}
function forwardTerminalDataReply(data) {
if (terminalDataRepliesEnabled) {
if (scope.terminalDataRepliesEnabled) {
notify({ type: "terminal-data", bytes: data });
}
}
function enqueueTerminalDataReplyBoundary(gen) {
enqueueWriteBoundary(function() {
if (gen === scope.terminalGeneration) {
terminalDataRepliesEnabled = true;
scope.terminalDataRepliesEnabled = true;
}
});
}
@@ -333,13 +380,10 @@ window.onerror = function(msg) {
}
enqueueTerminalDataReplyBoundary(gen);
}
let committedTerm = null;
let committedSurface = null;
let pendingSurface = null;
function beginTerminalSurfaceSwap() {
if (pendingSurface) {
if (scope.pendingSurface) {
try {
pendingSurface.remove();
scope.pendingSurface.remove();
} catch {
}
if (scope.pendingTerm) {
@@ -348,12 +392,12 @@ window.onerror = function(msg) {
} catch {
}
}
pendingSurface = null;
scope.pendingSurface = null;
scope.pendingTerm = null;
}
const swap = {
oldTerm: committedTerm,
oldSurface: committedSurface,
oldTerm: scope.committedTerm,
oldSurface: scope.committedSurface,
nextSurface: document.createElement("div")
};
disposeTermObservers();
@@ -364,7 +408,7 @@ window.onerror = function(msg) {
swap.nextSurface.style.top = "0";
document.getElementById("terminal-container").appendChild(swap.nextSurface);
scope.surface = swap.nextSurface;
pendingSurface = swap.nextSurface;
scope.pendingSurface = swap.nextSurface;
attachSurfaceEventHandlers(scope.surface);
swap.oldSurface.removeAttribute("id");
return swap;
@@ -378,17 +422,14 @@ window.onerror = function(msg) {
if (swap.oldTerm) {
swap.oldTerm.dispose();
}
committedTerm = nextTerm;
committedSurface = swap.nextSurface;
scope.committedTerm = nextTerm;
scope.committedSurface = swap.nextSurface;
scope.pendingTerm = null;
pendingSurface = null;
scope.pendingSurface = null;
}
function startSurfaceSwap() {
committedSurface = scope.surface;
scope.pendingTerm = null;
scope.committedSurface = scope.surface;
}
let scrollIndicator = null;
let scrollThumb = null;
const BASE_FONT_PX = 13;
const MIN_FONT_PX = 6;
const TEXT_SCALE_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2];
@@ -423,8 +464,9 @@ window.onerror = function(msg) {
return;
}
scope.term.options.fontSize = px;
requestAnimationFrame(function() {
if (!scope.term) {
const gen = scope.terminalGeneration;
scheduleDocumentFrame(function() {
if (!scope.term || gen !== scope.terminalGeneration) {
return;
}
const cellW = getCellWidth();
@@ -442,22 +484,8 @@ window.onerror = function(msg) {
});
}
function startTextScaling() {
scrollIndicator = document.getElementById("scroll-indicator");
scrollThumb = document.getElementById("scroll-thumb");
scope.scrollIndicatorHideTimer = null;
scope.writeQueue = [];
scope.writeQueueHead = 0;
scope.writesDraining = false;
scope.afterDrainCallbacks = [];
scope.termObserverDisposables = [];
scope.ready = false;
scope.everReady = false;
scope.currentScale = 1;
scope.userScale = 1;
scope.MIN_FIT_COLS = 20;
scope.currentTextScale = 1;
scope.MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0];
scope.MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1];
scope.scrollIndicator = document.getElementById("scroll-indicator");
scope.scrollThumb = document.getElementById("scroll-thumb");
scope.terminalFontFamily = (isIOSWebView() ? "ui-monospace, " : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS;
}
function flog(tag, payload) {
@@ -503,13 +531,13 @@ window.onerror = function(msg) {
}
}
function updateScrollIndicator(reveal) {
if (!scrollIndicator || !scrollThumb || !scope.term || !scope.term.buffer || !scope.term.buffer.active) {
if (!scope.scrollIndicator || !scope.scrollThumb || !scope.term || !scope.term.buffer || !scope.term.buffer.active) {
return;
}
const buffer = scope.term.buffer.active;
const maxViewportY = buffer.baseY || 0;
if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) {
scrollIndicator.classList.remove("visible");
scope.scrollIndicator.classList.remove("visible");
return;
}
const trackHeight = Math.max(0, window.innerHeight - 8);
@@ -520,44 +548,25 @@ window.onerror = function(msg) {
const thumbHeight = Math.max(24, trackHeight * (scope.term.rows || 0) / totalRows);
const maxTop = Math.max(0, trackHeight - thumbHeight);
const top = maxViewportY > 0 ? buffer.viewportY / maxViewportY * maxTop : 0;
scrollThumb.style.height = thumbHeight + "px";
scrollThumb.style.transform = "translateY(" + top + "px)";
scope.scrollThumb.style.height = thumbHeight + "px";
scope.scrollThumb.style.transform = "translateY(" + top + "px)";
if (!reveal) {
return;
}
scrollIndicator.classList.add("visible");
scope.scrollIndicator.classList.add("visible");
if (scope.scrollIndicatorHideTimer) {
clearTimeout(scope.scrollIndicatorHideTimer);
}
scope.scrollIndicatorHideTimer = setTimeout(function() {
scrollIndicator.classList.remove("visible");
scope.scrollIndicator.classList.remove("visible");
scope.scrollIndicatorHideTimer = null;
}, 550);
}
function startViewportTransform() {
scope.panX = 0;
scope.panY = 0;
scope.smoothScrollOffsetY = 0;
scope.pendingNormalScrollDeltaY = 0;
scope.normalScrollFrameId = null;
scope.initRows = 24;
scope.terminalGeneration = 0;
scope.defaultTheme = { "background": "#1a1b26", "foreground": "#c0caf5", "cursor": "#c0caf5", "cursorAccent": "#1a1b26", "selectionBackground": "#33467c", "selectionForeground": "#c0caf5", "black": "#15161e", "red": "#f7768e", "green": "#9ece6a", "yellow": "#e0af68", "blue": "#7aa2f7", "magenta": "#bb9af7", "cyan": "#7dcfff", "white": "#a9b1d6", "brightBlack": "#414868", "brightRed": "#f7768e", "brightGreen": "#9ece6a", "brightYellow": "#e0af68", "brightBlue": "#7aa2f7", "brightMagenta": "#bb9af7", "brightCyan": "#7dcfff", "brightWhite": "#c0caf5" };
scope.terminalThemeInput = null;
scope.terminalTheme = scope.defaultTheme;
scope.terminalMinimumContrastRatio = 3;
scope.webglAddon = null;
scope.webglRecoveryTimer = null;
scope.activeAltScreenSnapshot = false;
scope.trackedMouseTrackingMode = "none";
scope.sgrMouseMode = false;
scope.sgrMousePixelsMode = false;
scope.initialOscLinks = [];
scope.initialOscLinkRowOffset = 0;
scope.initialOscLinkEvictionReady = false;
scope.mouseModeScanTail = "";
scope.handledMessageIds = [];
scope.firstDataPending = false;
function stopViewportTransform() {
if (scope.scrollIndicatorHideTimer) {
clearTimeout(scope.scrollIndicatorHideTimer);
scope.scrollIndicatorHideTimer = null;
}
}
const DARK_BG_MIN_CONTRAST = 3;
const LIGHT_BG_MIN_CONTRAST = 4.5;
@@ -716,16 +725,15 @@ window.onerror = function(msg) {
function adjustRowsForViewport() {
}
const FIT_RETRY_MAX_FRAMES = 60;
let fitRetryToken = 0;
function applyFitScale(reason) {
if (!scope.term || !scope.term.element) {
return;
}
const token = ++fitRetryToken;
const token = ++scope.fitRetryToken;
let attempts = 0;
let lastScrollWidth = -1;
function attempt() {
if (token !== fitRetryToken) {
if (token !== scope.fitRetryToken) {
return;
}
if (!scope.term || !scope.term.element) {
@@ -754,9 +762,9 @@ window.onerror = function(msg) {
commitFitScale(reason, attempts, "timeout");
return;
}
requestAnimationFrame(attempt);
scheduleDocumentFrame(attempt);
}
requestAnimationFrame(attempt);
scheduleDocumentFrame(attempt);
}
function commitFitScale(reason, attempts, gate) {
if (!scope.term || !scope.term.element) {
@@ -794,6 +802,9 @@ window.onerror = function(msg) {
}
repositionOverlay();
}
function stopFitScale() {
scope.fitRetryToken++;
}
function isAltScreenActive(data) {
if (typeof data !== "string") {
return false;
@@ -1074,6 +1085,7 @@ window.onerror = function(msg) {
}
function stopWebglRecovery() {
document.removeEventListener("visibilitychange", onDocumentVisibilityChange);
cancelWebglContextRecovery();
}
function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) {
if (typeof nextFontScale === "number" && nextFontScale > 0) {
@@ -1156,7 +1168,7 @@ window.onerror = function(msg) {
cancelSelect();
attachTermObservers();
attachTerminalQueryReplyBridge(scope.term, gen);
requestAnimationFrame(function() {
scheduleDocumentFrame(function() {
if (gen !== scope.terminalGeneration) {
return;
}
@@ -1208,6 +1220,9 @@ window.onerror = function(msg) {
applyFitScale("resize-msg");
notify({ type: "ready", cols, rows });
}
function stopTerminalInit() {
scope.terminalGeneration++;
}
function reflow(cols, rows) {
if (!scope.term || isAlternateBufferActive()) {
return;
@@ -1255,12 +1270,11 @@ window.onerror = function(msg) {
const match = String(navigator.userAgent || "").match(/(?:Chrome|Chromium)\/([0-9.]+)/);
return match ? "Chrome " + match[1] : "Chrome version unknown";
}
let nonFatalErrorNotifies = 0;
function reportEngineError(context, err, fatal) {
const isFatal = fatal === void 0 ? !scope.everReady : !!fatal;
if (!isFatal) {
nonFatalErrorNotifies++;
if (nonFatalErrorNotifies > 5) {
scope.nonFatalErrorNotifies++;
if (scope.nonFatalErrorNotifies > 5) {
return;
}
}
@@ -1279,9 +1293,8 @@ window.onerror = function(msg) {
message: parts.join(" - ")
});
}
let uninstallErrorReporter = null;
function startHostNotify() {
uninstallErrorReporter = scope.installErrorReporter(function(msg, source, line, column, err) {
scope.uninstallErrorReporter = scope.installErrorReporter(function(msg, source, line, column, err) {
if (window.__engineErrors.length < 20) {
window.__engineErrors.push(String(msg));
}
@@ -1289,9 +1302,9 @@ window.onerror = function(msg) {
});
}
function stopHostNotify() {
if (uninstallErrorReporter) {
uninstallErrorReporter();
uninstallErrorReporter = null;
if (scope.uninstallErrorReporter) {
scope.uninstallErrorReporter();
scope.uninstallErrorReporter = null;
}
}
function measureFitDimensions(containerHeightPx, retriesLeft) {
@@ -1310,7 +1323,11 @@ window.onerror = function(msg) {
}
if (notReady || cellWidth <= 0 || cellHeight <= 0) {
if (retriesLeft > 0) {
requestAnimationFrame(function() {
const gen = scope.terminalGeneration;
scheduleDocumentFrame(function() {
if (gen !== scope.terminalGeneration) {
return;
}
measureFitDimensions(containerHeightPx, retriesLeft - 1);
});
return;
@@ -1430,15 +1447,14 @@ window.onerror = function(msg) {
}
}
}
let linesEverWritten = 0;
function resetEvictionCounter() {
linesEverWritten = 0;
scope.linesEverWritten = 0;
}
function isBufferFull() {
if (!scope.term) {
return false;
}
return linesEverWritten >= 5e3 + (scope.term.rows || 0);
return scope.linesEverWritten >= 5e3 + (scope.term.rows || 0);
}
function checkEviction() {
if (scope.selMode !== "select" || !scope.sel) {
@@ -1451,7 +1467,7 @@ window.onerror = function(msg) {
}
}
function logFeedAndEvict() {
linesEverWritten++;
scope.linesEverWritten++;
if (scope.initialOscLinkEvictionReady && isBufferFull()) {
scope.initialOscLinkRowOffset += 1;
}
@@ -1463,28 +1479,12 @@ window.onerror = function(msg) {
}
}
function startSelectionStateAndEviction() {
scope.WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u;
scope.LONG_PRESS_MS = 500;
scope.LONG_PRESS_SLOP = 10;
scope.TAP_SLOP = 24;
scope.TAP_MAX_MS = 700;
scope.EDGE_SCROLL_PX = 40;
scope.EDGE_SCROLL_INTERVAL = 60;
scope.selectionOverlay = document.getElementById("selection-overlay");
scope.handleStart = document.getElementById("sel-handle-start");
scope.handleEnd = document.getElementById("sel-handle-end");
scope.selMenu = document.getElementById("sel-menu");
scope.btnCopy = document.getElementById("sel-menu-copy");
scope.btnSelAll = document.getElementById("sel-menu-all");
scope.selMode = "navigate";
scope.sel = null;
scope.longPressTimer = null;
scope.longPressOrigin = null;
scope.tapCandidate = null;
scope.edgeScrollTimer = null;
scope.edgeScrollDir = 0;
scope.edgeScrollClientX = 0;
scope.edgeScrollClientY = 0;
}
function emitModesIfChanged() {
if (!scope.term) {
@@ -1515,15 +1515,6 @@ window.onerror = function(msg) {
});
}
}
function startModeMirroring() {
scope.lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: "none",
sgrMouseMode: false,
sgrMousePixelsMode: false
};
}
function lineHasVisibleContent(line, cell) {
if (line.translateToString(true).trim().length > 0) {
return true;
@@ -1946,7 +1937,7 @@ window.onerror = function(msg) {
if (scope.normalScrollFrameId !== null) {
return true;
}
scope.normalScrollFrameId = requestAnimationFrame(function() {
scope.normalScrollFrameId = scheduleDocumentFrame(function() {
scope.normalScrollFrameId = null;
const delta = scope.pendingNormalScrollDeltaY;
scope.pendingNormalScrollDeltaY = 0;
@@ -1968,6 +1959,9 @@ window.onerror = function(msg) {
scope.smoothScrollOffsetY = 0;
updateScrollIndicator(false);
}
function stopNormalBufferSmoothScroll() {
resetSmoothScrollOffset();
}
function cellToViewportPx(col, absRow) {
if (!scope.term) {
return { x: 0, y: 0 };
@@ -2616,12 +2610,9 @@ window.onerror = function(msg) {
stopEdgeScroll();
}
}
const dispatch = {
mode: "idle",
touchId: null,
touchIds: null,
longPressFingerInsideOverlay: false
};
function stopSelectionOverlay() {
stopEdgeScroll();
}
function touchById(touches, id) {
for (let i = 0; i < touches.length; i++) {
if (touches[i].identifier === id) {
@@ -2666,7 +2657,7 @@ window.onerror = function(msg) {
return dx + dy > scope.LONG_PRESS_SLOP;
}
function dispatcherShouldBlockSurface() {
return dispatch.mode === "select-drag";
return scope.touchDispatch.mode === "select-drag";
}
const CAPTURE_ACTIVE = { capture: true, passive: false };
const CAPTURE_PASSIVE = { capture: true, passive: true };
@@ -2682,16 +2673,16 @@ window.onerror = function(msg) {
notify({ type: "mobile-clip-cancel-by-pinch" });
cancelSelect();
}
dispatch.mode = "pinch";
dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier];
scope.touchDispatch.mode = "pinch";
scope.touchDispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier];
clearLongPress();
return;
}
if (onHandle && scope.selMode === "select") {
const handleName = target === scope.handleStart ? "start" : "end";
scope.sel.activeHandle = handleName;
dispatch.mode = "select-drag";
dispatch.touchId = t.identifier;
scope.touchDispatch.mode = "select-drag";
scope.touchDispatch.touchId = t.identifier;
e.preventDefault();
return;
}
@@ -2700,20 +2691,20 @@ window.onerror = function(msg) {
}
if (inSurface && scope.selMode === "select") {
cancelSelect();
dispatch.mode = "surface";
dispatch.touchId = t.identifier;
scope.touchDispatch.mode = "surface";
scope.touchDispatch.touchId = t.identifier;
return;
}
if (inSurface) {
dispatch.mode = "surface";
dispatch.touchId = t.identifier;
scope.touchDispatch.mode = "surface";
scope.touchDispatch.touchId = t.identifier;
scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier };
armLongPress(t);
}
}
function onDocumentTouchMove(e) {
if (dispatch.mode === "select-drag") {
const t = touchById(e.touches, dispatch.touchId);
if (scope.touchDispatch.mode === "select-drag") {
const t = touchById(e.touches, scope.touchDispatch.touchId);
if (!t || !scope.sel || !scope.sel.activeHandle) {
return;
}
@@ -2721,7 +2712,7 @@ window.onerror = function(msg) {
handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY);
return;
}
if (dispatch.mode === "surface" || dispatch.mode === "pinch") {
if (scope.touchDispatch.mode === "surface" || scope.touchDispatch.mode === "pinch") {
if (scope.longPressTimer && e.touches.length === 1) {
if (touchSlopExceeded(e.touches[0])) {
clearLongPress();
@@ -2742,34 +2733,34 @@ window.onerror = function(msg) {
}
}
function onDocumentTouchEnd(e) {
if (dispatch.mode === "select-drag") {
if (scope.touchDispatch.mode === "select-drag") {
if (scope.sel) {
scope.sel.activeHandle = null;
}
stopEdgeScroll();
dispatch.mode = "idle";
dispatch.touchId = null;
scope.touchDispatch.mode = "idle";
scope.touchDispatch.touchId = null;
return;
}
if (dispatch.mode === "pinch") {
if (scope.touchDispatch.mode === "pinch") {
if (e.touches.length < 2) {
dispatch.mode = e.touches.length === 1 ? "surface" : "idle";
dispatch.touchIds = null;
scope.touchDispatch.mode = e.touches.length === 1 ? "surface" : "idle";
scope.touchDispatch.touchIds = null;
if (e.touches.length === 1) {
dispatch.touchId = e.touches[0].identifier;
scope.touchDispatch.touchId = e.touches[0].identifier;
}
}
return;
}
if (dispatch.mode === "surface") {
if (scope.touchDispatch.mode === "surface") {
if (e.touches.length === 0 && scope.tapCandidate && scope.selMode !== "select" && Date.now() - scope.tapCandidate.t <= scope.TAP_MAX_MS) {
notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true);
}
clearLongPress();
scope.tapCandidate = null;
if (e.touches.length === 0) {
dispatch.mode = "idle";
dispatch.touchId = null;
scope.touchDispatch.mode = "idle";
scope.touchDispatch.touchId = null;
}
}
}
@@ -2777,14 +2768,14 @@ window.onerror = function(msg) {
clearLongPress();
scope.tapCandidate = null;
stopEdgeScroll();
if (dispatch.mode === "select-drag") {
if (scope.touchDispatch.mode === "select-drag") {
if (scope.sel) {
scope.sel.activeHandle = null;
}
}
dispatch.mode = "idle";
dispatch.touchId = null;
dispatch.touchIds = null;
scope.touchDispatch.mode = "idle";
scope.touchDispatch.touchId = null;
scope.touchDispatch.touchIds = null;
}
function startTapDispatch() {
document.addEventListener("touchstart", onDocumentTouchStart, CAPTURE_ACTIVE);
@@ -2797,6 +2788,7 @@ window.onerror = function(msg) {
document.removeEventListener("touchmove", onDocumentTouchMove, CAPTURE_ACTIVE);
document.removeEventListener("touchend", onDocumentTouchEnd, CAPTURE_PASSIVE);
document.removeEventListener("touchcancel", onDocumentTouchCancel, CAPTURE_PASSIVE);
clearLongPress();
}
function wheelEventPixelDeltaY(e) {
const delta = e.deltaY;
@@ -2850,10 +2842,6 @@ window.onerror = function(msg) {
{ capture: true, passive: false }
);
}
function startWheelScroll() {
scope.wheelAccumDeltaY = 0;
}
let mouseGesture = null;
function buildMouseButtonReport(kind, clientX, clientY) {
const mouseTrackingMode = getMouseTrackingMode();
if (mouseTrackingMode === "none") {
@@ -2898,8 +2886,8 @@ window.onerror = function(msg) {
return cell ? cell.col + "," + cell.row : null;
}
function abandonMouseGesture() {
const gesture = mouseGesture;
mouseGesture = null;
const gesture = scope.mouseGesture;
scope.mouseGesture = null;
if (!gesture) {
return;
}
@@ -2949,7 +2937,7 @@ window.onerror = function(msg) {
if (dispatcherShouldBlockSurface() || !scope.term) {
return;
}
if (mouseGesture) {
if (scope.mouseGesture) {
abandonMouseGesture();
}
try {
@@ -2958,7 +2946,7 @@ window.onerror = function(msg) {
}
} catch {
}
mouseGesture = {
scope.mouseGesture = {
startX: e.clientX,
startY: e.clientY,
lastX: e.clientX,
@@ -2970,7 +2958,7 @@ window.onerror = function(msg) {
};
if (scope.selMode === "select") {
cancelSelect();
mouseGesture.dismissedSelection = true;
scope.mouseGesture.dismissedSelection = true;
}
},
true
@@ -2978,7 +2966,7 @@ window.onerror = function(msg) {
targetSurface.addEventListener(
"pointermove",
function(e) {
const gesture = mouseGesture;
const gesture = scope.mouseGesture;
if (e.pointerType !== "mouse" || !gesture || gesture.mode === "cancelled") {
return;
}
@@ -3017,11 +3005,11 @@ window.onerror = function(msg) {
targetSurface.addEventListener(
"pointerup",
function(e) {
const gesture = mouseGesture;
const gesture = scope.mouseGesture;
if (e.pointerType !== "mouse" || !gesture || e.button !== 0) {
return;
}
mouseGesture = null;
scope.mouseGesture = null;
if (gesture.mode === "cancelled" || !scope.term) {
return;
}
@@ -3063,7 +3051,7 @@ window.onerror = function(msg) {
targetSurface.addEventListener(
"touchstart",
function() {
if (mouseGesture) {
if (scope.mouseGesture) {
abandonMouseGesture();
}
},
@@ -3103,19 +3091,6 @@ window.onerror = function(msg) {
}
});
}
const ts = {
lastX: 0,
lastY: 0,
lastTime: 0,
velY: 0,
accumDelta: 0,
momentumId: null,
isPinching: false,
pinchDist: 0,
pinchScale: 0,
pinchSurfX: 0,
pinchSurfY: 0
};
function updateTouchVelocity(deltaY, dt) {
if (dt <= 0) {
return;
@@ -3124,7 +3099,7 @@ window.onerror = function(msg) {
if (!Number.isFinite(instantVelocity)) {
return;
}
ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45;
scope.touchGesture.velY = scope.touchGesture.velY === 0 ? instantVelocity : scope.touchGesture.velY * 0.55 + instantVelocity * 0.45;
}
function getDistance(a, b) {
const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
@@ -3159,27 +3134,27 @@ window.onerror = function(msg) {
if (dispatcherShouldBlockSurface()) {
return;
}
if (ts.momentumId) {
cancelAnimationFrame(ts.momentumId);
ts.momentumId = null;
if (scope.touchGesture.momentumId) {
cancelAnimationFrame(scope.touchGesture.momentumId);
scope.touchGesture.momentumId = null;
}
if (e.touches.length === 2) {
ts.isPinching = true;
scope.touchGesture.isPinching = true;
scope.smoothScrollOffsetY = 0;
ts.pinchDist = getDistance(e.touches[0], e.touches[1]);
ts.pinchScale = scope.userScale;
scope.touchGesture.pinchDist = getDistance(e.touches[0], e.touches[1]);
scope.touchGesture.pinchScale = scope.userScale;
const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
const total = getTotalScale();
ts.pinchSurfX = (mx - scope.panX) / total;
ts.pinchSurfY = (my - scope.panY) / total;
scope.touchGesture.pinchSurfX = (mx - scope.panX) / total;
scope.touchGesture.pinchSurfY = (my - scope.panY) / total;
} else if (e.touches.length === 1) {
ts.isPinching = false;
ts.lastX = e.touches[0].clientX;
ts.lastY = e.touches[0].clientY;
ts.lastTime = Date.now();
ts.velY = 0;
ts.accumDelta = 0;
scope.touchGesture.isPinching = false;
scope.touchGesture.lastX = e.touches[0].clientX;
scope.touchGesture.lastY = e.touches[0].clientY;
scope.touchGesture.lastTime = Date.now();
scope.touchGesture.velY = 0;
scope.touchGesture.accumDelta = 0;
}
},
{ capture: true, passive: true }
@@ -3196,48 +3171,51 @@ window.onerror = function(msg) {
e.preventDefault();
e.stopPropagation();
if (e.touches.length === 2) {
ts.isPinching = true;
scope.touchGesture.isPinching = true;
const dist = getDistance(e.touches[0], e.touches[1]);
const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
const ratio = dist / ts.pinchDist;
const ratio = dist / scope.touchGesture.pinchDist;
const loScale = scope.MIN_TEXT_SCALE / scope.currentTextScale;
const hiScale = scope.MAX_TEXT_SCALE / scope.currentTextScale;
scope.userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio));
scope.userScale = Math.max(
loScale,
Math.min(hiScale, scope.touchGesture.pinchScale * ratio)
);
const total = getTotalScale();
scope.panX = mx - ts.pinchSurfX * total;
scope.panY = my - ts.pinchSurfY * total;
scope.panX = mx - scope.touchGesture.pinchSurfX * total;
scope.panY = my - scope.touchGesture.pinchSurfY * total;
clampPan();
updateTransform();
} else if (e.touches.length === 1 && !ts.isPinching) {
} else if (e.touches.length === 1 && !scope.touchGesture.isPinching) {
const x = e.touches[0].clientX, y = e.touches[0].clientY;
const now = Date.now(), dt = now - ts.lastTime;
const now = Date.now(), dt = now - scope.touchGesture.lastTime;
if (scope.term.element && scope.term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) {
scope.panX += x - ts.lastX;
scope.panX += x - scope.touchGesture.lastX;
clampPan();
updateTransform();
}
const deltaY = ts.lastY - y;
ts.lastTime = now;
const deltaY = scope.touchGesture.lastY - y;
scope.touchGesture.lastTime = now;
if (shouldRouteScrollToTerminalInput()) {
updateTouchVelocity(deltaY, dt);
resetSmoothScrollOffset();
const effectiveCellH = getCellHeight() * getTotalScale();
ts.accumDelta += deltaY;
const lines = Math.trunc(ts.accumDelta / effectiveCellH);
scope.touchGesture.accumDelta += deltaY;
const lines = Math.trunc(scope.touchGesture.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
scope.touchGesture.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, x, y);
}
} else {
if (enqueueNormalBufferScrollDelta(deltaY)) {
updateTouchVelocity(deltaY, dt);
} else {
ts.velY = 0;
scope.touchGesture.velY = 0;
}
}
ts.lastX = x;
ts.lastY = y;
scope.touchGesture.lastX = x;
scope.touchGesture.lastY = y;
}
},
{ capture: true, passive: false }
@@ -3251,8 +3229,8 @@ window.onerror = function(msg) {
if (!scope.term) {
return;
}
if (ts.isPinching && e.touches.length < 2) {
ts.isPinching = false;
if (scope.touchGesture.isPinching && e.touches.length < 2) {
scope.touchGesture.isPinching = false;
const target = snapToTextScalePreset(scope.currentTextScale * scope.userScale);
const changed = target !== scope.currentTextScale;
scope.userScale = 1;
@@ -3265,11 +3243,11 @@ window.onerror = function(msg) {
notify({ type: "haptic", kind: "selection" });
}
if (e.touches.length === 1) {
ts.lastX = e.touches[0].clientX;
ts.lastY = e.touches[0].clientY;
ts.lastTime = Date.now();
ts.velY = 0;
ts.accumDelta = 0;
scope.touchGesture.lastX = e.touches[0].clientX;
scope.touchGesture.lastY = e.touches[0].clientY;
scope.touchGesture.lastTime = Date.now();
scope.touchGesture.velY = 0;
scope.touchGesture.accumDelta = 0;
}
return;
}
@@ -3277,32 +3255,32 @@ window.onerror = function(msg) {
let momentumStep = function() {
vel *= FRICTION;
if (Math.abs(vel) < MIN_VEL) {
ts.momentumId = null;
scope.touchGesture.momentumId = null;
return;
}
const delta = vel * 16;
if (shouldRouteScrollToTerminalInput()) {
resetSmoothScrollOffset();
const effectiveCellH = getCellHeight() * getTotalScale();
ts.accumDelta += delta;
const lines = Math.trunc(ts.accumDelta / effectiveCellH);
scope.touchGesture.accumDelta += delta;
const lines = Math.trunc(scope.touchGesture.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, ts.lastX, ts.lastY);
scope.touchGesture.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, scope.touchGesture.lastX, scope.touchGesture.lastY);
}
} else {
if (!applyNormalBufferScrollDelta(delta)) {
ts.momentumId = null;
scope.touchGesture.momentumId = null;
return;
}
}
ts.momentumId = requestAnimationFrame(momentumStep);
scope.touchGesture.momentumId = scheduleDocumentFrame(momentumStep);
};
let vel = ts.velY;
let vel = scope.touchGesture.velY;
const FRICTION = 0.972;
const MIN_VEL = 0.012;
if (Math.abs(vel) > MIN_VEL) {
ts.momentumId = requestAnimationFrame(momentumStep);
scope.touchGesture.momentumId = scheduleDocumentFrame(momentumStep);
}
}
},
@@ -3312,6 +3290,12 @@ window.onerror = function(msg) {
function startSurfaceTouchGestures() {
attachSurfaceEventHandlers(scope.surface);
}
function stopSurfaceTouchGestures() {
if (scope.touchGesture.momentumId !== null) {
cancelAnimationFrame(scope.touchGesture.momentumId);
scope.touchGesture.momentumId = null;
}
}
function handleIncomingMessage(e) {
let msg;
try {
@@ -3345,17 +3329,14 @@ window.onerror = function(msg) {
reportEngineError("terminal engine missing", "xterm failed to load", true);
}
}
resetTerminalDocumentScope();
startRuntimeConstants();
startTerminalHandle();
startSurfaceSwap();
startTextScaling();
startViewportTransform();
startWebglRecovery();
startHostNotify();
startSelectionStateAndEviction();
startModeMirroring();
startTapDispatch();
startWheelScroll();
startSelectionMenuButtons();
startSurfaceTouchGestures();
startMessageBridge();
@@ -36,8 +36,11 @@ const STYLE_ELEMENT_ID = 'orca-terminal-document-style'
*
* `<style>` rather than a constructed sheet or inline attributes: the document's own rules and
* xterm's are written against ids and classes, and this is the same text the WebView's `<head>`
* carries. It stays in the head after unmount, because a second terminal on the same page would
* want it and re-parsing 6 KiB per mount is the only thing removing it would buy.
* carries. It stays in the head after unmount, because the *next* mount wants it — the page
* remounts the same terminal on navigation and on the error overlay's reload — and re-parsing
* 6 KiB each time is the only thing removing it would buy. Two terminals at once is not the
* case: `document-scope` is a module singleton, so there is one scope per page and `mount`
* refuses a second live document rather than letting the two share it.
*/
function ensureDocumentStyle() {
if (document.getElementById(STYLE_ELEMENT_ID)) {
@@ -67,9 +70,38 @@ function createPageWebglAddon(onFallback: (reason: string) => void) {
}
}
/**
* One live document per page, because there is one scope per page.
*
* `document-scope` is a module singleton and every module reads it, so a second mount while the
* first is up would not be a second terminal: both would drive the same fields, the same elements
* and the same start sequence. The component mounts and disposes in one effect and cannot reach
* this state, which is exactly why the refusal is named rather than left to surface as two
* terminals writing over each other.
*/
let liveDocumentHost: HTMLElement | null = null
export async function mountTerminalWebDocument(
host: HTMLElement,
receive: (message: Record<string, unknown>) => void
): Promise<TerminalWebDocument> {
if (liveDocumentHost) {
throw new Error('the terminal document is already mounted on this page')
}
liveDocumentHost = host
try {
return await buildTerminalWebDocument(host, receive)
} catch (error) {
// A mount that never completed holds nothing, and the overlay's Reload has to be able to try
// again — the dynamic import below is exactly the step that can fail.
liveDocumentHost = null
throw error
}
}
async function buildTerminalWebDocument(
host: HTMLElement,
receive: (message: Record<string, unknown>) => void
): Promise<TerminalWebDocument> {
ensureDocumentStyle()
host.innerHTML = TERMINAL_DOCUMENT_MARKUP
@@ -126,6 +158,7 @@ export async function mountTerminalWebDocument(
documentModules.handleMsg(command)
},
dispose: () => {
liveDocumentHost = null
window.removeEventListener('resize', onWindowResize)
documentModules.stopPageDocumentModules()
try {
@@ -0,0 +1,58 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from 'vitest'
import { mountTerminalWebDocument } from './terminal-web-document-mount'
/**
* One live document per page, and the undo that frees the page for the next one.
*
* `document-scope` is a module singleton: every module in `document/` reads that one object, so
* two mounts at once would not be two terminals but two drivers of the same fields and the same
* elements. The component cannot reach that state — it mounts and disposes in one effect — which
* is why the refusal is named here rather than left to surface as two terminals overwriting each
* other's surface. The second half is the one the page actually uses: dispose has to give the
* page back, or a remount and the error overlay's Reload would both be refused.
*/
describe('the page terminal document', () => {
beforeEach(() => {
document.body.innerHTML = ''
document.head.innerHTML = ''
})
it('refuses a second mount while one is live, and takes it back on dispose', async () => {
const host = document.createElement('div')
document.body.appendChild(host)
const second = document.createElement('div')
document.body.appendChild(second)
const mounted = await mountTerminalWebDocument(host, () => {})
await expect(mountTerminalWebDocument(second, () => {})).rejects.toThrow(
'the terminal document is already mounted on this page'
)
mounted.dispose()
const remounted = await mountTerminalWebDocument(second, () => {})
expect(second.querySelector('#terminal-container')).not.toBe(null)
remounted.dispose()
})
it('gives the page back when the mount itself fails, so Reload can try again', async () => {
// The overlay's Reload path. A mount that threw holds nothing, and a flag left set would
// refuse every later attempt — the document's chunk failing to load is exactly that case.
const detached = document.createElement('div')
Object.defineProperty(detached, 'innerHTML', {
set() {
throw new Error('orca-mount-failed')
},
get() {
return ''
}
})
await expect(mountTerminalWebDocument(detached, () => {})).rejects.toThrow('orca-mount-failed')
const host = document.createElement('div')
document.body.appendChild(host)
const mounted = await mountTerminalWebDocument(host, () => {})
expect(host.querySelector('#terminal-container')).not.toBe(null)
mounted.dispose()
})
})
@@ -171,7 +171,8 @@ describe('terminal WebView bundled engine', () => {
// old surface visible meanwhile), so the fatal default and the init-catch must
// key off `everReady` — otherwise a transient reflow error blanks a live
// terminal behind the fatal overlay. The latch stays set for the document.
expect(terminalHtmlSource).toContain('scope.everReady = false;')
// Ruling 21: the latch's initial value is in the scope factory, not in a parse-time write.
expect(terminalHtmlSource).toContain('everReady: false,')
expect(terminalHtmlSource).toContain('scope.everReady = true;')
expect(terminalHtmlSource).toContain('fatal === void 0 ? !scope.everReady : !!fatal')
expect(terminalHtmlSource).toContain('msg.type === "init" && !scope.everReady')
@@ -6,8 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html'
// uncovered region ships silently. A diff here means the emitted WebView source changed —
// update these values only when that change is deliberate, and only after checking the
// document still runs. Refactors that merely move slice boundaries must leave them alone.
const EXPECTED_SHA256 = '2d089b8d9ab9491eed79cf7fe353dde6444799a3d297269ab660aee63ba56c82'
const EXPECTED_LENGTH = 726168
const EXPECTED_SHA256 = '6a5a3216aab7b99daeb26bcdcfe6e325c415e5ef60c16405eea329ca141405fe'
const EXPECTED_LENGTH = 726081
describe('terminal WebView payload', () => {
it('composes the expected document', () => {
@@ -53,7 +53,8 @@ describe('terminal WebView reflow', () => {
})
it('does not locally resize hidden WebViews to a one-column grid', () => {
expect(htmlSource).toContain('scope.MIN_FIT_COLS = 20;')
// Ruling 21: the floor's value is in the scope factory, not in a parse-time write.
expect(htmlSource).toContain('MIN_FIT_COLS: 20,')
expect(htmlSource).toContain('if (cols < scope.MIN_FIT_COLS) {')
expect(htmlSource).toContain('flog("measure-skip-small-width"')
expect(htmlSource).toContain('notify({ type: "measure-result", cols: null, rows: null });')
@@ -81,7 +82,9 @@ describe('terminal WebView reflow', () => {
// between them; if its IIFE-time code threw, the listener below would
// never bind and reflow messages would silently no-op.
const reflowAt = XTERM_HTML.indexOf('function reflow(cols, rows) {')
const dispatchAt = XTERM_HTML.indexOf('const dispatch = {\n mode: "idle"')
// Ruling 21 moved the dispatcher's latch onto the scope, so the dispatcher is located by
// its own first handler rather than by the object it used to declare.
const dispatchAt = XTERM_HTML.indexOf('function onDocumentTouchStart(e) {')
const listenerAt = XTERM_HTML.indexOf('window.addEventListener("message"')
expect(reflowAt).toBeGreaterThanOrEqual(0)
expect(dispatchAt).toBeGreaterThan(reflowAt)
@@ -33,7 +33,7 @@ describe('TerminalWebView scroll routing', () => {
})
it('maps a downward pull at the bottom to older scrollback rows', () => {
expect(source).toContain('const deltaY = ts.lastY - y;')
expect(source).toContain('const deltaY = scope.touchGesture.lastY - y;')
expect(source).toContain('scope.smoothScrollOffsetY -= deltaY;')
expect(source).toContain(
'const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);'
@@ -71,7 +71,9 @@ describe('TerminalWebView scroll routing', () => {
expect(momentumBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan(
momentumBlock.indexOf('if (!applyNormalBufferScrollDelta(delta))')
)
expect(momentumBlock).toContain('routeScrollLines(lines, ts.lastX, ts.lastY);')
expect(momentumBlock).toContain(
'routeScrollLines(lines, scope.touchGesture.lastX, scope.touchGesture.lastY);'
)
})
it('does not rubber-band normal scroll at scrollback edges', () => {
@@ -90,14 +92,14 @@ describe('TerminalWebView scroll routing', () => {
'{ capture: true, passive: false }'
)
expect(touchMoveBlock).toContain('if (enqueueNormalBufferScrollDelta(deltaY))')
expect(touchMoveBlock).toContain('ts.velY = 0;')
expect(touchMoveBlock).toContain('scope.touchGesture.velY = 0;')
const momentumBlock = sliceBetween(
'let momentumStep = function()',
'if (Math.abs(vel) > MIN_VEL)'
)
expect(momentumBlock).toContain('if (!applyNormalBufferScrollDelta(delta))')
expect(momentumBlock).toContain('ts.momentumId = null;')
expect(momentumBlock).toContain('scope.touchGesture.momentumId = null;')
})
it('coalesces normal touch scroll row commits onto animation frames', () => {
@@ -107,7 +109,9 @@ describe('TerminalWebView scroll routing', () => {
)
expect(enqueueBlock).toContain('scope.pendingNormalScrollDeltaY += deltaY;')
expect(enqueueBlock).toContain('if (scope.normalScrollFrameId !== null) {')
expect(enqueueBlock).toContain('scope.normalScrollFrameId = requestAnimationFrame(function()')
// Ruling 21: every document frame goes through the scope's registry so dispose can take it
// back; the id is still held here, which is what the reset below cancels.
expect(enqueueBlock).toContain('scope.normalScrollFrameId = scheduleDocumentFrame(function()')
expect(enqueueBlock).toContain('applyNormalBufferScrollDelta(delta)')
const resetBlock = sliceBetween(
@@ -179,7 +183,7 @@ describe('TerminalWebView scroll routing', () => {
it('smooths velocity samples and uses lower friction for mobile momentum', () => {
expect(source).toContain('function updateTouchVelocity(deltaY, dt)')
expect(source).toContain('ts.velY * 0.55 + instantVelocity * 0.45')
expect(source).toContain('scope.touchGesture.velY * 0.55 + instantVelocity * 0.45')
expect(source).toContain('const FRICTION = 0.972;')
expect(source).toContain('const MIN_VEL = 0.012;')
})
@@ -26,30 +26,19 @@ const terminalHtmlSource = XTERM_HTML
const terminalWebglRecoverySource = await generatedDocumentModule('webgl-recovery')
function extractStatusDotNormalizer() {
// Ruling 20 put the dot constants inside `startRuntimeConstants`, so the block is taken whole
// and called rather than sliced statement by statement.
const declarationStart = terminalHtmlSource.indexOf(' function startRuntimeConstants() {')
const declarationEnd = terminalHtmlSource.indexOf(
'\n function startTerminalHandle',
declarationStart
)
// Ruling 21 put the dot constants in the scope factory, which the preamble already carries, so
// what is sliced here is the normalizer itself and nothing else.
const declarationAt = terminalHtmlSource.indexOf(' const statusDot = String.fromCharCode(9210);')
const functionStart = terminalHtmlSource.indexOf(' function isStatusDotPresentationSelector')
const functionEnd = terminalHtmlSource.indexOf('\n function enqueueWrite', functionStart)
expect(declarationStart).toBeGreaterThanOrEqual(0)
expect(declarationEnd).toBeGreaterThan(declarationStart)
expect(functionStart).toBeGreaterThan(declarationEnd)
expect(declarationAt).toBeGreaterThanOrEqual(0)
expect(functionStart).toBeGreaterThan(declarationAt)
expect(functionEnd).toBeGreaterThan(functionStart)
return `${documentScopePreamble()}${terminalHtmlSource.slice(declarationStart, declarationEnd)}\nstartRuntimeConstants();\n${terminalHtmlSource.slice(functionStart, functionEnd)}`
return `${documentScopePreamble()}${terminalHtmlSource.slice(functionStart, functionEnd)}`
}
function normalizeStatusDotChunks(chunks: string[]) {
// `startRuntimeConstants` opens with the surface read; the dot constants below it need no
// element, so an element-less document is enough to reach them.
const context: {
chunks: string[]
document: { getElementById: () => null }
output?: string
} = { chunks, document: { getElementById: () => null } }
const context: { chunks: string[]; output?: string } = { chunks }
new Script(`
${extractStatusDotNormalizer()}
output = chunks.map(function(chunk) { return normalizeStatusDotPresentation(chunk); }).join('');
@@ -114,12 +103,13 @@ describe('TerminalWebView text zoom', () => {
it('forces the Claude status dot to text presentation before xterm writes', () => {
expect(terminalHtmlSource).toContain('font-variant-emoji: text')
expect(terminalHtmlSource).toContain('scope.CLAUDE_STATUS_DOT = String.fromCharCode(9210)')
// Ruling 21: the dot's value is in the scope factory, not in a parse-time write.
expect(terminalHtmlSource).toContain('const statusDot = String.fromCharCode(9210);')
expect(terminalHtmlSource).toContain(
'scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)'
'const textPresentationSelector = String.fromCharCode(65038);'
)
expect(terminalHtmlSource).toContain(
'scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)'
'const emojiPresentationSelector = String.fromCharCode(65039);'
)
expect(terminalHtmlSource).toContain('function normalizeStatusDotPresentation(data)')
expect(terminalHtmlSource).toContain(