diff --git a/mobile/.gitignore b/mobile/.gitignore index 0428cbb65a9..7c30714f74c 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -1,5 +1,6 @@ node_modules/ src/terminal/terminal-webview-engine.generated.ts +src/terminal/terminal-webview-document-script.generated.ts src/components/pr-sidebar/mermaid-webview-engine.generated.ts .expo/ dist/ diff --git a/mobile/package.json b/mobile/package.json index c4a977cd459..d89f04ff36d 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -7,7 +7,7 @@ "start": "node scripts/start-expo.mjs", "android": "expo run:android", "ios": "expo run:ios", - "postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs", + "postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs && node scripts/build-terminal-document-script.mjs", "test": "vitest run", "typecheck": "tsc --noEmit", "typecheck:tests": "tsc --noEmit -p tsconfig.test.json", diff --git a/mobile/scripts/build-terminal-document-fixture.mjs b/mobile/scripts/build-terminal-document-fixture.mjs new file mode 100644 index 00000000000..885aca358d7 --- /dev/null +++ b/mobile/scripts/build-terminal-document-fixture.mjs @@ -0,0 +1,78 @@ +import { writeFile } from 'node:fs/promises' +import path from 'node:path' +import { importTypeScriptModule } from './import-typescript-module.mjs' + +/** + * Writes the committed copy of the terminal WebView document that + * `terminal-document-identity.test.ts` diffs against. + * + * The document is a build artifact: fourteen source slices joined in a pinned order, with the + * generated xterm engine spliced into two of them. `terminal-webview-payload-hash.test.ts` already + * says *whether* it moved; what it cannot say is *where*, and a refactor whose whole claim is that + * the document did not move needs the diff, not the digest. + * + * The two generated engine strings are stored as placeholders rather than inline. They are already + * pinned by the hash test, they are regenerated by postinstall from whatever xterm version the + * lockfile holds, and inlining them would put 612 KiB of vendored bytes in the fixture and turn + * every xterm bump into an unreadable diff of the file that is supposed to isolate hand-written + * changes. + * + * Regenerating this fixture is a review event: it is only correct when the emitted document was + * meant to change, and the diff is the evidence for that. Run `node scripts/build-terminal-document-fixture.mjs` + * from `mobile/`. + */ +const mobileRoot = path.resolve(import.meta.dirname, '..') +const entry = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-html.ts') +const enginePath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts') + +export const TERMINAL_DOCUMENT_FIXTURE_PATH = path.join( + mobileRoot, + 'src', + 'terminal', + 'terminal-document-golden.txt' +) + +/** Chosen so the document cannot contain one by accident; asserted below and in the test. */ +export const ENGINE_JS_PLACEHOLDER = '__ORCA_TERMINAL_ENGINE_JS__' +export const ENGINE_CSS_PLACEHOLDER = '__ORCA_TERMINAL_ENGINE_CSS__' + +/** + * The document with both generated sections replaced by their placeholders. + * + * Exported so the test builds the same text the script writes, rather than restating the + * substitution and agreeing with a fixture that was written wrong. + */ +export function terminalDocumentFixture(document, engineJs, engineCss) { + for (const placeholder of [ENGINE_JS_PLACEHOLDER, ENGINE_CSS_PLACEHOLDER]) { + if (document.includes(placeholder)) { + throw new Error(`the document already contains ${placeholder}`) + } + } + for (const [name, value] of [ + ['XTERM_ENGINE_JS', engineJs], + ['XTERM_ENGINE_CSS', engineCss] + ]) { + if (document.split(value).length !== 2) { + throw new Error(`${name} does not appear exactly once in the document`) + } + } + return document + .replace(engineJs, ENGINE_JS_PLACEHOLDER) + .replace(engineCss, ENGINE_CSS_PLACEHOLDER) +} + +async function main() { + const [{ XTERM_HTML }, { XTERM_ENGINE_JS, XTERM_ENGINE_CSS }] = await Promise.all([ + importTypeScriptModule(entry), + importTypeScriptModule(enginePath) + ]) + const fixture = terminalDocumentFixture(XTERM_HTML, XTERM_ENGINE_JS, XTERM_ENGINE_CSS) + await writeFile(TERMINAL_DOCUMENT_FIXTURE_PATH, fixture) + console.log( + `[build-terminal-document-fixture] ${Buffer.byteLength(fixture, 'utf8')} bytes (document ${Buffer.byteLength(XTERM_HTML, 'utf8')})` + ) +} + +if (import.meta.filename === process.argv[1]) { + await main() +} diff --git a/mobile/scripts/build-terminal-document-script.mjs b/mobile/scripts/build-terminal-document-script.mjs new file mode 100644 index 00000000000..9fe3c9b0587 --- /dev/null +++ b/mobile/scripts/build-terminal-document-script.mjs @@ -0,0 +1,189 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import * as esbuild from 'esbuild' +import { importTypeScriptModule } from './import-typescript-module.mjs' +import { + TERMINAL_DOCUMENT_MODULE_ORDER, + TERMINAL_DOCUMENT_SCOPE_MODULE +} from './terminal-document-module-order.mjs' + +/** + * Turns one module of the in-WebView terminal document back into the script text the document + * carries. + * + * The document is a string the native WebView loads, so its parts cannot be imported by anything; + * the web page needs exactly those parts and must not re-implement them. So the parts are modules, + * and this is the other direction: the modules' declarations, with their imports removed and their + * exports unmarked, spliced into the one function scope the document has always been. + * + * Imports are dropped rather than resolved because inside the document every name is already in + * scope — that is what the single IIFE means. `document-externals.ts` declares the names that have + * not moved into modules yet, and it emits nothing at all. + * + * `esbuild` does the TypeScript, as it already does for the xterm engine beside this file. It is a + * transform and not a bundle: a bundler would order the output by its dependency graph, and the + * document's order is part of what the equivalence test holds fixed. + */ +const INDENT = ' ' + +const constantsPath = path.join( + import.meta.dirname, + '..', + 'src', + 'terminal', + 'document', + 'document-constants.ts' +) + +let substitutions = null + +/** + * `document-constants.ts` as the literal text each name stands for. + * + * Substitution happens after the import lines are dropped, when the names are free again, and it is + * textual rather than an esbuild `define` because a `define` whose value is an object or an array + * is injected as a helper binding instead of being inlined, which is not what the document carries. + * The names are exported for this purpose only and none of them appears inside a string. + */ +async function documentConstantSubstitutions() { + if (substitutions === null) { + const module = await importTypeScriptModule(constantsPath) + substitutions = Object.fromEntries( + Object.entries(module).map(([name, value]) => [name, JSON.stringify(value)]) + ) + } + return substitutions +} + +/** + * Replaces each constant's name with its literal. + * + * The replacement is a function, not the literal itself: as a string, `$&`, `` $` ``, `$'` and + * `$n` are replacement patterns, so a constant whose value contains one would be spliced with the + * match rather than written out. A function replacer has no such reading. + */ +export function substituteDocumentConstants(text, substitutions) { + let substituted = text + for (const [name, literal] of Object.entries(substitutions)) { + substituted = substituted.replaceAll(new RegExp(`\\b${name}\\b`, 'g'), () => literal) + } + return substituted +} + +/** + * Whether a line is a lint directive. + * + * These are removed before the transform, not after it: a directive inside an expression makes + * esbuild wrap that expression in parentheses to keep the comment where it was, and those + * parentheses are tokens the document does not have. They are tooling metadata about the source, + * not part of the program the WebView runs. + */ +function isLintDirectiveLine(line) { + return /^\s*\/\/\s*oxlint-disable/.test(line) +} + +/** Whether a line opens an import the document does not need. */ +function isImportLine(line) { + return /^import[\s{'"]/.test(line) +} + +/** Whether a statement that started on this line also ended on it. */ +function closesOnSameLine(line, closer) { + return line.includes(closer) +} + +/** + * The emitted text of one module: transpiled, unexported, un-imported and indented into the IIFE. + * + * Multi-line imports are handled by dropping through to the line that closes them, which esbuild's + * output makes safe: it prints one import per line. + */ +export async function emitTerminalDocumentModule(modulePath) { + const source = await readFile(modulePath, 'utf8') + const program = source + .split('\n') + .filter((line) => !isLintDirectiveLine(line)) + .join('\n') + const { code } = await esbuild.transform(program, { + loader: 'ts', + format: 'esm', + target: 'chrome74', + // The document is read by people as well as by a WebView, and the equivalence test compares + // tokens, so keeping the printer's own layout costs nothing and keeps the diff legible. + minify: false + }) + const kept = [] + // esbuild wraps a long import or export list across lines, so both are skipped to their closer + // rather than by their first line. An export list dropped by its keyword alone would leave a + // bare block statement in the document, and an import list would leave its names loose. + let skipUntil = null + for (const line of code.split('\n')) { + if (skipUntil !== null) { + if (closesOnSameLine(line, skipUntil)) { + skipUntil = null + } + continue + } + if (isImportLine(line)) { + skipUntil = closesOnSameLine(line, ' from ') || closesOnSameLine(line, ';') ? null : ' from ' + continue + } + if (line.startsWith('export {')) { + skipUntil = closesOnSameLine(line, '}') ? null : '}' + continue + } + kept.push(line.startsWith('export ') ? line.slice('export '.length) : line) + } + const text = substituteDocumentConstants(kept.join('\n'), await documentConstantSubstitutions()) + const substituted = await esbuild.transform(text, { + loader: 'js', + format: 'esm', + target: 'chrome74', + minify: false + }) + const body = substituted.code.trim() + return body + .split('\n') + .map((line) => (line.length === 0 ? line : `${INDENT}${line}`)) + .join('\n') +} + +const documentDirectory = path.join(import.meta.dirname, '..', 'src', 'terminal', 'document') + +export const TERMINAL_DOCUMENT_SCRIPT_PATH = path.join( + import.meta.dirname, + '..', + 'src', + 'terminal', + 'terminal-webview-document-script.generated.ts' +) + +/** + * The document's whole script: every module in the order the document had, inside the one function + * scope it has always been. + */ +export async function buildTerminalDocumentScript() { + const emitted = [] + // The scope object goes first: every module below reads it, and the document is one function + // scope, so it has to exist before any of them run. It is the only part of the emitted script + // the hand-written document did not have. + for (const name of [TERMINAL_DOCUMENT_SCOPE_MODULE, ...TERMINAL_DOCUMENT_MODULE_ORDER]) { + emitted.push(await emitTerminalDocumentModule(path.join(documentDirectory, `${name}.ts`))) + } + return `(function() {\n${emitted.join('\n')}\n})();` +} + +async function main() { + const script = await buildTerminalDocumentScript() + await writeFile( + TERMINAL_DOCUMENT_SCRIPT_PATH, + `// Generated by scripts/build-terminal-document-script.mjs. Do not edit.\n` + + `// The source is mobile/src/terminal/document/, in the order\n` + + `// scripts/terminal-document-module-order.mjs pins.\n` + + `export const TERMINAL_DOCUMENT_SCRIPT = ${JSON.stringify(script)}\n` + ) +} + +if (process.argv[1] === import.meta.filename) { + await main() +} diff --git a/mobile/scripts/build-terminal-document-script.test.ts b/mobile/scripts/build-terminal-document-script.test.ts new file mode 100644 index 00000000000..f84e30de688 --- /dev/null +++ b/mobile/scripts/build-terminal-document-script.test.ts @@ -0,0 +1,105 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + emitTerminalDocumentModule, + substituteDocumentConstants +} from './build-terminal-document-script.mjs' +import { terminalBackgroundFallback } from '../src/terminal/document/document-constants' + +/** + * What the generator drops, what it keeps, and how it puts a module back into the document. + * + * The per-group tests compare a real module against the string the document carries, which says + * the two agree; these say why, on inputs small enough to read. The import and export cases are + * the ones that bit: esbuild wraps a long list across lines, and skipping only the first line + * leaves the rest of the names loose in the document. + */ +let directory: string + +async function emit(source: string): Promise { + const path = join(directory, `module-${Math.random().toString(36).slice(2)}.ts`) + await writeFile(path, source) + return emitTerminalDocumentModule(path) +} + +beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-terminal-document-')) +}) + +afterAll(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('emitting one terminal document module', () => { + it('unmarks an export and indents it into the document scope', async () => { + expect(await emit('export function f() {\n return 1\n}\n')).toBe( + ' function f() {\n return 1;\n }' + ) + }) + + it('drops an import that fits on one line', async () => { + expect(await emit("import { a } from './x'\nexport const b = 1\n")).toBe(' const b = 1;') + }) + + it('drops an import esbuild wrapped across lines', async () => { + // The case that produced an unparseable document: the names after the first line stayed. + const source = + "import { alpha, beta, gamma, delta, epsilon, zeta, eta, theta } from './document-externals'\n" + + 'export const b = alpha\n' + expect(await emit(source)).toBe(' const b = alpha;') + }) + + it('drops the trailing export block esbuild prints, not just its keyword', async () => { + // Left behind it is a bare block statement, which parses and does nothing. + const emitted = await emit('function f() {}\nfunction g() {}\nexport { f, g }\n') + expect(emitted).not.toContain('{ f, g }') + expect(emitted).toBe(' function f() {\n }\n function g() {\n }') + }) + + it('erases types without touching the program', async () => { + expect( + await emit( + 'export type T = { a: number }\nexport function f(v: T): number {\n return v.a\n}\n' + ) + ).toBe(' function f(v) {\n return v.a;\n }') + }) + + it('substitutes a build-time constant the document carries as a literal', async () => { + const emitted = await emit( + "import { terminalBackgroundFallback } from '../src/terminal/document/document-constants'\n" + + 'export function paint() {\n' + + ' return terminalBackgroundFallback\n' + + '}\n' + ) + expect(emitted).toContain(JSON.stringify(terminalBackgroundFallback)) + expect(emitted).not.toContain('terminalBackgroundFallback') + }) + + it('drops a lint directive rather than let it parenthesise the expression it guards', async () => { + expect( + await emit( + 'export const R =\n' + ' // oxlint-disable-next-line no-useless-escape\n' + ' /a/g\n' + ) + ).toBe(' const R = /a/g;') + }) +}) + +describe('substituting a build-time constant', () => { + it('writes a value containing a replacement pattern out as it stands', () => { + // `$&` is the matched text to `String.replaceAll`'s string form, which would splice the + // constant's own name in here and ship a document that says something else. + const literal = JSON.stringify('a $& b') + expect(substituteDocumentConstants('const v = marker;', { marker: literal })).toBe( + 'const v = "a $& b";' + ) + }) + + // `$n` is not listed: the pattern has no capture group, so it is already literal under either + // form and a case for it could not tell them apart. + it.each([['$&'], ["$'"], ['$`']])('is not read as the replacement pattern %s', (pattern) => { + const literal = JSON.stringify(`x${pattern}y`) + expect(substituteDocumentConstants('marker', { marker: literal })).toBe(literal) + }) +}) diff --git a/mobile/scripts/import-typescript-module.mjs b/mobile/scripts/import-typescript-module.mjs new file mode 100644 index 00000000000..54f84ab800b --- /dev/null +++ b/mobile/scripts/import-typescript-module.mjs @@ -0,0 +1,21 @@ +import * as esbuild from 'esbuild' + +/** + * Imports a TypeScript module from a build script, by bundling it to a data URL. + * + * Node cannot import TypeScript and these scripts run outside the app's bundler, so the values the + * document is built from — the theme, the URL limits, the caret options — would otherwise have to be + * restated here. Restating them is what the generator exists to avoid. + */ +export async function importTypeScriptModule(entryPoint) { + const result = await esbuild.build({ + entryPoints: [entryPoint], + bundle: true, + format: 'esm', + platform: 'node', + write: false, + logLevel: 'silent' + }) + const code = result.outputFiles[0].text + return import(`data:text/javascript;base64,${Buffer.from(code, 'utf8').toString('base64')}`) +} diff --git a/mobile/scripts/terminal-document-module-order.mjs b/mobile/scripts/terminal-document-module-order.mjs new file mode 100644 index 00000000000..7fa99fe5962 --- /dev/null +++ b/mobile/scripts/terminal-document-module-order.mjs @@ -0,0 +1,48 @@ +/** + * The order the document's modules are spliced back into the script, which is the order the + * hand-written document had. It is data, not a dependency graph: the document is one function + * scope, so declarations must land where they landed before. + * + * Both the generator and the equivalence test read this, so neither can drift from the other. + */ +/** The scope object, emitted ahead of everything else because everything else reads it. */ +export const TERMINAL_DOCUMENT_SCOPE_MODULE = 'document-scope' + +export const TERMINAL_DOCUMENT_MODULE_ORDER = [ + 'runtime-constants', + 'terminal-handle', + 'query-reply', + 'surface-swap', + 'text-scaling', + 'viewport-transform', + 'terminal-theme', + 'fit-scale', + 'mouse-mode-decset-scan', + 'write-queue', + 'webgl-recovery', + 'terminal-init', + 'reflow', + 'host-notify', + 'host-message-router', + 'selection-state-and-eviction', + 'mode-mirroring', + 'keyboard-avoidance-metrics', + 'term-observers', + 'viewport-cell', + 'mouse-report-cell', + 'mouse-input-encoding', + 'normal-buffer-smooth-scroll', + 'cell-geometry', + 'path-tap', + 'url-tap', + 'osc-link-tap', + 'surface-tap', + 'selection-range', + 'selection-overlay', + 'tap-dispatch', + 'wheel-scroll', + 'mouse-click-drag', + 'selection-menu-buttons', + 'surface-touch-gestures', + 'message-bridge' +] diff --git a/mobile/src/terminal/document/cell-geometry.ts b/mobile/src/terminal/document/cell-geometry.ts new file mode 100644 index 00000000000..d5f6e950912 --- /dev/null +++ b/mobile/src/terminal/document/cell-geometry.ts @@ -0,0 +1,46 @@ +import { getCellHeight } from './fit-scale' +import { getCellWidth, getTotalScale } from './viewport-transform' +import { scope } from './document-scope' + +export function cellToViewportPx(col: number, absRow: number) { + if (!scope.term) { + return { x: 0, y: 0 } + } + const cellW = getCellWidth() + const cellH = getCellHeight() + const viewportRow = absRow - scope.term.buffer.active.viewportY + const sx = col * cellW + const sy = viewportRow * cellH + const total = getTotalScale() + return { x: sx * total + scope.panX, y: sy * total + scope.panY } +} + +export function getLineText(absRow: number) { + if (!scope.term) { + return '' + } + const line = scope.term.buffer.active.getLine(absRow) + if (!line) { + return '' + } + return line.translateToString(false) +} + +// Why: getLineText collapses wide chars (emoji, CJK) to one string char, so a +// tap's CELL column no longer equals the STRING index that url/path matchers use. +// Convert by measuring the string length up to the tapped cell (the count of +// string chars before it). Without this, taps on lines with a leading wide char +// (e.g. agent output prefixed with ⏺) resolve to the wrong column and miss. +export function cellColToStringIndex(absRow: number, col: number) { + if (!scope.term) { + return col + } + const line = scope.term.buffer.active.getLine(absRow) + if (!line) { + return col + } + return line.translateToString(false, 0, col).length +} + +// File-path-under-tap detection (matchFilePathAtColumn). See path-tap.ts; +// mirrors the unit-tested terminal-path-tap.ts. diff --git a/mobile/src/terminal/document/document-constants.ts b/mobile/src/terminal/document/document-constants.ts new file mode 100644 index 00000000000..c5c67b29696 --- /dev/null +++ b/mobile/src/terminal/document/document-constants.ts @@ -0,0 +1,46 @@ +import { colors } from '../../theme/mobile-theme' +import { TERMINAL_TEXT_SCALES } from '../../storage/preferences' +import { + DEFAULT_TERMINAL_THEME, + MOBILE_TERMINAL_CARET_OPTIONS +} from '../terminal-webview-html/theme' +import { + TERMINAL_FILE_URL_REGEX_SOURCE, + TERMINAL_HTTP_URL_MAX_LENGTH, + TERMINAL_HTTP_URL_REGEX_SOURCE +} from '../terminal-webview-url-tap' + +/** + * The build-time values the document's script text carries as literals. + * + * The document is a string, so it cannot import: today each of these is interpolated into a + * template literal at the site that needs it. A module cannot do that and still be the same + * program, so the generator substitutes these exports into the text it emits, and the web page + * imports the very same bindings. One source either way. + * + * Every export must be JSON-serialisable, because a substitution is a JSON literal. + */ + +/** The page background before a theme arrives, and the fallback when a theme omits one. */ +export const terminalBackgroundFallback = colors.terminalBg + +/** The http(s) candidate pattern, as a string because the document builds the RegExp per call. */ +export const terminalHttpUrlRegexSource = TERMINAL_HTTP_URL_REGEX_SOURCE + +/** The file:// candidate pattern, same shape. */ +export const terminalFileUrlRegexSource = TERMINAL_FILE_URL_REGEX_SOURCE + +/** The longest candidate a tap will open, matching desktop. */ +export const terminalHttpUrlMaxLength = TERMINAL_HTTP_URL_MAX_LENGTH + +/** The caret options, one export each because a substitution is keyed by name. */ +export const terminalCursorBlink = MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink +export const terminalCursorStyle = MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle +export const terminalShowCursorImmediately = MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately +export const terminalCursorInactiveStyle = MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle + +/** The text-scale presets, as the document's own array literal. */ +export const terminalTextScalePresets = [...TERMINAL_TEXT_SCALES] + +/** The built-in theme, as the document's own object literal. */ +export const terminalDefaultTheme = DEFAULT_TERMINAL_THEME diff --git a/mobile/src/terminal/document/document-module-order.test.ts b/mobile/src/terminal/document/document-module-order.test.ts new file mode 100644 index 00000000000..7a66e9ad29a --- /dev/null +++ b/mobile/src/terminal/document/document-module-order.test.ts @@ -0,0 +1,43 @@ +import { readdirSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + TERMINAL_DOCUMENT_MODULE_ORDER, + TERMINAL_DOCUMENT_SCOPE_MODULE +} from '../../../scripts/terminal-document-module-order.mjs' + +/** + * Every module in this directory is in the document, and everything in the order list is here. + * + * The generator emits exactly what the order list names, so a module added here and forgotten + * there is dead code that reads as live, and a name left in the list after its file goes makes the + * generator throw at build time rather than at review time. Both directions are asserted. + * + * `document-constants` is the one file that is deliberately not emitted: its exports are + * substituted into the modules that import them as literals, so the document carries its values + * without carrying the module. + */ +const NOT_EMITTED = 'document-constants' + +function documentModuleNames(): string[] { + return readdirSync(new URL('.', import.meta.url)) + .filter((entry) => entry.endsWith('.ts')) + .filter((entry) => !entry.endsWith('.test.ts') && !entry.endsWith('.test-support.ts')) + .map((entry) => entry.slice(0, -'.ts'.length)) + .sort() +} + +describe('the document module order', () => { + it('names every module the directory holds, and only those', () => { + const expected = [ + NOT_EMITTED, + TERMINAL_DOCUMENT_SCOPE_MODULE, + ...TERMINAL_DOCUMENT_MODULE_ORDER + ].sort() + expect(documentModuleNames()).toEqual(expected) + }) + + it('names each module once, so the generator cannot emit one twice', () => { + const listed = [TERMINAL_DOCUMENT_SCOPE_MODULE, ...TERMINAL_DOCUMENT_MODULE_ORDER] + expect(listed).toHaveLength(new Set(listed).size) + }) +}) diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts new file mode 100644 index 00000000000..046ce3e708e --- /dev/null +++ b/mobile/src/terminal/document/document-scope.ts @@ -0,0 +1,406 @@ +import { terminalDefaultTheme, terminalTextScalePresets } from './document-constants' +import type { TerminalDocumentThemeMessage } from './terminal-theme' +/** + * The state the in-WebView terminal document shares across its parts. + * + * The document is one function scope: 2,758 lines around 100 `var` declarations, 57 of which are + * written from more than one place. Moving its parts into modules is what lets the web page import + * them instead of re-implementing them, and a variable assigned from another module cannot be an + * import — assigning an imported binding is a syntax error. So the written ones become fields here, + * and the group that owns each is named beside it. + * + * Two things keep a variable out of this table. One the script never assigns again is an ordinary + * local. One both declared and assigned inside a single group is that module's own state, however + * often it is written — `terminalDataRepliesEnabled` is written from four places and all four are + * in `query-reply`, so it stays a `let` there. + * + * Declared, not merely written: while the rest of the document is still strings, a variable the + * main slice declares is shared even when every use of it is in one group, because the declaration + * has nowhere else to live yet. `webglRecoveryTimer` is that case. Those can migrate out of this + * table when the flip makes the main slice modules too, and doing it before then would emit a + * second declaration beside the one the slice still carries. + * + * The table grows one group at a time as C7.1 extracts them; a field arrives with its group. + */ + +/** One cell of a buffer line, as the document inspects it. */ +/** xterm's OSC 8 link service, reached through internals and always guarded. */ +export type TerminalOscLinkService = { getLinkData?: (id: number) => { uri?: string } | undefined } + +/** The xterm internals the OSC 8 lookup walks. */ +export type TerminalDocumentCore = { + _renderService?: { dimensions?: { css: { cell: { height: number; width: number } } } } + _oscLinkService?: TerminalOscLinkService + _inputHandler?: { _oscLinkService?: TerminalOscLinkService } +} + +/** An OSC 8 link the host captured from scrollback before xterm replayed it. */ +export type TerminalInitialOscLink = { + uri?: string + row: number + startCol: number + endCol: number + text?: string +} + +export type TerminalDocumentCell = { + isBgDefault: () => boolean + extended?: { urlId?: number } + isInverse: () => boolean + isUnderline?: () => boolean + isStrikethrough?: () => boolean + isOverline?: () => boolean +} + +/** One buffer line, as the document inspects it. */ +export type TerminalDocumentLine = { + readonly length: number + translateToString: (trimRight: boolean, startColumn?: number, endColumn?: number) => string + getCell?: (x: number, cell?: TerminalDocumentCell | null) => TerminalDocumentCell | null +} + +/** One side of xterm's buffer, as the document reads it. */ +export type TerminalDocumentBuffer = { + readonly length: number + readonly viewportY: number + readonly baseY: number + readonly cursorY: number + readonly type: string + getNullCell?: () => TerminalDocumentCell + getLine: (index: number) => TerminalDocumentLine | undefined +} + +/** As much of xterm's terminal as the document's own code touches. */ +/** A terminal colour theme: xterm reads it as a flat map of slot to CSS colour. */ +export type TerminalDocumentTheme = Record + +/** The xterm options the document writes; each field is owned by the group that sets it. */ +export type TerminalDocumentTerminalOptions = { + theme: TerminalDocumentTheme + minimumContrastRatio: number + fontSize: number +} + +export type TerminalDocumentTerminal = { + readonly cols: number + readonly rows: number + readonly buffer: { readonly active: TerminalDocumentBuffer } + options: TerminalDocumentTerminalOptions + write: (data: string, callback?: () => void) => void + open: (element: HTMLElement) => void + scrollToLine: (line: number) => void + clear: () => void + reset: () => void + selectAll: () => void + getSelection?: () => string + select: (col: number, row: number, length: number) => void + clearSelection: () => void + readonly unicode: { activeVersion: string } + attachCustomKeyEventHandler: (handler: () => boolean) => void + onData: (listener: (data: string) => void) => TerminalDocumentDisposable + readonly textarea?: { + readOnly: boolean + tabIndex: number + setAttribute: (name: string, value: string) => void + } + readonly element?: HTMLElement + readonly _core?: TerminalDocumentCore + readonly modes?: { + bracketedPasteMode?: boolean + mouseTrackingMode?: string + applicationCursorKeysMode?: boolean + } + onLineFeed?: (listener: () => void) => TerminalDocumentDisposable + onScroll?: (listener: () => void) => TerminalDocumentDisposable + onWriteParsed?: (listener: () => void) => TerminalDocumentDisposable + resize: (cols: number, rows: number) => void + refresh: (start: number, end: number) => void + dispose: () => void + loadAddon: (addon: TerminalDocumentWebglAddon) => void + scrollToBottom: () => void + scrollLines: (amount: number) => void +} + +export type TerminalDocumentScope = { + /** `terminal-handle`: 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 + panY: number + /** `terminal-init`: bumped on every re-init, so a late callback can tell it is stale. */ + terminalGeneration: number + /** `term-observers`: xterm listener handles to dispose when the terminal is replaced. */ + termObserverDisposables: TerminalDocumentDisposable[] + /** `terminal-init`: the row count the last init or reflow settled on. */ + initRows: number + /** `webgl-recovery`: the loaded WebGL addon, or null on the DOM renderer. */ + webglAddon: TerminalDocumentWebglAddon | null + /** `webgl-recovery`: the pending single retry after a context loss. */ + webglRecoveryTimer: ReturnType | null + /** `terminal-theme`: the theme the host last sent, replayed on visibility. */ + terminalThemeInput: TerminalDocumentThemeMessage + /** `wheel-scroll`: sub-line wheel travel carried between events; reset by a touch scroll. */ + wheelAccumDeltaY: number + /** `terminal-theme`: the built-in theme, and the fallback for every slot a host theme omits. */ + defaultTheme: TerminalDocumentTheme + /** `terminal-theme`: the host theme normalised against the built-in one. */ + terminalTheme: TerminalDocumentTheme + /** `terminal-theme`: the contrast floor in force, published or derived from the background. */ + terminalMinimumContrastRatio: number + /** `selection-overlay`: OSC 8 links captured from scrollback before xterm replayed it. */ + initialOscLinks: TerminalInitialOscLink[] + /** `selection-overlay`: how far the captured rows have scrolled out of the buffer. */ + initialOscLinkRowOffset: number + /** `runtime-constants`: the escape byte every report is prefixed with. */ + ESC: string + /** `mode-mirroring`: the last mode set published to the host, to suppress repeats. */ + lastEmittedModes: TerminalDocumentModes + /** `terminal-init`: whether the terminal has ever reached ready. */ + everReady: boolean + /** `runtime-constants`: the C1 form of the control sequence introducer. */ + C1_CSI: string + /** `mouse-mode-decset-scan`: the tail of the last chunk, in case a DECSET straddles two writes. */ + mouseModeScanTail: string + /** `mouse-mode-decset-scan`: the mouse tracking mode the TUI last asked for. */ + trackedMouseTrackingMode: string + /** `mouse-mode-decset-scan`: whether the TUI asked for SGR (1006) mouse reports. */ + sgrMouseMode: boolean + /** `mouse-mode-decset-scan`: whether the TUI asked for SGR pixel (1016) mouse reports. */ + sgrMousePixelsMode: boolean + /** `text-scaling`: the scroll indicator's hide timer. */ + scrollIndicatorHideTimer: ReturnType | null + /** `text-scaling`: the narrowest grid a text-scale change will fit to. */ + MIN_FIT_COLS: number + /** `text-scaling`: the smallest text-scale preset. */ + MIN_TEXT_SCALE: number + /** `text-scaling`: the largest text-scale preset. */ + MAX_TEXT_SCALE: number + /** `viewport-transform`: host message ids already handled, to drop repeats. */ + handledMessageIds: number[] + /** `text-scaling`: the text scale the user picked, as a preset index. */ + currentTextScale: number + /** `text-scaling`: the font stack xterm renders with. */ + terminalFontFamily: string + /** `terminal-init`: whether the first live chunk since init is still pending. */ + firstDataPending: boolean + /** `terminal-init`: whether the replayed snapshot was an alternate screen. */ + activeAltScreenSnapshot: boolean + /** `fit-scale`: the fit scale the document committed. */ + currentScale: number + /** `text-scaling`: the pinch zoom the user applied on top of the fit scale. */ + userScale: number + /** `runtime-constants`: Claude's record dot, which iOS WebKit would otherwise promote to emoji. */ + CLAUDE_STATUS_DOT: string + /** `runtime-constants`: the variation selector that forces the text glyph. */ + TEXT_PRESENTATION_SELECTOR: string + /** `runtime-constants`: the variation selector that forces the emoji glyph. */ + EMOJI_PRESENTATION_SELECTOR: string + /** `runtime-constants`: the dot with any trailing selectors, as one pattern. */ + CLAUDE_STATUS_DOT_PATTERN: RegExp + /** `write-queue`: whether a chunk ended mid-selector, so the next one starts inside it. */ + statusDotPendingSelector: boolean + /** `write-queue`: how far a split DECSET may be carried before the scan gives up. */ + PRIVATE_MODE_SCAN_TAIL_LIMIT: number + /** `write-queue`: chunks and boundaries waiting for xterm. */ + writeQueue: TerminalWriteQueueEntry[] + /** `write-queue`: how far the queue has been consumed, before compaction. */ + writeQueueHead: number + /** `write-queue`: whether a write is parsing right now. */ + writesDraining: boolean + /** `write-queue`: callbacks waiting for the queue to empty. */ + afterDrainCallbacks: (() => void)[] + /** `terminal-init`: whether the terminal has been initialised. */ + ready: boolean + /** `normal-buffer-smooth-scroll`: sub-row scroll travel not yet committed to xterm. */ + smoothScrollOffsetY: number + /** `normal-buffer-smooth-scroll`: scroll travel waiting for the next frame. */ + pendingNormalScrollDeltaY: number + /** `normal-buffer-smooth-scroll`: the frame request that will apply it, if one is pending. */ + normalScrollFrameId: number | null + /** `selection-state-and-eviction`: what counts as one word for select-all and word seeding. */ + WORD_RE: RegExp + /** `selection-state-and-eviction`: how close to an edge a handle drag starts scrolling. */ + EDGE_SCROLL_PX: number + /** `selection-state-and-eviction`: the edge-scroll tick, in milliseconds. */ + EDGE_SCROLL_INTERVAL: number + /** `selection-state-and-eviction`: the menu pill element. */ + selMenu: HTMLElement | null + /** `selection-state-and-eviction`: the pill's copy button. */ + btnCopy: HTMLElement | null + /** `selection-state-and-eviction`: the pill's select-all button. */ + btnSelAll: HTMLElement | null + /** `selection-state-and-eviction`: the running edge-scroll timer. */ + edgeScrollTimer: ReturnType | null + /** `selection-state-and-eviction`: which way the edge scroll is going. */ + edgeScrollDir: number + /** `selection-state-and-eviction`: where the dragging finger last was. */ + edgeScrollClientX: number + /** `selection-state-and-eviction`: where the dragging finger last was. */ + edgeScrollClientY: number + /** `selection-state-and-eviction`: whether captured OSC 8 rows may start shifting with eviction. */ + initialOscLinkEvictionReady: boolean + /** `selection-overlay`: the press duration that starts a selection, in milliseconds. */ + LONG_PRESS_MS: number + /** `selection-overlay`: the travel that cancels a pending long press, in pixels. */ + LONG_PRESS_SLOP: number + /** `selection-overlay`: the travel that disqualifies a tap, in pixels. */ + TAP_SLOP: number + /** `selection-overlay`: the longest press still counted as a tap, in milliseconds. */ + TAP_MAX_MS: number + /** `selection-overlay`: the overlay element that carries the handles and the menu pill. */ + selectionOverlay: HTMLElement | null + /** `selection-overlay`: the selection's leading handle element. */ + handleStart: HTMLElement | null + /** `selection-overlay`: the selection's trailing handle element. */ + handleEnd: HTMLElement | null + /** `selection-overlay`: `navigate` or `select`. */ + selMode: string + /** `selection-overlay`: the live selection, or null when there is none. */ + sel: TerminalDocumentSelection | null + /** `selection-overlay`: the pending long-press timer. */ + longPressTimer: ReturnType | null + /** `selection-overlay`: where the pending long press started. */ + longPressOrigin: TerminalDocumentTouchOrigin | null + /** `selection-overlay`: the touch that may still resolve as a tap. */ + tapCandidate: TerminalDocumentTapCandidate | null + /** `surface-swap`: the element xterm is currently mounted on. */ + surface: HTMLElement | null + /** `surface-swap`: the terminal of a hidden replacement surface that has not committed. */ + pendingTerm: TerminalDocumentTerminal | null +} + +/** An xterm listener handle, as the document disposes of one. */ +/** The live selection; only the dragged handle is read outside the overlay slice. */ +export type TerminalDocumentSelection = { + anchor: { row: number; col: number } + focus: { row: number; col: number } + activeHandle: string | null +} + +/** Where a press began, and which finger began it. */ +export type TerminalDocumentTouchOrigin = { x: number; y: number; identifier: number } + +/** A touch that may still resolve as a tap: its origin, its start time and its finger. */ +export type TerminalDocumentTapCandidate = TerminalDocumentTouchOrigin & { t: number } + +/** The terminal modes the host mirrors. */ +export type TerminalDocumentModes = { + bracketedPasteMode: boolean + altScreen: boolean + mouseTrackingMode: string + sgrMouseMode: boolean + sgrMousePixelsMode: boolean +} + +/** One entry of the write queue: a chunk, a boundary callback, or a consumed slot. */ +export type TerminalWriteQueueEntry = string | (() => void) | undefined + +export type TerminalDocumentDisposable = { dispose?: () => void } + +/** xterm's WebGL addon, as the document loads, repaints and disposes of it. */ +export type TerminalDocumentWebglAddon = { + onContextLoss?: (listener: () => void) => void + clearTextureAtlas?: () => void + dispose: () => void +} + +/** + * The initial values, which are the ones the document's own declarations carried. + * + * A factory rather than a shared literal so a second document — a test, or a page that remounts — + * starts from its own state instead of inheriting what the last one left. + */ +const textScalePresets = terminalTextScalePresets +const statusDot = String.fromCharCode(0x23fa) +const textPresentationSelector = String.fromCharCode(0xfe0e) +const emojiPresentationSelector = String.fromCharCode(0xfe0f) + +export function createTerminalDocumentScope(): TerminalDocumentScope { + return { + term: null, + panX: 0, + panY: 0, + terminalGeneration: 0, + termObserverDisposables: [], + initRows: 24, + webglAddon: null, + webglRecoveryTimer: null, + terminalThemeInput: null, + defaultTheme: terminalDefaultTheme, + terminalTheme: terminalDefaultTheme, + terminalMinimumContrastRatio: 3, + initialOscLinks: [], + initialOscLinkRowOffset: 0, + ESC: String.fromCharCode(27), + lastEmittedModes: { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false + }, + everReady: false, + C1_CSI: String.fromCharCode(155), + mouseModeScanTail: '', + trackedMouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false, + scrollIndicatorHideTimer: null, + MIN_FIT_COLS: 20, + MIN_TEXT_SCALE: textScalePresets[0], + MAX_TEXT_SCALE: textScalePresets[textScalePresets.length - 1], + handledMessageIds: [], + currentTextScale: 1, + terminalFontFamily: '', + firstDataPending: true, + activeAltScreenSnapshot: false, + currentScale: 1, + userScale: 1, + CLAUDE_STATUS_DOT: statusDot, + TEXT_PRESENTATION_SELECTOR: textPresentationSelector, + EMOJI_PRESENTATION_SELECTOR: emojiPresentationSelector, + CLAUDE_STATUS_DOT_PATTERN: new RegExp( + statusDot + '[' + textPresentationSelector + emojiPresentationSelector + ']*', + 'g' + ), + statusDotPendingSelector: false, + PRIVATE_MODE_SCAN_TAIL_LIMIT: 4096, + writeQueue: [], + writeQueueHead: 0, + writesDraining: false, + afterDrainCallbacks: [], + ready: false, + smoothScrollOffsetY: 0, + pendingNormalScrollDeltaY: 0, + normalScrollFrameId: null, + WORD_RE: /[\p{L}\p{N}_./:@~+=?&#%-]/u, + EDGE_SCROLL_PX: 40, + EDGE_SCROLL_INTERVAL: 60, + selMenu: null, + btnCopy: null, + btnSelAll: null, + edgeScrollTimer: null, + edgeScrollDir: 0, + edgeScrollClientX: 0, + edgeScrollClientY: 0, + initialOscLinkEvictionReady: false, + LONG_PRESS_MS: 500, + LONG_PRESS_SLOP: 10, + TAP_SLOP: 24, + TAP_MAX_MS: 700, + selectionOverlay: null, + handleStart: null, + handleEnd: null, + selMode: 'navigate', + sel: null, + longPressTimer: null, + longPressOrigin: null, + tapCandidate: null, + wheelAccumDeltaY: 0, + surface: null, + pendingTerm: null + } +} + +/** The document's own scope. The generator emits this declaration at the top of the script. */ +export const scope: TerminalDocumentScope = createTerminalDocumentScope() diff --git a/mobile/src/terminal/document/fit-scale.ts b/mobile/src/terminal/document/fit-scale.ts new file mode 100644 index 00000000000..9f9138ed88a --- /dev/null +++ b/mobile/src/terminal/document/fit-scale.ts @@ -0,0 +1,146 @@ +import { repositionOverlay } from './selection-overlay' +import { + computeFitScale, + flog, + getCellWidth, + getTotalScale, + updateTransform +} from './viewport-transform' +import { scope } from './document-scope' + +export function getCellHeight() { + if (!scope.term || !scope.term._core) { + return 15 + } + const core = scope.term._core + if (core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.height || 15 + } + return 15 +} + +// Why: clamp pan so the terminal content always covers the viewport +// when zoomed in. When content is smaller than viewport in a +// dimension, pin to top-left (no floating in the middle). +export function clampPan() { + if (!scope.term || !scope.term.element) { + return + } + const ts = getTotalScale() + const cw = scope.term.element.scrollWidth * ts + const ch = scope.term.element.scrollHeight * ts + const vpW = window.innerWidth + const vpH = window.innerHeight + if (cw > vpW) { + scope.panX = Math.min(0, Math.max(vpW - cw, scope.panX)) + } else { + scope.panX = 0 + } + if (ch > vpH) { + scope.panY = Math.min(0, Math.max(vpH - ch, scope.panY)) + } else { + scope.panY = 0 + } +} + +// Why: intentional no-op. Mobile replays a live PTY snapshot then applies +// live cursor-relative chunks from that same PTY; resizing only the WebView +// xterm changes cursor coordinates and makes TUI repaint chunks duplicate or +// overlap. Kept as a no-op so its call sites stay legible. +export function adjustRowsForViewport() {} + +// Why: cold-start fit. After init() opens xterm, the renderer needs +// several frames before cell dimensions are computed. Reading too early +// gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM +// not laid out), and computeFitScale returns 1 → no zoom. +// +// Gate: cellWidth × cols is the canonical "logical width" of the grid +// and reflects xterm's layout decision, independent of buffer content. +// We commit when cellWidth becomes positive (renderer ready). Fallback: +// if cellWidth never becomes available, gate on stable positive +// 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 + let attempts = 0 + let lastScrollWidth = -1 + function attempt() { + if (token !== fitRetryToken) { + return + } + if (!scope.term || !scope.term.element) { + return + } + attempts++ + const cellW = getCellWidth() + if (cellW > 0 && scope.term.cols > 0) { + commitFitScale(reason, attempts, 'cellW') + return + } + const w = scope.term.element.scrollWidth + if (w > 0 && w === lastScrollWidth) { + commitFitScale(reason, attempts, 'stableSW') + return + } + lastScrollWidth = w + if (attempts >= FIT_RETRY_MAX_FRAMES) { + flog('commit-timeout', { + reason: reason, + attempts: attempts, + cellW: cellW, + scrollWidth: w, + cols: scope.term.cols + }) + commitFitScale(reason, attempts, 'timeout') + return + } + requestAnimationFrame(attempt) + } + requestAnimationFrame(attempt) +} + +export function commitFitScale(reason: string, attempts: number, gate: string) { + if (!scope.term || !scope.term.element) { + return + } + const preSnapScale = computeFitScale() + scope.currentScale = preSnapScale + // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar + // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents + // a second applyFitScale from observing a "no-op needed" state. + if (scope.currentScale >= 0.95) { + scope.currentScale = 1 + } + scope.userScale = 1 + scope.panX = 0 + scope.panY = 0 + scope.smoothScrollOffsetY = 0 + updateTransform() + adjustRowsForViewport() + + const cellW = getCellWidth() + const sw = scope.term.element.scrollWidth + const vpW = window.innerWidth + const expectedW = cellW * scope.term.cols + const suspect = scope.currentScale === 1 && scope.term.cols > 0 && expectedW > vpW + 1 // expected wider than viewport but no zoom + if (suspect) { + flog('commit-SUSPECT', { + reason: reason, + attempts: attempts, + gate: gate, + preSnapScale: preSnapScale, + finalScale: scope.currentScale, + cellW: cellW, + cols: scope.term.cols, + expectedW: expectedW, + scrollWidth: sw, + vpWidth: vpW + }) + } + repositionOverlay() +} diff --git a/mobile/src/terminal/document/generated-document-region.test-support.ts b/mobile/src/terminal/document/generated-document-region.test-support.ts new file mode 100644 index 00000000000..f93efbfcb0e --- /dev/null +++ b/mobile/src/terminal/document/generated-document-region.test-support.ts @@ -0,0 +1,51 @@ +import { fileURLToPath } from 'node:url' +import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs' +import { XTERM_HTML } from '../terminal-webview-html' + +const SCOPE_OPEN = '(function() {\n' +// The first statement the document runs once the scope object exists. +const FIRST_STATEMENT_AFTER_SCOPE = ' scope.surface = document.getElementById' + +/** + * The scope object the document opens with. Every block below it reads and writes document state + * through this one object, so a test that evaluates a block has to build it first. + */ +export function documentScopePreamble(): string { + const start = XTERM_HTML.indexOf(SCOPE_OPEN) + const end = XTERM_HTML.indexOf(FIRST_STATEMENT_AFTER_SCOPE, start) + if (start === -1 || end <= start) { + throw new Error('the document does not open with the scope object') + } + return XTERM_HTML.slice(start + SCOPE_OPEN.length, end) +} + +/** + * One module's text as the document carries it. The module is re-emitted and then located in the + * document, so a test that evaluates the result is running the WebView's own bytes, not a + * parallel copy of them. + */ +export async function generatedDocumentModule(name: string): Promise { + const emitted = await emitTerminalDocumentModule( + fileURLToPath(new URL(`./${name}.ts`, import.meta.url)) + ) + if (!XTERM_HTML.includes(emitted)) { + throw new Error(`the document does not carry the ${name} module; rebuild the document script`) + } + return emitted +} + +/** + * A function the document's own text declared, read out of the context it was evaluated in. The + * name is checked to be callable, so only its parameter and return types are the caller's claim. + */ +export function documentDeclaredFunction unknown>( + context: Record, + name: string +): T { + const value = context[name] + if (typeof value !== 'function') { + throw new Error(`the document text did not declare ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: checked callable above. + return value as T +} diff --git a/mobile/src/terminal/document/host-message-router.ts b/mobile/src/terminal/document/host-message-router.ts new file mode 100644 index 00000000000..b03bca2cead --- /dev/null +++ b/mobile/src/terminal/document/host-message-router.ts @@ -0,0 +1,194 @@ +import { scope } from './document-scope' +import { applyFitScale } from './fit-scale' +import { notify } from './host-notify' +import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' +import { emitModesIfChanged } from './mode-mirroring' +import { reflow } from './reflow' +import { resumeTerminalDataReplyAuthority } from './query-reply' +import { repositionOverlay } from './selection-overlay' +import { cancelSelect } from './selection-range' +import { resetEvictionCounter } from './selection-state-and-eviction' +import { applyTerminalTheme } from './terminal-theme' +import { init, resize, write } from './terminal-init' +import { applyTextScale } from './text-scaling' +import { flog } from './viewport-transform' +import { resetWriteQueue } from './write-queue' + +/** One message from the host. Every field is optional because the router reads them by type. */ +export type TerminalHostMessage = { + id?: number + type?: string + cols?: number + rows?: number + initialData?: unknown + terminalTheme?: Parameters[0] + fontScale?: number + preserveScroll?: boolean + oscLinks?: unknown + data?: string + containerHeight?: number +} + +export function measureFitDimensions(containerHeightPx: unknown, retriesLeft?: number) { + if (typeof retriesLeft !== 'number') { + retriesLeft = 30 + } + // Why: init and measure are posted back-to-back from React, but + // init has an async rAF chain. A measure that runs synchronously + // after init can find term null, disposed, lacking element, or + // with cells size 0. Retry the whole gate for ~500ms. + const notReady = !scope.term || !scope.term.element + let cellWidth = 0 + let cellHeight = 0 + if (!notReady) { + const core = scope.term!._core + if (core && core._renderService && core._renderService.dimensions) { + cellWidth = core._renderService.dimensions.css.cell.width + cellHeight = core._renderService.dimensions.css.cell.height + } + } + if (notReady || cellWidth <= 0 || cellHeight <= 0) { + if (retriesLeft > 0) { + requestAnimationFrame(function () { + measureFitDimensions(containerHeightPx, retriesLeft - 1) + }) + return + } + flog('measure-fail', { + notReady: notReady, + cellWidth: cellWidth, + cellHeight: cellHeight, + retriesLeft: retriesLeft + }) + notify({ type: 'measure-result', cols: null, rows: null }) + return + } + const vpWidth = window.innerWidth + // Why: prefer the container height passed from React Native over + // window.innerHeight. The RN layout system knows the exact pixel + // height of the terminal frame after the accessory/input bars are + // subtracted, whereas innerHeight can overstate the visible area + // due to layout timing or safe-area insets. + const vpHeight = + typeof containerHeightPx === 'number' && containerHeightPx > 0 + ? containerHeightPx + : window.innerHeight + const cols = Math.floor(vpWidth / cellWidth) + if (cols < scope.MIN_FIT_COLS) { + flog('measure-skip-small-width', { + vpWidth: vpWidth, + cellWidth: cellWidth, + cols: cols + }) + notify({ type: 'measure-result', cols: null, rows: null }) + return + } + // Why: the rows we report become the PTY's actual row count after the + // server fits to viewport, and xterm renders exactly that many lines + // anchored top-left of the WebView. Subtracting rows here would leave + // dead xterm-background space at the bottom of the container and make + // the last PTY rows visually appear above an "invisible line." Any + // safety margin between the prompt and the accessory bar must come + // from RN layout (terminalFrame's flex bounds), not from undersizing + // the PTY. + const rows = Math.max(8, Math.floor(vpHeight / cellHeight)) + notify({ type: 'measure-result', cols: cols, rows: rows }) +} + +export function handleMsg(msg: TerminalHostMessage) { + if (typeof msg.id === 'number') { + // oxlint-disable-next-line unicorn/prefer-includes -- the document's text is pinned token for token; rewriting this changes the native program + if (scope.handledMessageIds.indexOf(msg.id) !== -1) { + return + } + scope.handledMessageIds.push(msg.id) + if (scope.handledMessageIds.length > 256) { + scope.handledMessageIds.shift() + } + } + if (msg.type === 'ping') { + notify({ type: 'pong', pingId: msg.id }) + } else if (msg.type === 'init') { + init( + msg.cols!, + msg.rows!, + msg.initialData, + msg.terminalTheme, + msg.fontScale, + msg.preserveScroll!, + msg.oscLinks + ) + } else if (msg.type === 'set-font-scale') { + // Why: ignore RN echoing back the value a pinch just set (msg.fontScale === + // currentTextScale) so the post-pinch state isn't reset; only apply changes. + if ( + typeof msg.fontScale === 'number' && + msg.fontScale > 0 && + msg.fontScale !== scope.currentTextScale + ) { + scope.userScale = 1 + scope.panX = 0 + scope.panY = 0 + applyTextScale(msg.fontScale) + } + } else if (msg.type === 'resize') { + resize(msg.cols!, msg.rows!) + } else if (msg.type === 'reflow') { + reflow(msg.cols!, msg.rows!) + } else if (msg.type === 'write') { + write(msg.data!) + } else if (msg.type === 'clear') { + scope.terminalGeneration++ + resetWriteQueue() + resumeTerminalDataReplyAuthority() // Why: clear drops the replay boundary. + scope.statusDotPendingSelector = false + scope.afterDrainCallbacks = [] + scope.writesDraining = false + scope.mouseModeScanTail = '' + scope.trackedMouseTrackingMode = 'none' + scope.sgrMouseMode = false + scope.sgrMousePixelsMode = false + scope.initialOscLinks = [] + scope.initialOscLinkRowOffset = 0 + scope.initialOscLinkEvictionReady = false + if (scope.term) { + scope.term.clear() + scope.term.reset() + } + emitModesIfChanged() + emitKeyboardAvoidanceMetrics() + resetEvictionCounter() + if (scope.selMode === 'select') { + notify({ type: 'selection-evicted' }) + cancelSelect() + } + } else if (msg.type === 'measure') { + measureFitDimensions(msg.containerHeight) + } else if (msg.type === 'reset-zoom') { + applyFitScale('reset-zoom-msg') + } else if (msg.type === 'set-theme') { + applyTerminalTheme(msg.terminalTheme) + } else if (msg.type === 'cancel-select') { + if (scope.selMode === 'select') { + cancelSelect() + } + } else if (msg.type === 'do-select-all') { + if (scope.term) { + try { + scope.term.selectAll() + const b = scope.term.buffer.active + if (scope.selMode !== 'select') { + scope.selMode = 'select' + scope.selectionOverlay!.classList.add('active') + notify({ type: 'set-select-mode', enabled: true }) + } + scope.sel = { + anchor: { col: 0, row: 0 }, + focus: { col: scope.term.cols - 1, row: b.length - 1 }, + activeHandle: null + } + repositionOverlay() + } catch {} + } + } +} diff --git a/mobile/src/terminal/document/host-notify.ts b/mobile/src/terminal/document/host-notify.ts new file mode 100644 index 00000000000..310a80e2b39 --- /dev/null +++ b/mobile/src/terminal/document/host-notify.ts @@ -0,0 +1,86 @@ +import { scope } from './document-scope' + +/** + * The postMessage bridge to the host, and the engine error reporting that rides on it. + * + * They are one module because the document declares them together, ahead of the message router + * that both serve. + */ + +declare global { + interface Window { + __engineErrors: string[] + } +} + +export function notify(msg: Record) { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify(msg)) + } +} + +/** What a thrown value can be here: an Error-shaped object, a string, or nothing. */ +export type TerminalEngineError = string | null | undefined | { message?: unknown } + +export function engineErrorText(err: TerminalEngineError) { + if (!err) { + return '' + } + if (typeof err === 'string') { + return err + } + if (err && typeof err.message === 'string') { + return err.message + } + try { + return String(err) + } catch { + return '' + } +} + +export function chromeVersionText() { + const match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\/([0-9.]+)/) + 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) { + return + } + } + const parts = [context] + const errText = engineErrorText(err) + if (errText) { + parts.push(errText) + } + if (window.__engineErrors && window.__engineErrors.length) { + parts.push('captured: ' + window.__engineErrors.join(' | ')) + } + parts.push(chromeVersionText()) + notify({ + type: 'error', + fatal: isFatal, + message: parts.join(' - ') + }) +} + +window.onerror = function ( + msg: string | (Event & { message?: unknown }), + source, + line, + column, + err?: TerminalEngineError +) { + if (window.__engineErrors.length < 20) { + window.__engineErrors.push(String(msg)) + } + reportEngineError('terminal runtime error', err || msg) +} diff --git a/mobile/src/terminal/document/keyboard-avoidance-metrics.ts b/mobile/src/terminal/document/keyboard-avoidance-metrics.ts new file mode 100644 index 00000000000..4c3520ca529 --- /dev/null +++ b/mobile/src/terminal/document/keyboard-avoidance-metrics.ts @@ -0,0 +1,70 @@ +import { notify } from './host-notify' +import { scope, type TerminalDocumentCell, type TerminalDocumentLine } from './document-scope' + +export function lineHasVisibleContent( + line: TerminalDocumentLine, + cell: TerminalDocumentCell | null +) { + if (line.translateToString(true).trim().length > 0) { + return true + } + if (!cell || !line.getCell) { + return false + } + const limit = Math.min(scope.term!.cols || 0, line.length || 0) + for (let x = 0; x < limit; x++) { + const current = line.getCell(x, cell) + if (!current) { + continue + } + if (!current.isBgDefault() || current.isInverse()) { + return true + } + if (typeof current.isUnderline === 'function' && current.isUnderline()) { + return true + } + if (typeof current.isStrikethrough === 'function' && current.isStrikethrough()) { + return true + } + if (typeof current.isOverline === 'function' && current.isOverline()) { + return true + } + } + return false +} + +export function computeContentBottomRow() { + if (!scope.term || !scope.term.buffer || !scope.term.buffer.active) { + return 0 + } + const buffer = scope.term.buffer.active + const top = buffer.viewportY || 0 + const cell = buffer.getNullCell ? buffer.getNullCell() : null + for (let y = (scope.term.rows || 0) - 1; y >= 0; y--) { + try { + const line = buffer.getLine(top + y) + if (line && lineHasVisibleContent(line, cell)) { + return y + } + } catch {} + } + return 0 +} + +export function emitKeyboardAvoidanceMetrics() { + if (!scope.term) { + return + } + let alt = false + try { + alt = + scope.term.buffer && scope.term.buffer.active && scope.term.buffer.active.type === 'alternate' + } catch {} + notify({ + type: 'keyboard-avoidance-metrics', + cursorY: scope.term.buffer && scope.term.buffer.active ? scope.term.buffer.active.cursorY : 0, + contentBottomRow: alt ? 0 : computeContentBottomRow(), + rows: scope.term.rows || 0, + altScreen: alt + }) +} diff --git a/mobile/src/terminal/document/message-bridge.ts b/mobile/src/terminal/document/message-bridge.ts new file mode 100644 index 00000000000..2141b7cfa3b --- /dev/null +++ b/mobile/src/terminal/document/message-bridge.ts @@ -0,0 +1,53 @@ +import { adjustRowsForViewport, applyFitScale, clampPan } from './fit-scale' +import { repositionOverlay } from './selection-overlay' +import { handleMsg, type TerminalHostMessage } from './host-message-router' +import { notify, reportEngineError, type TerminalEngineError } from './host-notify' +import { updateTransform } from './viewport-transform' +import { scope } from './document-scope' + +declare global { + interface Window { + Terminal?: unknown + } +} + +export function handleIncomingMessage(e: Event & { data?: TerminalHostMessage | string }) { + let msg: TerminalHostMessage + try { + msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data + } catch { + return + } + try { + handleMsg(msg!) + } catch (ex) { + reportEngineError( + msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a catch binding is `unknown`; the reporter reads only `message` and falls back to String(). + ex as TerminalEngineError, + msg && msg.type === 'init' && !scope.everReady + ) + } +} + +window.addEventListener('message', handleIncomingMessage) + +document.addEventListener('message', handleIncomingMessage) + +window.addEventListener('resize', function () { + // Why: viewport changed (keyboard open/close, orientation, RN container + // size update). Re-fit so the scale matches the new vpWidth — without + // this, opening the keyboard leaves the terminal at the old scale even + // though there's now less vertical room and the fit ratio may differ. + applyFitScale('window-resize') + adjustRowsForViewport() + repositionOverlay() + clampPan() + updateTransform() +}) + +if (window.Terminal) { + notify({ type: 'web-ready' }) +} else { + reportEngineError('terminal engine missing', 'xterm failed to load', true) +} diff --git a/mobile/src/terminal/document/mode-mirroring.ts b/mobile/src/terminal/document/mode-mirroring.ts new file mode 100644 index 00000000000..c5471bb8db4 --- /dev/null +++ b/mobile/src/terminal/document/mode-mirroring.ts @@ -0,0 +1,46 @@ +import { notify } from './host-notify' +import { getMouseTrackingMode } from './mouse-input-encoding' +import { scope } from './document-scope' + +export function emitModesIfChanged() { + if (!scope.term) { + return + } + const bp = !!(scope.term.modes && scope.term.modes.bracketedPasteMode) + let alt = false + const mouseTrackingMode = getMouseTrackingMode() + try { + alt = + scope.term.buffer && scope.term.buffer.active && scope.term.buffer.active.type === 'alternate' + } catch {} + if ( + bp !== scope.lastEmittedModes.bracketedPasteMode || + alt !== scope.lastEmittedModes.altScreen || + mouseTrackingMode !== scope.lastEmittedModes.mouseTrackingMode || + scope.sgrMouseMode !== scope.lastEmittedModes.sgrMouseMode || + scope.sgrMousePixelsMode !== scope.lastEmittedModes.sgrMousePixelsMode + ) { + scope.lastEmittedModes = { + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: scope.sgrMouseMode, + sgrMousePixelsMode: scope.sgrMousePixelsMode + } + notify({ + type: 'modes', + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: scope.sgrMouseMode, + sgrMousePixelsMode: scope.sgrMousePixelsMode + }) + } +} +scope.lastEmittedModes = { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false +} diff --git a/mobile/src/terminal/document/mouse-click-drag.ts b/mobile/src/terminal/document/mouse-click-drag.ts new file mode 100644 index 00000000000..aed11e99f5f --- /dev/null +++ b/mobile/src/terminal/document/mouse-click-drag.ts @@ -0,0 +1,283 @@ +import { handleDragMove, repositionOverlay, stopEdgeScroll } from './selection-overlay' +import { applyXtermSelection, cancelSelect } from './selection-range' +import { notify } from './host-notify' +import { getMouseTrackingMode, isSafeSgrMouseCoordinate } from './mouse-input-encoding' +import { viewportToCell } from './viewport-cell' +import { scope } from './document-scope' +import { notifyTerminalSurfaceTap } from './surface-tap' +import { viewportToMouseReportCell } from './mouse-report-cell' +import { dispatcherShouldBlockSurface } from './tap-dispatch' + +/** A mouse press being tracked from pointerdown to pointerup. */ +export type TerminalMouseGesture = { + startX: number + startY: number + lastX: number + lastY: number + lastCellKey: string | null + moved: boolean + mode: string + 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 +// drag/any report motion) or the cell is not encodable. +export function buildMouseButtonReport(kind: string, clientX: number, clientY: number) { + const mouseTrackingMode = getMouseTrackingMode() + if (mouseTrackingMode === 'none') { + return '' + } + if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') { + return '' + } + if (kind === 'release' && mouseTrackingMode === 'x10') { + return '' + } + const cell = viewportToMouseReportCell(clientX, clientY) + if (!cell) { + return '' + } + const sgrButton = kind === 'motion' ? 32 : 0 + const sgrFinal = kind === 'release' ? 'm' : 'M' + if (scope.sgrMousePixelsMode) { + if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) { + return '' + } + return scope.ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal + } + if (scope.sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + const sgrCol = cell.col + 1 + const sgrRow = cell.row + 1 + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) { + return '' + } + return scope.ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal + } + const button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32 + const col = cell.col + 1 + 32 + const row = cell.row + 1 + 32 + // Why: non-SGR mouse bytes above ASCII are not preserved reliably through + // the mobile JSON/RPC string path; drop instead of corrupting input. + if (col > 126 || row > 126) { + return '' + } + return ( + scope.ESC + + '[M' + + String.fromCharCode(button) + + String.fromCharCode(col) + + String.fromCharCode(row) + ) +} + +export function mouseReportCellKey(clientX: number, clientY: number) { + const cell = viewportToMouseReportCell(clientX, clientY) + return cell ? cell.col + ',' + cell.row : null +} + +export function abandonMouseGesture() { + const gesture = mouseGesture + mouseGesture = null + if (!gesture) { + return + } + if (gesture.mode === 'tracking') { + // Why: the press report already went to the TUI; a lost pointer must not + // leave the button latched down on the far side. + const release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY) + if (release) { + notify({ type: 'terminal-input', bytes: release }) + } + } else if (gesture.mode === 'selecting') { + if (scope.sel) { + scope.sel.activeHandle = null + } + stopEdgeScroll() + } +} + +export function beginMouseDrag(gesture: TerminalMouseGesture) { + gesture.moved = true + if (getMouseTrackingMode() !== 'none') { + gesture.mode = 'tracking' + gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY) + const press = buildMouseButtonReport('press', gesture.startX, gesture.startY) + if (press) { + notify({ type: 'terminal-input', bytes: press }) + } + return + } + const anchor = viewportToCell(gesture.startX, gesture.startY) + if (!anchor) { + gesture.mode = 'cancelled' + return + } + // Why: mouse drags select character-anchored ranges like desktop terminals, + // not the word-seeded long-press selection; reuse the touch handle-drag + // plumbing (edge scroll included) by acting as a live 'end' handle. + gesture.mode = 'selecting' + scope.selMode = 'select' + scope.sel = { anchor: anchor, focus: anchor, activeHandle: 'end' } + scope.selectionOverlay!.classList.add('active') + notify({ type: 'set-select-mode', enabled: true }) + applyXtermSelection() + repositionOverlay() +} + +export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) { + targetSurface.addEventListener( + 'pointerdown', + function (e) { + if (e.pointerType !== 'mouse' || e.button !== 0) { + return + } + if (dispatcherShouldBlockSurface() || !scope.term) { + return + } + // 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) { + abandonMouseGesture() + } + // Why: mouse pointers have no implicit capture; without it a drag that + // leaves the surface drops pointermove/pointerup and strands the gesture. + try { + if (targetSurface.setPointerCapture) { + targetSurface.setPointerCapture(e.pointerId) + } + } catch {} + mouseGesture = { + startX: e.clientX, + startY: e.clientY, + lastX: e.clientX, + lastY: e.clientY, + lastCellKey: null, + moved: false, + mode: 'pending', + dismissedSelection: false + } + if (scope.selMode === 'select') { + // 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 + } + }, + true + ) + + targetSurface.addEventListener( + 'pointermove', + function (e) { + const gesture = mouseGesture + if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') { + return + } + if (!scope.term) { + return + } + gesture.lastX = e.clientX + gesture.lastY = e.clientY + if ((e.buttons & 1) === 0) { + // Why: a pointerup lost outside the WebView (capture unavailable) must + // end the gesture here, or a tracked press stays latched at the TUI. + // Coordinates first, so the synthesized release lands where the + // pointer re-entered rather than at the previous cell. + abandonMouseGesture() + return + } + if (!gesture.moved) { + const dx = Math.abs(e.clientX - gesture.startX) + const dy = Math.abs(e.clientY - gesture.startY) + if (dx + dy <= scope.TAP_SLOP) { + return + } + beginMouseDrag(gesture) + } + if (gesture.mode === 'tracking') { + // Why: one motion report per cell keeps drags bounded by grid size, not + // by pointermove cadence, so the RN rate limiter is never the bottleneck. + const cellKey = mouseReportCellKey(e.clientX, e.clientY) + if (cellKey && cellKey !== gesture.lastCellKey) { + gesture.lastCellKey = cellKey + const motion = buildMouseButtonReport('motion', e.clientX, e.clientY) + if (motion) { + notify({ type: 'terminal-input', bytes: motion }) + } + } + } else if (gesture.mode === 'selecting') { + handleDragMove('end', e.clientX, e.clientY) + } + }, + true + ) + + targetSurface.addEventListener( + 'pointerup', + function (e) { + const gesture = mouseGesture + if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) { + return + } + mouseGesture = null + if (gesture.mode === 'cancelled' || !scope.term) { + return + } + if (gesture.mode === 'tracking') { + const release = buildMouseButtonReport('release', e.clientX, e.clientY) + if (release) { + notify({ type: 'terminal-input', bytes: release }) + } + return + } + if (gesture.mode === 'selecting') { + if (scope.sel) { + scope.sel.activeHandle = null + } + stopEdgeScroll() + repositionOverlay() + return + } + if (dispatcherShouldBlockSurface()) { + return + } + // Why: a dismissing tap only clears the selection (touch parity); it must + // not also open a link or focus the keyboard underneath. + if (gesture.dismissedSelection) { + return + } + // Pointer clicks keep their current link, file, TUI mouse, and focus priority. + notifyTerminalSurfaceTap(e.clientX, e.clientY, false) + }, + true + ) + + targetSurface.addEventListener( + 'pointercancel', + function (e) { + if (e.pointerType !== 'mouse') { + return + } + abandonMouseGesture() + }, + true + ) + + // Why: Android input injection can pair a mouse-flavored pointerdown with + // real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives, + // the document touch dispatcher owns the gesture. + targetSurface.addEventListener( + 'touchstart', + function () { + if (mouseGesture) { + abandonMouseGesture() + } + }, + true + ) +} diff --git a/mobile/src/terminal/document/mouse-input-encoding.ts b/mobile/src/terminal/document/mouse-input-encoding.ts new file mode 100644 index 00000000000..1ea8360cca3 --- /dev/null +++ b/mobile/src/terminal/document/mouse-input-encoding.ts @@ -0,0 +1,230 @@ +import { notify } from './host-notify' +import { scope } from './document-scope' +import { viewportToMouseReportCell } from './mouse-report-cell' + +export function isAlternateBufferActive() { + try { + return !!( + scope.term && + scope.term.buffer && + scope.term.buffer.active && + scope.term.buffer.active.type === 'alternate' + ) + } catch { + return false + } +} + +export function getMouseTrackingMode() { + try { + if (scope.term && scope.term.modes && typeof scope.term.modes.mouseTrackingMode === 'string') { + const mode = scope.term.modes.mouseTrackingMode + if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') { + return mode + } + return 'none' + } + } catch {} + if ( + scope.trackedMouseTrackingMode === 'x10' || + scope.trackedMouseTrackingMode === 'vt200' || + scope.trackedMouseTrackingMode === 'drag' || + scope.trackedMouseTrackingMode === 'any' + ) { + return scope.trackedMouseTrackingMode + } + return 'none' +} + +export function repeatSequence(sequence: string, count: number) { + let out = '' + for (let i = 0; i < count; i++) { + out += sequence + } + return out +} + +export function buildArrowScrollSequence(lines: number) { + let prefix = '[' + try { + if (scope.term && scope.term.modes && scope.term.modes.applicationCursorKeysMode) { + prefix = 'O' + } + } catch {} + return scope.ESC + prefix + (lines < 0 ? 'A' : 'B') +} + +export function buildMouseWheelSequence(lines: number, clientX: number, clientY: number) { + const cell = viewportToMouseReportCell(clientX, clientY) + if (!cell) { + return '' + } + const eventCode = lines < 0 ? 64 : 65 + if (scope.sgrMousePixelsMode) { + if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) { + return '' + } + return scope.ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M' + } + if (scope.sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + const sgrCol = cell.col + 1 + const sgrRow = cell.row + 1 + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) { + return '' + } + return scope.ESC + '[<' + eventCode + ';' + sgrCol + ';' + sgrRow + 'M' + } + // Why: xterm increments zero-based mouse cells before encoding reports. + const button = eventCode + 32 + const col = cell.col + 1 + 32 + const row = cell.row + 1 + 32 + // Why: non-SGR mouse bytes above ASCII are not preserved reliably through + // the mobile JSON/RPC string path. Fall back to keys for wide terminals. + if (button > 126 || col > 126 || row > 126) { + return '' + } + return ( + scope.ESC + + '[M' + + String.fromCharCode(button) + + String.fromCharCode(col) + + String.fromCharCode(row) + ) +} + +export function isSafeSgrMouseCoordinate(value: number) { + return Number.isInteger(value) && value >= 0 && value <= 9999 +} + +export function buildMouseClickInput(clientX: number, clientY: number) { + const mouseTrackingMode = getMouseTrackingMode() + if (!isClickMouseTrackingMode(mouseTrackingMode)) { + return '' + } + const cell = viewportToMouseReportCell(clientX, clientY) + if (!cell) { + return '' + } + if (scope.sgrMousePixelsMode) { + // Why: xterm 1016 keeps SGR syntax but reports raw zero-based pixel positions. + const pixelX = cell.x + const pixelY = cell.y + if (!isSafeSgrMouseCoordinate(pixelX) || !isSafeSgrMouseCoordinate(pixelY)) { + return '' + } + const pixelPress = scope.ESC + '[<0;' + pixelX + ';' + pixelY + 'M' + if (mouseTrackingMode === 'x10') { + return pixelPress + } + return pixelPress + scope.ESC + '[<0;' + pixelX + ';' + pixelY + 'm' + } + if (scope.sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + const sgrCol = cell.col + 1 + const sgrRow = cell.row + 1 + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) { + return '' + } + const sgrPress = scope.ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M' + if (mouseTrackingMode === 'x10') { + return sgrPress + } + return sgrPress + scope.ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm' + } + // Why: non-SGR click coordinates use printable ASCII bytes on the mobile + // bridge; unsafe wide-terminal cells must not turn into corrupted input. + const col = cell.col + 1 + 32 + const row = cell.row + 1 + 32 + if (col > 126 || row > 126) { + return '' + } + const press = + scope.ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row) + if (mouseTrackingMode === 'x10') { + return press + } + return ( + press + + scope.ESC + + '[M' + + String.fromCharCode(35) + + String.fromCharCode(col) + + String.fromCharCode(row) + ) +} + +export function isClickMouseTrackingMode(mode: string) { + return mode !== 'none' +} + +export function isWheelMouseTrackingMode(mode: string) { + return mode !== 'none' && mode !== 'x10' +} + +export function shouldRouteScrollToTerminalInput() { + return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive() +} + +export function buildMouseWheelScrollInput(lines: number, clientX: number, clientY: number) { + const count = Math.min(Math.abs(lines), 32) + if (count === 0) { + return '' + } + const sequence = buildMouseWheelSequence(lines, clientX, clientY) + if (!sequence) { + return '' + } + return repeatSequence(sequence, count) +} + +export function buildTuiScrollInput(lines: number, clientX: number, clientY: number) { + const count = Math.min(Math.abs(lines), 32) + if (count === 0) { + return '' + } + const mouseTrackingMode = getMouseTrackingMode() + let sequence = '' + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + sequence = buildMouseWheelSequence(lines, clientX, clientY) + } + if (!sequence) { + sequence = buildArrowScrollSequence(lines) + } + return repeatSequence(sequence, count) +} + +export function routeScrollLines(lines: number, clientX: number, clientY: number) { + if (!scope.term || lines === 0) { + return + } + const mouseTrackingMode = getMouseTrackingMode() + const alternateBufferActive = isAlternateBufferActive() + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + // Why: xterm sends wheel events to mouse-aware TUIs before considering + // scrollback, even if the app stays on the normal buffer. + const mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY) + if (mouseInput) { + notify({ type: 'terminal-input', bytes: mouseInput }) + return + } + // Why: default mouse encoding can be unrepresentable in our ASCII-safe + // RPC path on wide terminals. Send bounded arrows instead of local + // scrollback/no-op while a mouse-aware app owns scroll gestures. + const fallbackInput = buildTuiScrollInput(lines, clientX, clientY) + if (fallbackInput) { + notify({ type: 'terminal-input', bytes: fallbackInput }) + } + return + } + if (alternateBufferActive) { + // Why: alternate-screen TUIs own their scroll state and xterm has no + // scrollback there, so mobile scroll gestures must become terminal input. + const input = buildTuiScrollInput(lines, clientX, clientY) + if (input) { + notify({ type: 'terminal-input', bytes: input }) + } + return + } + scope.term.scrollLines(lines) +} diff --git a/mobile/src/terminal/document/mouse-mode-decset-scan.ts b/mobile/src/terminal/document/mouse-mode-decset-scan.ts new file mode 100644 index 00000000000..f47370ab7e9 --- /dev/null +++ b/mobile/src/terminal/document/mouse-mode-decset-scan.ts @@ -0,0 +1,74 @@ +import { extractMouseModeScanTail } from './write-queue' +import { scope } from './document-scope' + +export function isAltScreenActive(data: unknown): data is string { + if (typeof data !== 'string') { + return false + } + const on = data.lastIndexOf(scope.ESC + '[?1049h') + const off = data.lastIndexOf(scope.ESC + '[?1049l') + return on !== -1 && on > off +} + +export function normalizeInitialData(data: unknown) { + if (!isAltScreenActive(data)) { + return data + } + const on = data.lastIndexOf(scope.ESC + '[?1049h') + // Why: SerializeAddon can include normal-buffer scrollback before the + // active alternate-screen snapshot. Replaying both into a fresh mobile + // xterm duplicates TUI frames and can flatten SGR attributes. + return on > 0 ? data.slice(on) : data +} + +export function updateMouseModeFromData(data: unknown) { + if (typeof data !== 'string' || data.length === 0) { + return + } + const input = scope.mouseModeScanTail + data + scope.mouseModeScanTail = extractMouseModeScanTail(input) + const re = new RegExp( + scope.ESC + 'c|' + scope.ESC + '\\[\\?([0-9;]+)([hl])|' + scope.C1_CSI + '\\?([0-9;]+)([hl])', + 'g' + ) + let match: RegExpExecArray | null + while ((match = re.exec(input)) !== null) { + if (match[0] === scope.ESC + 'c') { + scope.trackedMouseTrackingMode = 'none' + scope.sgrMouseMode = false + scope.sgrMousePixelsMode = false + continue + } + const enabled = (match[2] || match[4]) === 'h' + const params = (match[1] || match[3]).split(';') + for (let i = 0; i < params.length; i++) { + if (params[i] === '') { + continue + } + const param = Number(params[i]) + if (!Number.isInteger(param)) { + continue + } + if (param === 9) { + scope.trackedMouseTrackingMode = enabled ? 'x10' : 'none' + } + if (param === 1000) { + scope.trackedMouseTrackingMode = enabled ? 'vt200' : 'none' + } + if (param === 1002) { + scope.trackedMouseTrackingMode = enabled ? 'drag' : 'none' + } + if (param === 1003) { + scope.trackedMouseTrackingMode = enabled ? 'any' : 'none' + } + if (param === 1006) { + scope.sgrMouseMode = enabled + scope.sgrMousePixelsMode = false + } + if (param === 1016) { + scope.sgrMouseMode = false + scope.sgrMousePixelsMode = enabled + } + } + } +} diff --git a/mobile/src/terminal/document/mouse-report-cell.ts b/mobile/src/terminal/document/mouse-report-cell.ts new file mode 100644 index 00000000000..ca4bbaec3ed --- /dev/null +++ b/mobile/src/terminal/document/mouse-report-cell.ts @@ -0,0 +1,67 @@ +import { getCellHeight } from './fit-scale' +import { getCellWidth, getTotalScale } from './viewport-transform' +import { scope } from './document-scope' + +/** Where a viewport point lands in the terminal's cell grid, for an xterm mouse report. */ +export type MouseReportCell = { col: number; row: number; x: number; y: number } + +/** + * Maps a viewport point to a mouse-report cell, or null when there is no grid to map onto. + * + * Reads through the pan offset and the total scale rather than the element's box: the surface is a + * transformed layer, so its on-screen geometry is not the geometry xterm reports in. + */ +export function viewportToMouseReportCell( + clientX: number, + clientY: number +): MouseReportCell | null { + if (!scope.term) { + return null + } + const cellW = getCellWidth() + const cellH = getCellHeight() + if (cellW <= 0 || cellH <= 0) { + return null + } + if (typeof clientX !== 'number') { + clientX = window.innerWidth / 2 + } + if (typeof clientY !== 'number') { + clientY = window.innerHeight / 2 + } + let total = getTotalScale() + if (total <= 0) { + total = 1 + } + let sx = (clientX - scope.panX) / total + let sy = (clientY - scope.panY) / total + const maxX = Math.max(0, scope.term.cols * cellW - 1) + const maxY = Math.max(0, scope.term.rows * cellH - 1) + if (sx < 0) { + sx = 0 + } + if (sx > maxX) { + sx = maxX + } + if (sy < 0) { + sy = 0 + } + if (sy > maxY) { + sy = maxY + } + let col = Math.floor(sx / cellW) + let row = Math.floor(sy / cellH) + if (col < 0) { + col = 0 + } + if (col > scope.term.cols - 1) { + col = scope.term.cols - 1 + } + if (row < 0) { + row = 0 + } + if (row > scope.term.rows - 1) { + row = scope.term.rows - 1 + } + return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) } +} diff --git a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts new file mode 100644 index 00000000000..e1ee6dd63e3 --- /dev/null +++ b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts @@ -0,0 +1,102 @@ +import { getCellHeight } from './fit-scale' +import { getTotalScale, updateScrollIndicator } from './viewport-transform' +import { scope } from './document-scope' + +export function clampNormalScrollLines(lines: number) { + if (!scope.term || !scope.term.buffer || !scope.term.buffer.active || lines === 0) { + return 0 + } + const buffer = scope.term.buffer.active + if (lines > 0) { + return Math.min(lines, Math.max(0, buffer.baseY - buffer.viewportY)) + } + return Math.max(lines, -buffer.viewportY) +} + +export function canScrollNormalBufferDelta(deltaY: number) { + if (!scope.term || !scope.term.buffer || !scope.term.buffer.active || deltaY === 0) { + return false + } + const buffer = scope.term.buffer.active + if (deltaY > 0) { + return buffer.viewportY < buffer.baseY + } + return buffer.viewportY > 0 +} + +export function applyNormalBufferScrollDelta(deltaY: number) { + if (!scope.term || deltaY === 0) { + return false + } + const effectiveCellH = getCellHeight() * getTotalScale() + if (effectiveCellH <= 0) { + return false + } + if (!canScrollNormalBufferDelta(deltaY)) { + resetSmoothScrollOffset() + return false + } + scope.smoothScrollOffsetY -= deltaY + const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH) + if (lines !== 0) { + const applied = clampNormalScrollLines(lines) + if (applied !== 0) { + scope.term.scrollLines(applied) + // Why: xterm's renderer is row-based. Buffer touch pixels and only + // commit whole rows so TUI canvas layers do not shimmer between + // fractional transforms and xterm repaints. + scope.smoothScrollOffsetY += applied * effectiveCellH + } + if (applied !== lines) { + scope.smoothScrollOffsetY = 0 + } + } + const limit = effectiveCellH - 1 + if (scope.smoothScrollOffsetY > limit) { + scope.smoothScrollOffsetY = limit + } + if (scope.smoothScrollOffsetY < -limit) { + scope.smoothScrollOffsetY = -limit + } + updateScrollIndicator(true) + return true +} + +export function enqueueNormalBufferScrollDelta(deltaY: number) { + if (!scope.term || deltaY === 0) { + return false + } + if (!canScrollNormalBufferDelta(deltaY)) { + resetSmoothScrollOffset() + return false + } + scope.pendingNormalScrollDeltaY += deltaY + if (scope.normalScrollFrameId !== null) { + return true + } + // 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 = null + const delta = scope.pendingNormalScrollDeltaY + scope.pendingNormalScrollDeltaY = 0 + if (!applyNormalBufferScrollDelta(delta)) { + resetSmoothScrollOffset() + } + }) + return true +} + +export function resetSmoothScrollOffset() { + scope.pendingNormalScrollDeltaY = 0 + if (scope.normalScrollFrameId !== null) { + cancelAnimationFrame(scope.normalScrollFrameId) + scope.normalScrollFrameId = null + } + if (scope.smoothScrollOffsetY === 0) { + return + } + scope.smoothScrollOffsetY = 0 + updateScrollIndicator(false) +} diff --git a/mobile/src/terminal/document/osc-link-tap.ts b/mobile/src/terminal/document/osc-link-tap.ts new file mode 100644 index 00000000000..762c4596459 --- /dev/null +++ b/mobile/src/terminal/document/osc-link-tap.ts @@ -0,0 +1,221 @@ +import { cellColToStringIndex, getLineText } from './cell-geometry' +import { viewportToCell } from './viewport-cell' +import { + scope, + type TerminalDocumentLine, + type TerminalInitialOscLink, + type TerminalOscLinkService +} from './document-scope' +import { parsePathLineCol, type TerminalPathCandidate } from './path-tap' + +/** What a tapped OSC 8 link resolves to: a URL to open, or a file to reveal. */ +export type TerminalOscLinkTarget = + | { kind: 'url'; url: string } + | { kind: 'file'; fileTap: TerminalPathCandidate } + +// Why: OSC 8 links can render as labels like "#1234"; the URI lives in +// xterm's internal link service, so every access is guarded and falls through. +export function oscLinkService(): TerminalOscLinkService | null { + try { + const core = scope.term && scope.term._core + if (!core) { + return null + } + return ( + core._oscLinkService || (core._inputHandler && core._inputHandler._oscLinkService) || null + ) + } catch { + return null + } +} + +export function oscLinkAtViewportPoint(clientX: number, clientY: number) { + try { + const cell = viewportToCell(clientX, clientY) + if (!cell) { + return null + } + const line = scope.term!.buffer.active.getLine(cell.row) + if (!line) { + return null + } + const urlId = oscLinkIdAtCell(line, cell.col) + if (!urlId) { + return initialOscLinkAtCell(cell.row, cell.col) + } + const svc = oscLinkService() + if (!svc || !svc.getLinkData) { + return initialOscLinkAtCell(cell.row, cell.col) + } + const data = svc.getLinkData(urlId) + const uri = data && data.uri + return terminalOscLinkTarget(uri) + } catch { + return null + } +} + +export function initialOscLinkAtCell(row: number, col: number) { + for (let i = 0; i < scope.initialOscLinks.length; i++) { + const link = scope.initialOscLinks[i] + if (!link || typeof link.uri !== 'string') { + continue + } + if (link.row < scope.initialOscLinkRowOffset) { + continue + } + const shiftedRow = link.row - scope.initialOscLinkRowOffset + if ( + shiftedRow === row && + col >= link.startCol && + col < link.endCol && + initialOscLinkTextStillMatches(link, shiftedRow) + ) { + return terminalOscLinkTarget(link.uri) + } + } + return null +} + +export function terminalOscLinkTarget(uri: unknown): TerminalOscLinkTarget | null { + if (typeof uri !== 'string') { + return null + } + if (/^https?:/i.test(uri)) { + return { kind: 'url', url: uri } + } + const fileTap = resolveTerminalOscFileTap(uri) + return fileTap ? { kind: 'file', fileTap: fileTap } : null +} + +export function resolveTerminalOscFileTap(uri: string) { + return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri) +} + +export function resolveTerminalFileUrlTap(uri: string): TerminalPathCandidate | null { + let parsed: URL + try { + parsed = new URL(uri) + } catch { + return null + } + if (parsed.protocol !== 'file:') { + return null + } + let filePath: string + try { + filePath = decodeURIComponent(parsed.pathname || '') + } catch { + return null + } + if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) { + filePath = '//' + parsed.hostname + filePath + } else if (/^\/[A-Za-z]:\//.test(filePath)) { + filePath = filePath.slice(1) + } + if (!filePath) { + return null + } + const hashTarget = parseFileUrlLineHash(parsed.hash || '') + if (hashTarget) { + return { pathText: filePath, line: hashTarget.line, column: hashTarget.column } + } + if (/%3a/i.test(parsed.pathname || '')) { + return { pathText: filePath, line: null, column: null } + } + return ( + parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null } + ) +} + +export function isLocalFileUriHostname(hostname: string) { + const normalized = String(hostname).toLowerCase() + return ( + normalized === 'localhost' || + normalized === '127.0.0.1' || + normalized === '::1' || + normalized === '[::1]' + ) +} + +export function parseOscPathLikeTarget(value: string) { + if ( + !/^(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))/.test( + value + ) + ) { + return null + } + return parsePathLineCol(value) +} + +export function parseFileUrlLineHash(hash: string) { + const match = /^#?L(\d+)(?:C(\d+))?$/i.exec(hash) + if (!match) { + return null + } + const line = Number.parseInt(match[1], 10) + const column = match[2] ? Number.parseInt(match[2], 10) : null + if (line < 1 || (column !== null && column < 1)) { + return null + } + return { line: line, column: column } +} + +export function parseFilePathTrailingLineTarget(filePath: string) { + const match = /^(.*?)(?::(\d+))(?::(\d+))?$/.exec(filePath) + if ( + !match || + !match[1] || + match[1].charAt(match[1].length - 1) === '/' || + match[1].charAt(match[1].length - 1) === '\\' + ) { + return null + } + const line = Number.parseInt(match[2], 10) + const column = match[3] ? Number.parseInt(match[3], 10) : null + if (line < 1 || (column !== null && column < 1)) { + return null + } + return { pathText: match[1], line: line, column: column } +} + +export function captureInitialOscLinkTexts() { + if (!Array.isArray(scope.initialOscLinks)) { + return + } + for (let i = 0; i < scope.initialOscLinks.length; i++) { + const link = scope.initialOscLinks[i] + if (!link || typeof link.text === 'string') { + continue + } + link.text = initialOscLinkTextAtRow(link, link.row) + } +} + +export function initialOscLinkTextStillMatches(link: TerminalInitialOscLink, row: number) { + if (typeof link.text !== 'string') { + return false + } + return link.text.length > 0 && initialOscLinkTextAtRow(link, row) === link.text +} + +export function initialOscLinkTextAtRow(link: TerminalInitialOscLink, row: number) { + try { + const lineText = getLineText(row) + const start = cellColToStringIndex(row, link.startCol) + const end = cellColToStringIndex(row, link.endCol) + return lineText.slice(start, end) + } catch { + return '' + } +} + +export function oscLinkIdAtCell(line: TerminalDocumentLine, col: number) { + try { + const bufCell = line.getCell!(col) + return bufCell && bufCell.extended && bufCell.extended.urlId ? bufCell.extended.urlId : 0 + } catch { + return 0 + } +} diff --git a/mobile/src/terminal/document/path-tap.ts b/mobile/src/terminal/document/path-tap.ts new file mode 100644 index 00000000000..42b0f25b839 --- /dev/null +++ b/mobile/src/terminal/document/path-tap.ts @@ -0,0 +1,222 @@ +import { cellColToStringIndex, getLineText } from './cell-geometry' +import { viewportToCell } from './viewport-cell' + +/** + * File-path-under-tap detection. + * + * Mirrors the unit-tested `terminal-path-tap.ts`; keep the two in sync. That module is the source + * of truth for the algorithm and has the regression tests. + * + * Matches both slash-bearing paths AND bare filenames with an extension (README.md, + * src/index.ts:5) — like desktop, we propose candidates and let the host's + * files.resolveTerminalPath existence check reject non-files. Agents often print a bare filename + * (the markdown link target is consumed, leaving only the label text), so requiring a slash would + * miss the common case. + */ + +/** A span of a rendered line, in string indices. */ +export type TerminalPathRange = { text: string; startIndex: number; endIndex: number } + +/** A path proposed to the host, with the line and column suffixes it carried. */ +export type TerminalPathCandidate = { + pathText: string + line: number | null + column: number | null +} + +const FILE_PATH_RE = + // oxlint-disable-next-line no-useless-escape -- the document's text is pinned token for token; rewriting this changes the native program + /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g +const SPACED_PATH_RE = + // oxlint-disable-next-line no-useless-escape -- the document's text is pinned token for token; rewriting this changes the native program + /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g +const PATH_LEADING_TRIM: Record = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 } +const PATH_TRAILING_TRIM: Record = { + ')': 1, + ']': 1, + '}': 1, + '"': 1, + "'": 1, + ',': 1, + ';': 1, + '.': 1 +} + +export function parsePathLineCol(value: string): TerminalPathCandidate | null { + const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value) + if (!m) { + return null + } + const pathText = m[1] + const last = pathText.charAt(pathText.length - 1) + if (!pathText || last === '/' || last === '\\') { + return null + } + const line = m[2] ? Number.parseInt(m[2], 10) : null + const column = m[3] ? Number.parseInt(m[3], 10) : null + if ((line !== null && line < 1) || (column !== null && column < 1)) { + return null + } + return { pathText: pathText, line: line, column: column } +} + +export function trimPathBoundaryPunctuation( + raw: string, + rawStart: number +): TerminalPathRange | null { + let start = 0, + end = raw.length + while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) { + start += 1 + } + while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) { + end -= 1 + } + if (start >= end) { + return null + } + return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end } +} + +export function hasSeparatorAfterWhitespace(text: string) { + let sawWhitespace = false + for (let i = 0; i < text.length; i++) { + const ch = text.charAt(i) + if (/\s/.test(ch)) { + sawWhitespace = true + continue + } + if (sawWhitespace && (ch === '/' || ch === '\\')) { + return true + } + } + return false +} + +export function trimSpacedPathTrailingProse( + range: TerminalPathRange, + col?: number +): TerminalPathRange | null { + // A line-end extension token only extends the span when the added segment + // is path-like (contains a separator) — prose must not be swallowed. + let selected: string | null = null + const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g + let match: RegExpExecArray | null + while ((match = extensionPrefixPattern.exec(range.text)) !== null) { + const end = match.index + match[0].length + // Why `var`: the document declares this name twice in one function, which is one binding; two + // block-scoped declarations would be a different program and esbuild renames the inner one. + var text = range.text.slice(0, end) + if (countPathStarts(text) > 1) { + continue + } + if ( + end < range.text.length || + selected === null || + /[\\/]/.test(range.text.slice(selected.length, end)) + ) { + selected = text + } + } + if (selected) { + if (col !== undefined && col >= range.startIndex + selected.length) { + return null + } + return { + text: selected, + startIndex: range.startIndex, + endIndex: range.startIndex + selected.length + } + } + var text = range.text.replace(/\s+$/, '') + return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length } +} + +export function countPathStarts(text: string) { + let count = 0 + const pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g + while (pathStartPattern.exec(text) !== null) { + count += 1 + } + return count +} + +export function hasSpacedPathExtension(text: string) { + const range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length }) + if (!range) { + return false + } + const trimmed = range.text.replace(/\s+$/, '') + return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed) +} + +export function matchSpacedFilePathAtColumn(lineText: string, col: number) { + SPACED_PATH_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = SPACED_PATH_RE.exec(lineText)) !== null) { + const trimmed = trimPathBoundaryPunctuation(match[0], match.index) + if ( + !trimmed || + (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text)) + ) { + continue + } + const candidate = trimSpacedPathTrailingProse(trimmed, col) + if (!candidate) { + continue + } + if (col < candidate.startIndex || col >= candidate.endIndex) { + continue + } + const parsed = parsePathLineCol(candidate.text) + if (parsed) { + return parsed + } + } + return null +} + +export function matchFilePathAtColumn(lineText: string, col: number) { + const spaced = matchSpacedFilePathAtColumn(lineText, col) + if (spaced) { + return spaced + } + FILE_PATH_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = FILE_PATH_RE.exec(lineText)) !== null) { + const raw = match[0] + if (raw.length === 0) { + FILE_PATH_RE.lastIndex += 1 + continue + } + const trimmed = trimPathBoundaryPunctuation(raw, match.index) + if (!trimmed) { + continue + } + if (col < trimmed.startIndex || col >= trimmed.endIndex) { + continue + } + const parsed = parsePathLineCol(trimmed.text) + if (parsed) { + return parsed + } + } + return null +} + +// Returns the path candidate under the tap, or null. Query-only so the tap +// handler can try file detection before forwarding a mouse click — which lets +// file paths open even inside a mouse-tracking TUI. Relies on viewportToCell/ +// getLineText from the host script scope. +export function filePathAtViewportPoint(originX: number, originY: number) { + const tapCell = viewportToCell(originX, originY) + if (!tapCell) { + return null + } + // Map the cell column to a string index so wide chars (emoji/CJK) earlier on + // the line don't shift the match column off the tapped path. + return matchFilePathAtColumn( + getLineText(tapCell.row), + cellColToStringIndex(tapCell.row, tapCell.col) + ) +} diff --git a/mobile/src/terminal/document/query-reply.ts b/mobile/src/terminal/document/query-reply.ts new file mode 100644 index 00000000000..70509735b2c --- /dev/null +++ b/mobile/src/terminal/document/query-reply.ts @@ -0,0 +1,70 @@ +import { enqueueWriteBoundary } from './write-queue' +import { notify } from './host-notify' +import { scope, type TerminalDocumentDisposable } from './document-scope' + +/** + * The gate deciding when xterm's parser replies may reach the native host. + * + * One unit so the tests exercise the same replay and generation gate the document runs rather than + * a re-implementation of it — which was already the reason this was one injected string. + */ +export type QueryReplyTerminal = { + attachCustomKeyEventHandler: (handler: () => boolean) => void + textarea?: { + readOnly: boolean + tabIndex: number + setAttribute: (name: string, value: string) => void + } + 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 +} + +export function resumeTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = true +} + +export function forwardTerminalDataReply(data: string) { + if (terminalDataRepliesEnabled) { + notify({ type: 'terminal-data', bytes: data }) + } +} + +export function enqueueTerminalDataReplyBoundary(gen: number) { + enqueueWriteBoundary(function () { + if (gen === scope.terminalGeneration) { + terminalDataRepliesEnabled = true + } + }) +} + +export function attachTerminalQueryReplyBridge(term: QueryReplyTerminal, gen: number) { + // Why: parser replies require stdin enabled, but mobile input is owned by + // native controls. Keep xterm's textarea inert for touch/hardware keys. + try { + term.attachCustomKeyEventHandler(function () { + return false + }) + if (term.textarea) { + term.textarea.readOnly = true + term.textarea.tabIndex = -1 + term.textarea.setAttribute('inputmode', 'none') + } + } catch {} + try { + scope.termObserverDisposables.push( + term.onData(function (data) { + forwardTerminalDataReply(data) + }) + ) + } catch {} + // Why: live output can queue before initial replay finishes. Enable replies + // at the replay boundary so those live queries are answered, never replayed ones. + enqueueTerminalDataReplyBoundary(gen) +} diff --git a/mobile/src/terminal/document/reflow.ts b/mobile/src/terminal/document/reflow.ts new file mode 100644 index 00000000000..d4f031557e3 --- /dev/null +++ b/mobile/src/terminal/document/reflow.ts @@ -0,0 +1,37 @@ +import { applyFitScale } from './fit-scale' +import { isAlternateBufferActive } from './mouse-input-encoding' +import { updateScrollIndicator } from './viewport-transform' +import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' +import { scope } from './document-scope' + +// Why: rewrap the local xterm buffer (scrollback included) to a new width +// after a server PTY reflow. Skip the alternate screen: those snapshots are +// fully repainted by the PTY and a local resize there can drop SGR attributes +// (see init's alt-screen handling), which shows as white text. +export function reflow(cols: number, rows: number) { + if (!scope.term || isAlternateBufferActive()) { + return + } + const nextCols = cols || scope.term.cols + const nextRows = rows || scope.term.rows + if (nextCols === scope.term.cols && nextRows === scope.term.rows) { + return + } + const buffer = scope.term.buffer.active + // Why: anchor reflow on whether the user was pinned to the live bottom so + // their scroll position survives the rewrap — if they were scrolled up, + // hold the same distance from the bottom; if at the bottom, stay there. + const wasAtBottom = buffer.viewportY >= buffer.baseY + const distanceFromBottom = buffer.baseY - buffer.viewportY + scope.initRows = nextRows + scope.term.resize(nextCols, nextRows) + const rewrapped = scope.term.buffer.active + if (wasAtBottom) { + scope.term.scrollToBottom() + } else { + scope.term.scrollLines(rewrapped.baseY - distanceFromBottom - rewrapped.viewportY) + } + applyFitScale('reflow-msg') + updateScrollIndicator(false) + emitKeyboardAvoidanceMetrics() +} diff --git a/mobile/src/terminal/document/runtime-constants.ts b/mobile/src/terminal/document/runtime-constants.ts new file mode 100644 index 00000000000..a283c629ff8 --- /dev/null +++ b/mobile/src/terminal/document/runtime-constants.ts @@ -0,0 +1,23 @@ +import { scope } from './document-scope' + +/** + * The first declarations inside the document's IIFE. + * + * All eight are read by other parts of the script, so all eight are scope fields; the document + * shell opens the function they live in and `document-close.ts` closes it. + */ +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 diff --git a/mobile/src/terminal/document/selection-menu-buttons.ts b/mobile/src/terminal/document/selection-menu-buttons.ts new file mode 100644 index 00000000000..9601cdb3bdf --- /dev/null +++ b/mobile/src/terminal/document/selection-menu-buttons.ts @@ -0,0 +1,36 @@ +import { scope } from './document-scope' +import { notify } from './host-notify' +import { cancelSelect } from './selection-range' +import { repositionOverlay } from './selection-overlay' + +scope.btnCopy!.addEventListener('click', function (e) { + e.preventDefault() + e.stopPropagation() + if (!scope.term) { + return + } + const text = scope.term.getSelection ? scope.term.getSelection() : '' + if (text && text.length > 0) { + notify({ type: 'selection', text: text }) + } else { + cancelSelect() + } +}) + +scope.btnSelAll!.addEventListener('click', function (e) { + e.preventDefault() + e.stopPropagation() + if (!scope.term) { + return + } + try { + scope.term.selectAll() + const b = scope.term.buffer.active + scope.sel = { + anchor: { col: 0, row: 0 }, + focus: { col: scope.term.cols - 1, row: b.length - 1 }, + activeHandle: null + } + repositionOverlay() + } catch {} +}) diff --git a/mobile/src/terminal/document/selection-overlay.ts b/mobile/src/terminal/document/selection-overlay.ts new file mode 100644 index 00000000000..24e0edcecbe --- /dev/null +++ b/mobile/src/terminal/document/selection-overlay.ts @@ -0,0 +1,141 @@ +import { cellToViewportPx } from './cell-geometry' +import { scope } from './document-scope' +import { getCellHeight } from './fit-scale' +import { notify } from './host-notify' +import { applyXtermSelection, selRange } from './selection-range' +import { viewportToCell } from './viewport-cell' +import { getTotalScale } from './viewport-transform' + +export function repositionOverlay() { + if (scope.selMode !== 'select' || !scope.sel || !scope.term) { + return + } + const r = selRange()! + const sPx = cellToViewportPx(r.start.col, r.start.row) + const ePx = cellToViewportPx(r.end.col + 1, r.end.row) + const cellH = getCellHeight() * getTotalScale() + // Why: native iOS pattern — start handle anchors at the TOP of the + // first selected cell (dot above, stem covers the cell going down); + // end handle anchors at the BOTTOM of the last selected cell (dot + // below, stem covers the cell going up). + scope.handleStart!.style.left = sPx.x + 'px' + scope.handleStart!.style.top = sPx.y + 'px' + scope.handleEnd!.style.left = ePx.x + 'px' + scope.handleEnd!.style.top = ePx.y + cellH + 'px' + const startVisible = sPx.y >= 0 && sPx.y <= window.innerHeight + const endVisible = ePx.y >= 0 && ePx.y <= window.innerHeight + scope.handleStart!.style.visibility = startVisible ? 'visible' : 'hidden' + scope.handleEnd!.style.visibility = endVisible ? 'visible' : 'hidden' + let menuCenterX: number, menuY: number, vTransform: string, marginTop: string + if (startVisible && sPx.y > 56) { + menuCenterX = sPx.x + menuY = sPx.y + vTransform = 'translateY(-100%)' + marginTop = '-12px' + } else if (endVisible && ePx.y + cellH + 56 < window.innerHeight) { + menuCenterX = ePx.x + menuY = ePx.y + cellH + vTransform = 'translateY(0)' + marginTop = '12px' + } else { + // selection covers full viewport — pin to visible center + menuCenterX = window.innerWidth / 2 + menuY = window.innerHeight / 2 + vTransform = 'translateY(-50%)' + marginTop = '0' + } + // Why: clamp horizontally so the pill stays fully visible when the + // selection sits near a screen edge. We position via plain left + // (no horizontal translate) so the clamp math is straightforward. + scope.selMenu!.style.transform = vTransform + scope.selMenu!.style.marginTop = marginTop + scope.selMenu!.style.top = menuY + 'px' + scope.selMenu!.style.left = '0px' + const EDGE_MARGIN = 8 + const menuW = scope.selMenu!.offsetWidth || 0 + const minLeft = EDGE_MARGIN + const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - menuW - EDGE_MARGIN) + const desiredLeft = menuCenterX - menuW / 2 + const clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft)) + scope.selMenu!.style.left = clampedLeft + 'px' +} + +export function syncSelectionHandleToViewportPoint( + handle: string, + clientX: number, + clientY: number +) { + const c = viewportToCell(clientX, clientY) + if (!c || !scope.sel) { + return false + } + if (handle === 'start') { + scope.sel.anchor = c + } else { + scope.sel.focus = c + } + applyXtermSelection() + return true +} + +export function syncEdgeScrollSelectionEndpoint() { + if (!scope.sel || !scope.sel.activeHandle) { + return false + } + // Why: WebView may not emit new touchmove events while a handle is held + // at the edge; resample the stored finger point after each viewport scroll. + return syncSelectionHandleToViewportPoint( + scope.sel.activeHandle, + scope.edgeScrollClientX, + scope.edgeScrollClientY + ) +} + +export function startEdgeScroll(dir: number) { + if (scope.edgeScrollDir === dir) { + return + } + stopEdgeScroll() + scope.edgeScrollDir = dir + scope.edgeScrollTimer = setInterval(function () { + if (!scope.term || scope.edgeScrollDir === 0) { + return + } + const beforeY = scope.term.buffer.active.viewportY + scope.term.scrollLines(scope.edgeScrollDir) + const afterY = scope.term.buffer.active.viewportY + if (beforeY === afterY) { + notify({ type: 'haptic', kind: 'edge-bump' }) + stopEdgeScroll() + return + } + syncEdgeScrollSelectionEndpoint() + repositionOverlay() + }, scope.EDGE_SCROLL_INTERVAL) +} + +export function stopEdgeScroll() { + if (scope.edgeScrollTimer) { + clearInterval(scope.edgeScrollTimer) + scope.edgeScrollTimer = null + } + scope.edgeScrollDir = 0 +} + +export function handleDragMove(handle: string, clientX: number, clientY: number) { + scope.edgeScrollClientX = clientX + scope.edgeScrollClientY = clientY + if (!syncSelectionHandleToViewportPoint(handle, clientX, clientY)) { + return + } + repositionOverlay() + if (clientY < scope.EDGE_SCROLL_PX) { + startEdgeScroll(-1) + } else if (clientY > window.innerHeight - scope.EDGE_SCROLL_PX) { + startEdgeScroll(1) + } else { + stopEdgeScroll() + } +} + +// Latching document-level touch dispatcher: see tap-dispatch.ts. diff --git a/mobile/src/terminal/document/selection-range.ts b/mobile/src/terminal/document/selection-range.ts new file mode 100644 index 00000000000..0c7e77efdf4 --- /dev/null +++ b/mobile/src/terminal/document/selection-range.ts @@ -0,0 +1,115 @@ +import { getLineText } from './cell-geometry' +import { scope, type TerminalDocumentSelection } from './document-scope' +import { notify } from './host-notify' +import { repositionOverlay, stopEdgeScroll } from './selection-overlay' + +/** The ordered ends of the selection, whichever way the user dragged it. */ +export type TerminalSelectionRange = { + start: TerminalDocumentSelection['anchor'] + end: TerminalDocumentSelection['anchor'] +} + +export function seedWordSelection(col: number, absRow: number) { + const line = getLineText(absRow) + if (!line) { + scope.sel = { + anchor: { col: col, row: absRow }, + focus: { col: col, row: absRow }, + activeHandle: null + } + applyXtermSelection() + return + } + let s = col + let e = col + if (col >= 0 && col < line.length && scope.WORD_RE.test(line[col])) { + while (s > 0 && scope.WORD_RE.test(line[s - 1])) { + s-- + } + while (e < line.length - 1 && scope.WORD_RE.test(line[e + 1])) { + e++ + } + } + scope.sel = { + anchor: { col: s, row: absRow }, + focus: { col: e, row: absRow }, + activeHandle: null + } + applyXtermSelection() +} + +export function isStartFirst( + a: TerminalDocumentSelection['anchor'], + b: TerminalDocumentSelection['anchor'] +) { + if (a.row !== b.row) { + return a.row < b.row + } + return a.col <= b.col +} + +export function selRange(): TerminalSelectionRange | null { + if (!scope.sel) { + return null + } + if (isStartFirst(scope.sel.anchor, scope.sel.focus)) { + return { start: scope.sel.anchor, end: scope.sel.focus } + } + return { start: scope.sel.focus, end: scope.sel.anchor } +} + +export function applyXtermSelection() { + if (!scope.term || !scope.sel) { + return + } + const r = selRange() + if (!r) { + return + } + // Why: term.select(col, row, length) takes a buffer-absolute row, + // not a viewport-relative one. Subtracting viewportY here drifts the + // selection by the scrollback height — handles render where the user + // pressed (their math is independent), but xterm highlights an + // off-screen scrollback region and copies the wrong text. + let length: number + if (r.start.row === r.end.row) { + length = Math.max(1, r.end.col - r.start.col + 1) + } else { + const first = scope.term.cols - r.start.col + const middle = Math.max(0, r.end.row - r.start.row - 1) * scope.term.cols + const last = r.end.col + 1 + length = first + middle + last + } + try { + scope.term.select(r.start.col, r.start.row, length) + } catch {} +} + +export function cancelSelect() { + scope.selMode = 'navigate' + scope.sel = null + stopEdgeScroll() + if (scope.term) { + try { + scope.term.clearSelection() + } catch {} + // Why: some xterm renderers cache cells and skip repaint on + // clearSelection alone, leaving the previously-highlighted cells + // visually selected. Force a full refresh so the selection layer + // actually clears on screen. + try { + scope.term.refresh(0, scope.term.rows - 1) + } catch {} + } + scope.selectionOverlay!.classList.remove('active') + notify({ type: 'set-select-mode', enabled: false }) +} + +export function enterSelect(col: number, absRow: number) { + scope.selMode = 'select' + seedWordSelection(col, absRow) + scope.selectionOverlay!.classList.add('active') + notify({ type: 'set-select-mode', enabled: true }) + notify({ type: 'haptic', kind: 'selection' }) + repositionOverlay() +} diff --git a/mobile/src/terminal/document/selection-state-and-eviction.ts b/mobile/src/terminal/document/selection-state-and-eviction.ts new file mode 100644 index 00000000000..a97c1b9cb72 --- /dev/null +++ b/mobile/src/terminal/document/selection-state-and-eviction.ts @@ -0,0 +1,82 @@ +import { repositionOverlay } from './selection-overlay' +import { cancelSelect } from './selection-range' +import { notify } from './host-notify' +import { scope } from './document-scope' + +// ============================================================ +// SELECTION MODE (long-press → handles → Copy) +// ============================================================ +scope.WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u +scope.LONG_PRESS_MS = 500 +scope.LONG_PRESS_SLOP = 10 +// Why: a tap that opens a link/path must survive small finger jitter. The +// long-press slop (10px) only cancels the press-to-select timer; reusing it +// to gate the tap dropped any URL/file tap that wandered >10px — at fit scale +// a few screen px of jitter is a normal tap. Use a wider, time-bounded tap +// window so deliberate scrolls/pans still don't fire a tap. +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') + +// mode: 'navigate' | 'select' +scope.selMode = 'navigate' +scope.sel = null // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } +scope.longPressTimer = null +scope.longPressOrigin = null // {x,y, identifier} +// Why: tap detection is tracked separately from the long-press timer so a +// small jitter that cancels the press-to-select timer does not also cancel +// 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). +scope.tapCandidate = null +scope.edgeScrollTimer = null +scope.edgeScrollDir = 0 +scope.edgeScrollClientX = 0 +scope.edgeScrollClientY = 0 + +// Eviction watchdog: linesEverWritten counts onLineFeed since 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 +} + +export function isBufferFull() { + if (!scope.term) { + return false + } + return linesEverWritten >= 5000 + (scope.term.rows || 0) +} + +export function checkEviction() { + if (scope.selMode !== 'select' || !scope.sel) { + return + } + const oldest = Math.min(scope.sel.anchor.row, scope.sel.focus.row) + if (oldest < 0) { + notify({ type: 'selection-evicted' }) + cancelSelect() + } +} + +export function logFeedAndEvict() { + linesEverWritten++ + if (scope.initialOscLinkEvictionReady && isBufferFull()) { + scope.initialOscLinkRowOffset += 1 + } + if (scope.selMode === 'select' && scope.sel && isBufferFull()) { + scope.sel.anchor.row -= 1 + scope.sel.focus.row -= 1 + checkEviction() + repositionOverlay() + } +} diff --git a/mobile/src/terminal/document/surface-swap.ts b/mobile/src/terminal/document/surface-swap.ts new file mode 100644 index 00000000000..26082493cc3 --- /dev/null +++ b/mobile/src/terminal/document/surface-swap.ts @@ -0,0 +1,69 @@ +import { disposeTermObservers } from './write-queue' +import { attachSurfaceEventHandlers } from './surface-touch-gestures' +import { scope, type TerminalDocumentTerminal } from './document-scope' + +/** The surfaces and terminal a swap is replacing, handed back to whoever commits it. */ +export type TerminalSurfaceSwap = { + oldTerm: TerminalDocumentTerminal | null + oldSurface: HTMLElement | null + 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 = scope.surface +scope.pendingTerm = 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) { + try { + pendingSurface.remove() + } catch {} + if (scope.pendingTerm) { + try { + scope.pendingTerm.dispose() + } catch {} + } + pendingSurface = null + scope.pendingTerm = null + } + const swap = { + oldTerm: committedTerm, + oldSurface: committedSurface, + nextSurface: document.createElement('div') + } + disposeTermObservers() + swap.nextSurface.id = 'terminal-surface' + swap.nextSurface.style.visibility = 'hidden' + swap.nextSurface.style.position = 'absolute' + swap.nextSurface.style.left = '0' + swap.nextSurface.style.top = '0' + document.getElementById('terminal-container')!.appendChild(swap.nextSurface) + scope.surface = swap.nextSurface + pendingSurface = swap.nextSurface + attachSurfaceEventHandlers(scope.surface) + swap.oldSurface!.removeAttribute('id') + return swap +} + +export function commitTerminalSurfaceSwap( + swap: TerminalSurfaceSwap, + nextTerm: TerminalDocumentTerminal +) { + swap.nextSurface.style.visibility = 'visible' + swap.nextSurface.style.position = '' + swap.nextSurface.style.left = '' + swap.nextSurface.style.top = '' + swap.oldSurface!.remove() + if (swap.oldTerm) { + swap.oldTerm.dispose() + } + committedTerm = nextTerm + committedSurface = swap.nextSurface + scope.pendingTerm = null + pendingSurface = null +} diff --git a/mobile/src/terminal/document/surface-tap.ts b/mobile/src/terminal/document/surface-tap.ts new file mode 100644 index 00000000000..475e3992648 --- /dev/null +++ b/mobile/src/terminal/document/surface-tap.ts @@ -0,0 +1,59 @@ +import { notify } from './host-notify' +import { + buildMouseClickInput, + getMouseTrackingMode, + isClickMouseTrackingMode +} from './mouse-input-encoding' +import { oscLinkAtViewportPoint, resolveTerminalFileUrlTap } from './osc-link-tap' +import { filePathAtViewportPoint } from './path-tap' +import { fileUrlAtViewportPoint, urlAtViewportPoint } from './url-tap' + +export function notifyTerminalSurfaceTap(originX: number, originY: number, focusKeyboard: boolean) { + const tappedOscLink = oscLinkAtViewportPoint(originX, originY) + if (tappedOscLink && tappedOscLink.kind === 'file') { + notify({ + type: 'terminal-file-tap', + pathText: tappedOscLink.fileTap.pathText, + line: tappedOscLink.fileTap.line, + column: tappedOscLink.fileTap.column + }) + return + } + const tappedFileUrl = fileUrlAtViewportPoint(originX, originY) + const tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null + if (tappedFileUrlPath) { + notify({ + type: 'terminal-file-tap', + pathText: tappedFileUrlPath.pathText, + line: tappedFileUrlPath.line, + column: tappedFileUrlPath.column + }) + return + } + const tappedUrl = + tappedOscLink && tappedOscLink.kind === 'url' + ? tappedOscLink.url + : urlAtViewportPoint(originX, originY) + if (tappedUrl) { + notify({ type: 'open-url', url: tappedUrl }) + return + } + const tappedPath = filePathAtViewportPoint(originX, originY) + if (tappedPath) { + notify({ + type: 'terminal-file-tap', + pathText: tappedPath.pathText, + line: tappedPath.line, + column: tappedPath.column + }) + return + } + const clickInput = buildMouseClickInput(originX, originY) + if (clickInput) { + notify({ type: 'terminal-input', bytes: clickInput }) + } + // Touch still needs native input focus after the TUI consumes its mouse click. + if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) { + notify({ type: 'terminal-tap' }) + } +} diff --git a/mobile/src/terminal/document/surface-touch-gestures.ts b/mobile/src/terminal/document/surface-touch-gestures.ts new file mode 100644 index 00000000000..072186a14db --- /dev/null +++ b/mobile/src/terminal/document/surface-touch-gestures.ts @@ -0,0 +1,276 @@ +import { scope } from './document-scope' +import { clampPan, getCellHeight } from './fit-scale' +import { notify } from './host-notify' +import { attachSurfaceMouseClickDragHandler } from './mouse-click-drag' +import { routeScrollLines, shouldRouteScrollToTerminalInput } from './mouse-input-encoding' +import { + applyNormalBufferScrollDelta, + enqueueNormalBufferScrollDelta, + resetSmoothScrollOffset +} from './normal-buffer-smooth-scroll' +import { dispatcherShouldBlockSurface } from './tap-dispatch' +import { applyTextScale, snapToTextScalePreset } from './text-scaling' +import { getTotalScale, updateTransform } from './viewport-transform' +import { attachSurfaceWheelHandler } from './wheel-scroll' + +/** A surface that has already been wired, so a re-mount does not stack handlers. */ +type TerminalGestureSurface = HTMLElement & { __orcaSurfaceHandlersAttached?: boolean } + +/** The live touch gesture: the last point, the velocity, and the pinch it may be in. */ +type TerminalTouchState = { + lastX: number + lastY: number + lastTime: number + velY: number + accumDelta: number + momentumId: number | null + isPinching: boolean + pinchDist: number + pinchScale: number + pinchSurfX: number + 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 + } + const instantVelocity = deltaY / dt + if (!Number.isFinite(instantVelocity)) { + return + } + // 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 +} + +export function getDistance(a: Touch, b: Touch) { + const dx = a.clientX - b.clientX, + dy = a.clientY - b.clientY + return Math.sqrt(dx * dx + dy * dy) +} + +export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface) { + if (!targetSurface || targetSurface.__orcaSurfaceHandlersAttached) { + return + } + targetSurface.__orcaSurfaceHandlersAttached = true + // Why: init() swaps in a new hidden surface to avoid flicker; each + // replacement needs gesture handlers or tab-switch replays stop scrolling. + targetSurface.addEventListener( + 'mousedown', + function (e) { + e.preventDefault() + e.stopPropagation() + }, + true + ) + targetSurface.addEventListener( + 'click', + function (e) { + e.preventDefault() + e.stopPropagation() + }, + true + ) + + attachSurfaceWheelHandler(targetSurface) + attachSurfaceMouseClickDragHandler(targetSurface) + + targetSurface.addEventListener( + 'touchstart', + function (e) { + if (dispatcherShouldBlockSurface()) { + return + } + if (ts.momentumId) { + cancelAnimationFrame(ts.momentumId) + ts.momentumId = null + } + if (e.touches.length === 2) { + ts.isPinching = true + scope.smoothScrollOffsetY = 0 + ts.pinchDist = getDistance(e.touches[0], e.touches[1]) + ts.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 + } 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 + } + }, + { capture: true, passive: true } + ) + + targetSurface.addEventListener( + 'touchmove', + function (e) { + if (dispatcherShouldBlockSurface()) { + return + } + if (!scope.term) { + return + } + e.preventDefault() + e.stopPropagation() + + if (e.touches.length === 2) { + ts.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 + // 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)) + const total = getTotalScale() + scope.panX = mx - ts.pinchSurfX * total + scope.panY = my - ts.pinchSurfY * total + clampPan() + updateTransform() + } else if (e.touches.length === 1 && !ts.isPinching) { + const x = e.touches[0].clientX, + y = e.touches[0].clientY + const now = Date.now(), + dt = now - ts.lastTime + + // Why: pan horizontally only when content overflows the viewport (larger + // than fit) — same check clampPan() uses. Vertical always drives buffer + // scroll so scrollback stays reachable at any text size; calling the + // never-defined contentWiderThanViewport() here threw and killed all + // single-finger scrolling, scrollback included. + if ( + scope.term.element && + scope.term.element.scrollWidth * getTotalScale() > window.innerWidth + 1 + ) { + scope.panX += x - ts.lastX + clampPan() + updateTransform() + } + + const deltaY = ts.lastY - y + ts.lastTime = now + if (shouldRouteScrollToTerminalInput()) { + updateTouchVelocity(deltaY, dt) + resetSmoothScrollOffset() + const effectiveCellH = getCellHeight() * getTotalScale() + ts.accumDelta += deltaY + const lines = Math.trunc(ts.accumDelta / effectiveCellH) + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH + routeScrollLines(lines, x, y) + } + } else { + if (enqueueNormalBufferScrollDelta(deltaY)) { + updateTouchVelocity(deltaY, dt) + } else { + ts.velY = 0 + } + } + ts.lastX = x + ts.lastY = y + } + }, + { capture: true, passive: false } + ) + + targetSurface.addEventListener( + 'touchend', + function (e) { + if (dispatcherShouldBlockSurface()) { + return + } + if (!scope.term) { + return + } + + if (ts.isPinching && e.touches.length < 2) { + ts.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 + // size change reflows columns and RN persists + resizes the PTY to match. + const target = snapToTextScalePreset(scope.currentTextScale * scope.userScale) + const changed = target !== scope.currentTextScale + scope.userScale = 1 + scope.panX = 0 + scope.panY = 0 + applyTextScale(target) + updateTransform() + notify({ type: 'font-scale-changed', fontScale: target }) + if (changed) { + 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 + } + return + } + + if (e.touches.length === 0) { + let vel = ts.velY + const FRICTION = 0.972 + const MIN_VEL = 0.012 + function momentumStep() { + vel *= FRICTION + if (Math.abs(vel) < MIN_VEL) { + ts.momentumId = null + return + } + const delta = vel * 16 + if (shouldRouteScrollToTerminalInput()) { + resetSmoothScrollOffset() + const effectiveCellH = getCellHeight() * getTotalScale() + ts.accumDelta += delta + const lines = Math.trunc(ts.accumDelta / effectiveCellH) + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH + routeScrollLines(lines, ts.lastX, ts.lastY) + } + } else { + if (!applyNormalBufferScrollDelta(delta)) { + ts.momentumId = null + return + } + } + ts.momentumId = requestAnimationFrame(momentumStep) + } + if (Math.abs(vel) > MIN_VEL) { + ts.momentumId = requestAnimationFrame(momentumStep) + } + } + }, + { capture: true, passive: true } + ) +} + +attachSurfaceEventHandlers(scope.surface!) diff --git a/mobile/src/terminal/document/tap-dispatch.ts b/mobile/src/terminal/document/tap-dispatch.ts new file mode 100644 index 00000000000..8e0b07c4bbd --- /dev/null +++ b/mobile/src/terminal/document/tap-dispatch.ts @@ -0,0 +1,248 @@ +import { handleDragMove, stopEdgeScroll } from './selection-overlay' +import { cancelSelect, enterSelect } from './selection-range' +import { notify } from './host-notify' +import { viewportToCell } from './viewport-cell' +import { scope } from './document-scope' +import { notifyTerminalSurfaceTap } from './surface-tap' + +// ============================================================ +// LATCHING TOUCH DISPATCHER (document-level) +// ============================================================ + +/** What the dispatcher has latched onto, and the fingers it is tracking. */ +export type TerminalTouchDispatch = { + mode: string + touchId: number | null + touchIds: number[] | null + longPressFingerInsideOverlay: boolean +} + +/** 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) { + return touches[i] + } + } + return null +} + +export function targetInside( + target: EventTarget | null, + el: TerminalDocumentTargetContainer | null +) { + if (!target || !el) { + return false + } + return el.contains(target) +} + +export function clearLongPress() { + if (scope.longPressTimer) { + clearTimeout(scope.longPressTimer) + scope.longPressTimer = null + } + scope.longPressOrigin = null +} + +export function armLongPress(touch: Touch) { + scope.longPressOrigin = { x: touch.clientX, y: touch.clientY, identifier: touch.identifier } + scope.longPressTimer = setTimeout(function () { + scope.longPressTimer = null + if (!scope.longPressOrigin) { + return + } + const c = viewportToCell(scope.longPressOrigin.x, scope.longPressOrigin.y) + if (!c) { + return + } + enterSelect(c.col, c.row) + }, scope.LONG_PRESS_MS) +} + +export function touchSlopExceeded(t: Touch) { + if (!scope.longPressOrigin) { + return false + } + const dx = Math.abs(t.clientX - scope.longPressOrigin.x) + const dy = Math.abs(t.clientY - scope.longPressOrigin.y) + return dx + dy > scope.LONG_PRESS_SLOP +} + +// 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' +} + +document.addEventListener( + 'touchstart', + function (e) { + const t = e.touches[0] + const target = e.target + const onHandle = target === scope.handleStart || target === scope.handleEnd + const inOverlay = targetInside(target, scope.selectionOverlay) + const inSurface = targetInside(target, scope.surface) + // Why: clear any stale tap candidate up front; only a fresh single-finger + // surface touch (below) re-arms it, so handle drags / pinches / dismiss + // taps never resolve as a link tap on touchend. + scope.tapCandidate = null + + if (e.touches.length === 2) { + // pinch latch + if (scope.selMode === 'select') { + notify({ type: 'mobile-clip-cancel-by-pinch' }) + cancelSelect() + } + dispatch.mode = 'pinch' + dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier] + clearLongPress() + return + } + + if (onHandle && scope.selMode === 'select') { + // start handle drag + const handleName = target === scope.handleStart ? 'start' : 'end' + scope.sel!.activeHandle = handleName + dispatch.mode = 'select-drag' + dispatch.touchId = t.identifier + e.preventDefault() + return + } + + if (inOverlay) { + // tap on menu pill — let the buttons' own handlers fire + return + } + + if (inSurface && scope.selMode === 'select') { + // Why: tap-to-dismiss matches native iOS/Android — touching outside the + // 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 + return + } + + if (inSurface) { + dispatch.mode = 'surface' + dispatch.touchId = t.identifier + scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier } + armLongPress(t) + } + }, + { capture: true, passive: false } +) + +document.addEventListener( + 'touchmove', + function (e) { + if (dispatch.mode === 'select-drag') { + const t = touchById(e.touches, dispatch.touchId) + if (!t || !scope.sel || !scope.sel.activeHandle) { + return + } + e.preventDefault() + handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY) + return + } + if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') { + // long-press slop check + if (scope.longPressTimer && e.touches.length === 1) { + if (touchSlopExceeded(e.touches[0])) { + clearLongPress() + } + } + // Why: disqualify the tap only once the finger travels past TAP_SLOP + // (a scroll/pan), independent of the long-press timer — so a tap that + // jitters under TAP_SLOP still opens the link/path under the finger. + if (scope.tapCandidate && e.touches.length === 1) { + const mt = e.touches[0] + if (mt.identifier === scope.tapCandidate.identifier) { + const dx = Math.abs(mt.clientX - scope.tapCandidate.x) + const dy = Math.abs(mt.clientY - scope.tapCandidate.y) + if (dx + dy > scope.TAP_SLOP) { + scope.tapCandidate = null + } + } + } else if (e.touches.length !== 1) { + scope.tapCandidate = null + } + // existing surface handler will run from its own listener + } + }, + { capture: true, passive: false } +) + +document.addEventListener( + 'touchend', + function (e) { + if (dispatch.mode === 'select-drag') { + if (scope.sel) { + scope.sel.activeHandle = null + } + stopEdgeScroll() + dispatch.mode = 'idle' + dispatch.touchId = null + return + } + if (dispatch.mode === 'pinch') { + if (e.touches.length < 2) { + dispatch.mode = e.touches.length === 1 ? 'surface' : 'idle' + dispatch.touchIds = null + if (e.touches.length === 1) { + dispatch.touchId = e.touches[0].identifier + } + } + return + } + if (dispatch.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. + 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 + } + } + }, + { capture: true, passive: true } +) + +document.addEventListener( + 'touchcancel', + function () { + clearLongPress() + scope.tapCandidate = null + stopEdgeScroll() + if (dispatch.mode === 'select-drag') { + if (scope.sel) { + scope.sel.activeHandle = null + } + } + dispatch.mode = 'idle' + dispatch.touchId = null + dispatch.touchIds = null + }, + { capture: true, passive: true } +) diff --git a/mobile/src/terminal/document/term-observers.ts b/mobile/src/terminal/document/term-observers.ts new file mode 100644 index 00000000000..2e7cd4f12b3 --- /dev/null +++ b/mobile/src/terminal/document/term-observers.ts @@ -0,0 +1,40 @@ +import { afterWritesDrained, disposeTermObservers } from './write-queue' +import { updateScrollIndicator } from './viewport-transform' +import { scope } from './document-scope' +import { logFeedAndEvict } from './selection-state-and-eviction' +import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' +import { emitModesIfChanged } from './mode-mirroring' + +export function attachTermObservers() { + if (!scope.term) { + return + } + disposeTermObservers() + try { + scope.termObserverDisposables.push(scope.term.onLineFeed!(logFeedAndEvict)) + } catch {} + try { + scope.termObserverDisposables.push( + scope.term.onScroll!(function () { + updateScrollIndicator(false) + }) + ) + } catch {} + // Why: emit modes on every parsed write so RN's mirror stays current + // without round-trip; covers \x1b[?2004h/l and alt-screen toggles. + try { + if (scope.term.onWriteParsed) { + scope.termObserverDisposables.push( + scope.term.onWriteParsed(function () { + emitModesIfChanged() + emitKeyboardAvoidanceMetrics() + }) + ) + } + } catch {} + // Initial emit once buffer settles. + afterWritesDrained(function () { + emitModesIfChanged() + emitKeyboardAvoidanceMetrics() + }) +} diff --git a/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts b/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts new file mode 100644 index 00000000000..4a799bb1f25 --- /dev/null +++ b/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts @@ -0,0 +1,391 @@ +import { + describeToken, + readScriptTokens, + type DocumentToken +} from './terminal-document-tokens.test-support' + +/** + * Whether two versions of the in-WebView document script are the same program, allowing only the + * scope qualifier that moving it into modules requires. + * + * C7.1 turns the document's one 2,758-line IIFE into modules the web page can import. A variable + * the script assigns across what became a module boundary cannot stay a free variable — assigning + * an imported binding is a syntax error — so those become fields of one scope object, 73 + * declaration sites in all, and every read and write of them gains a qualifier. Nothing else about + * the program may change. + * + * Byte comparison cannot make that claim once the source is formatter-owned: `oxfmt` writes the + * repository's style, which drops the semicolons the hand-written document carries, so the emitted + * text necessarily differs on almost every line for reasons that are not the refactor. Tokens are + * the level where the claim is exactly true. Semicolons are excluded for the same reason they moved + * — they are the formatter's, not the program's — and comments never reach the stream. + * + * This is deliberately stricter than "it still runs": a reordered statement, a changed literal, a + * dropped `!`, a renamed local, all diverge here and are reported with the token index and both + * sides, so the flip commit is reviewed by running this rather than by reading a 515-line diff. + */ +/** + * The differences moving the script into modules is allowed to make, each counted on its own. + * + * Eight classes and no others. Six are the repository's own rules and the printer rewriting the + * document's ES5 style the moment its source is a linted module — measured over the whole script, + * not assumed: `curly` braces 279 brace-less bodies, `no-unused-vars` unbinds 36 catch clauses, 373 + * `var` declarators become `const` or `let`, `unicorn/prefer-number-properties` moves 17 globals + * onto `Number`, the printer spells out 4 shorthand properties whose value gained a qualifier, and + * it stops renaming 7 bindings that are no longer shadows. The other two are the move itself: 609 + * qualified references and 73 declarations onto the scope. Semicolons and whitespace are the + * formatter's and never reach the token stream at all. + * + * Counted separately because the flip commit pins each number: a total would let one class absorb + * another, which is exactly the drift the pin exists to catch. + */ +export type TerminalDocumentNormalisations = { + /** `name` became `.name`; the declaration stayed where it was. */ + readonly qualifiedReferences: number + /** + * `var name` became `.name`; the declaration moved onto the scope object. A `var` + * with several declarators counts once per declarator, because each becomes its own assignment. + */ + readonly scopeFieldDeclarations: number + /** `var` became `const` or `let`, the binding staying local to the emitted script. */ + readonly rebindings: number + /** A brace-less `if`/`else`/`for`/`while` body gained its braces. */ + readonly bracedBodies: number + /** `catch (e)` became `catch`, the unused binding dropped. */ + readonly unboundCatches: number + /** A global numeric function became its `Number` property. */ + readonly numberProperties: number + /** `{ name: name }` was shorthand; qualifying the value spells the property out again. */ + readonly shorthandProperties: number + /** + * An inner binding that shadowed a document variable stopped being a shadow once that variable + * moved onto the scope, so the printer stopped renaming it. + */ + readonly unshadowedNames: number +} + +/** + * The bindings the printer renamed on the baseline and leaves alone in the modules, listed. + * + * A parameter named for a document variable shadowed it while both lived in one function scope, so + * the printer gave the inner one a decimal suffix; once the outer name is a scope field there is no + * shadow and the inner one keeps its own name. Listed rather than matched by shape: a rule that + * accepted any `name2` facing `name` would also accept an unrelated rename that happens to end in a + * digit, which is a changed program, not a normalisation. + * + * One entry covers all seven sites the whole script has: the `term` parameter of + * `attachTerminalQueryReplyBridge` in `query-reply.ts` and its six uses. + */ +const UNSHADOWED_RENAMES: readonly { + readonly baseline: string + readonly generated: string + readonly module: string +}[] = [{ baseline: 'term2', generated: 'term', module: 'query-reply' }] + +/** Whether this exact baseline-to-generated pair is one of the listed unshadowed renames. */ +function isListedUnshadowedRename(baseline: string, generated: string): boolean { + return UNSHADOWED_RENAMES.some( + (entry) => entry.baseline === baseline && entry.generated === generated + ) +} + +/** + * The globals `unicorn/prefer-number-properties` moves onto `Number`. + * + * Measured over the whole script: seventeen sites, and the rule is the only one of its kind that + * appears often enough to be worth matching. Each is equivalent here because every call is already + * behind a `typeof … === 'number'` check or is parsing a string, which is what the `Number` form + * does with no coercion of its own. + */ +const NUMBER_GLOBALS = new Set(['isFinite', 'isNaN', 'parseInt', 'parseFloat']) + +export type TerminalDocumentEquivalence = + | { readonly equivalent: true; readonly normalisations: TerminalDocumentNormalisations } + | { readonly equivalent: false; readonly reason: string } + +/** + * `baseline` is the script as it stood before the move, `candidate` the one the modules generate. + * + * The qualifier is read from `qualifier`, not assumed, so the test names the object it expects and + * a rename cannot quietly satisfy this. + */ +/** The statement heads `curly` braces: everything whose body may be a single unbraced statement. */ +const BRACEABLE_HEAD_KEYWORDS = new Set(['if', 'for', 'while']) + +/** + * Whether the `{` at `open` is the body of a braceable head rather than some other block. + * + * `else` and `do` are followed by their body directly. The rest put a parenthesised head first, so + * the `)` is walked back to its `(` and the keyword before that is what decides. Without this a + * bare block anywhere in the generated script would be absorbed as a linter-added body, when it is + * a statement the baseline does not have. + */ +function isBraceableHeadBody(tokens: readonly DocumentToken[], open: number): boolean { + const previous = tokens[open - 1] + if (previous === undefined) { + return false + } + if (previous.label === 'else' || previous.label === 'do') { + return true + } + if (previous.label !== ')') { + return false + } + let depth = 0 + for (let i = open - 1; i >= 0; i--) { + const label = tokens[i]?.label + if (label === ')') { + depth += 1 + continue + } + if (label === '(') { + depth -= 1 + if (depth === 0) { + return BRACEABLE_HEAD_KEYWORDS.has(tokens[i - 1]?.label ?? '') + } + } + } + return false +} + +/** The index of the `}` closing the `{` at `open`, or -1 when the generated script has none. */ +function matchingCloseIndex(tokens: readonly DocumentToken[], open: number): number { + let depth = 0 + for (let i = open; i < tokens.length; i++) { + const label = tokens[i]?.label + if (label === '{') { + depth += 1 + continue + } + if (label === '}') { + depth -= 1 + if (depth === 0) { + return i + } + } + } + return -1 +} + +export function compareTerminalDocumentScripts( + baseline: string, + candidate: string, + qualifier: string +): TerminalDocumentEquivalence { + const baselineTokens = readScriptTokens(baseline, 'the baseline') + if (!baselineTokens.ok) { + return { equivalent: false, reason: baselineTokens.reason } + } + const candidateTokens = readScriptTokens(candidate, 'the generated script') + if (!candidateTokens.ok) { + return { equivalent: false, reason: candidateTokens.reason } + } + const before = baselineTokens.tokens + const after = candidateTokens.tokens + let qualifiedReferences = 0 + let scopeFieldDeclarations = 0 + let rebindings = 0 + let bracedBodies = 0 + let unboundCatches = 0 + let numberProperties = 0 + let shorthandProperties = 0 + let unshadowedNames = 0 + // The generated index each inserted `{` expects its `}` at, innermost last. Recording the index + // rather than counting means an absorbed close is the one that closes that body and no other. + const insertedBraceCloses: number[] = [] + let lastMatched: DocumentToken | undefined + let left = 0 + let right = 0 + while (left < before.length && right < after.length) { + const expected = before[left] + const actual = after[right] + // Ahead of the equality check on purpose: the baseline's next token is a `}` too wherever a + // braced body ends a block, and this index is known to close the inserted body, so matching + // them as a pair would consume the wrong one and leave the counts right for the wrong reason. + if (actual.label === '}' && insertedBraceCloses.at(-1) === right) { + insertedBraceCloses.pop() + right += 1 + continue + } + if (expected.label === actual.label && expected.text === actual.text) { + lastMatched = expected + left += 1 + right += 1 + continue + } + // `term2` -> `term`: the printer disambiguated a shadowed binding on the baseline side, and + // qualifying the outer name removed the shadow, so the inner one keeps its own name. + if ( + expected.label === 'name' && + actual.label === 'name' && + isListedUnshadowedRename(expected.text, actual.text) + ) { + unshadowedNames += 1 + lastMatched = actual + left += 1 + right += 1 + continue + } + // `{ name }` -> `{ name: .name }`: the printer writes the baseline's shorthand back + // as one token, and qualifying the value makes the property name unavoidable again. + if ( + actual.label === ':' && + lastMatched?.label === 'name' && + after[right + 1]?.label === 'name' && + after[right + 1]?.text === qualifier && + after[right + 2]?.label === '.' && + after[right + 3]?.text === lastMatched.text + ) { + shorthandProperties += 1 + right += 4 + continue + } + // `name` -> `.name`, three tokens for one. + if (isQualified(after, right, expected, qualifier)) { + qualifiedReferences += 1 + left += 1 + right += 3 + continue + } + // `parseInt` -> `Number.parseInt`, the same shape under a different object. + if (NUMBER_GLOBALS.has(expected.text) && isQualified(after, right, expected, 'Number')) { + numberProperties += 1 + left += 1 + right += 3 + continue + } + // `var name` -> `.name`: the declaration itself moved onto the scope object. + if ( + expected.label === 'var' && + before[left + 1] !== undefined && + isQualified(after, right, before[left + 1], qualifier) + ) { + scopeFieldDeclarations += 1 + left += 2 + right += 3 + continue + } + // `var a = 1, b = 2` where both moved onto the scope: the comma introduces the second + // declaration, which is written as its own assignment. + if ( + expected.label === ',' && + before[left + 1] !== undefined && + isQualified(after, right, before[left + 1], qualifier) + ) { + scopeFieldDeclarations += 1 + left += 2 + right += 3 + continue + } + if (expected.label === 'var' && isBlockScopedKeyword(actual)) { + rebindings += 1 + lastMatched = actual + left += 1 + right += 1 + continue + } + // `catch (e) {` -> `catch {`: three baseline tokens the linted form does not carry. + if ( + lastMatched?.label === 'catch' && + expected.label === '(' && + before[left + 1]?.label === 'name' && + before[left + 2]?.label === ')' && + actual.label === '{' + ) { + unboundCatches += 1 + left += 3 + continue + } + // `if (a) b;` -> `if (a) { b; }`: the body the repository's `curly` rule braced. Only a + // braceable head's body qualifies, and only that body's own close is absorbed. + if (actual.label === '{' && isBraceableHeadBody(after, right)) { + const close = matchingCloseIndex(after, right) + if (close !== -1) { + bracedBodies += 1 + insertedBraceCloses.push(close) + right += 1 + continue + } + } + return { + equivalent: false, + reason: `token ${left}: expected ${describeToken(expected)}, generated ${describeToken(actual)}` + } + } + // A body braced at the very end of the script leaves its close after the baseline has run out. + while (insertedBraceCloses.at(-1) === right && after[right]?.label === '}') { + insertedBraceCloses.pop() + right += 1 + } + if (left !== before.length || right !== after.length) { + return { + equivalent: false, + reason: `length: ${before.length - left} token(s) left in the baseline, ${after.length - right} in the generated script` + } + } + if (insertedBraceCloses.length !== 0) { + return { + equivalent: false, + reason: `${insertedBraceCloses.length} inserted brace(s) never closed` + } + } + return { + equivalent: true, + normalisations: { + qualifiedReferences, + scopeFieldDeclarations, + rebindings, + bracedBodies, + unboundCatches, + numberProperties, + shorthandProperties, + unshadowedNames + } + } +} + +/** + * Whether a token is the `const` or `let` a `var` became. + * + * `let` is contextual outside strict mode, so acorn reports it as a name rather than as a keyword; + * matching on the label alone would refuse every `let` the linter introduced. + */ +function isBlockScopedKeyword(token: DocumentToken): boolean { + return token.label === 'const' || (token.label === 'name' && token.text === 'let') +} + +/** Whether the generated stream reads `.` where the baseline read `expected`. */ +function isQualified( + after: DocumentToken[], + right: number, + expected: DocumentToken, + qualifier: string +): boolean { + return ( + after[right]?.label === 'name' && + after[right]?.text === qualifier && + after[right + 1]?.label === '.' && + after[right + 2]?.label === expected.label && + after[right + 2]?.text === expected.text + ) +} + +/** + * The hand-written script out of the whole document, which is the part C7.1 moves. + * + * Read by locating the generated engine rather than by an index into the text, so a slice added + * above or below it does not silently shift what gets compared. + */ +export function readTerminalDocumentScript(document: string, engineJs: string): string { + const opener = `` + const start = document.indexOf(opener) + if (start === -1) { + throw new Error('the document does not carry the generated engine script') + } + const scriptStart = document.indexOf('') + if (scriptStart === -1 || scriptEnd <= scriptStart) { + throw new Error('the document does not carry a hand-written script after the engine') + } + return document.slice(scriptStart + ' + + + + +
+
+
+
+
+
+
+
+ + +
+
+ + + + \ No newline at end of file diff --git a/mobile/src/terminal/terminal-document-identity.test.ts b/mobile/src/terminal/terminal-document-identity.test.ts new file mode 100644 index 00000000000..0f4bf152bd7 --- /dev/null +++ b/mobile/src/terminal/terminal-document-identity.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + ENGINE_CSS_PLACEHOLDER, + ENGINE_JS_PLACEHOLDER, + TERMINAL_DOCUMENT_FIXTURE_PATH, + terminalDocumentFixture +} from '../../scripts/build-terminal-document-fixture.mjs' +import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' +import { XTERM_HTML } from './terminal-webview-html' + +/** + * The emitted WebView document, byte for byte, against a committed copy of itself. + * + * `terminal-webview-payload-hash.test.ts` pins the same bytes as a digest, which answers whether + * the document moved. This answers where: the whole document is one assertion, so a slice that + * gained a character, lost an indent or changed order arrives as a diff of the line rather than as + * two hexadecimal strings. Both are kept — the digest also covers the generated engine, which this + * fixture deliberately does not. + * + * C7.1 moves the document's hand-written script into modules the web page can import, and a + * generator rebuilds the document from them. This is the instrument that says the native screen + * kept the document it had. Regenerate the fixture with + * `node scripts/build-terminal-document-fixture.mjs` only when the emitted document was meant to + * change; the diff in that commit is the evidence, and reviewing it is the point. + */ +const fixture = readFileSync(TERMINAL_DOCUMENT_FIXTURE_PATH, 'utf8') + +describe('the terminal WebView document', () => { + it('is byte for byte the document the fixture holds', () => { + // Rebuilt through the script's own substitution rather than a second copy of it: a fixture + // written by a different rule than the one that reads it agrees with itself and with nothing. + expect(terminalDocumentFixture(XTERM_HTML, XTERM_ENGINE_JS, XTERM_ENGINE_CSS)).toBe(fixture) + }) + + it('holds the generated engine as placeholders, so an xterm bump is not a diff here', () => { + // Without this the fixture could lose a placeholder — inlining the engine, or dropping the + // section entirely — and the assertion above would still pass against whatever it became. + for (const placeholder of [ENGINE_JS_PLACEHOLDER, ENGINE_CSS_PLACEHOLDER]) { + expect(fixture.split(placeholder)).toHaveLength(2) + } + expect(fixture).not.toContain(XTERM_ENGINE_JS) + expect(fixture).not.toContain(XTERM_ENGINE_CSS) + }) + + it('is the whole document once the engine is put back', () => { + // The placeholder round trip, which is what makes the first case a claim about the document + // and not only about the hand-written part of it. + const restored = fixture + .replace(ENGINE_JS_PLACEHOLDER, () => XTERM_ENGINE_JS) + .replace(ENGINE_CSS_PLACEHOLDER, () => XTERM_ENGINE_CSS) + expect(restored).toBe(XTERM_HTML) + }) +}) diff --git a/mobile/src/terminal/terminal-document-pre-flip-script.txt b/mobile/src/terminal/terminal-document-pre-flip-script.txt new file mode 100644 index 00000000000..1252cd0ac6b --- /dev/null +++ b/mobile/src/terminal/terminal-document-pre-flip-script.txt @@ -0,0 +1,2758 @@ + +(function() { + var surface = document.getElementById('terminal-surface'); + var ESC = String.fromCharCode(27); + var C1_CSI = String.fromCharCode(155); + var CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa); + var TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e); + var EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f); + var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g'); + var statusDotPendingSelector = false; + var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; + var term = null; + var terminalDataRepliesEnabled = false; + + function resetTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = false; + } + + function resumeTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = true; + } + + function forwardTerminalDataReply(data) { + if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data }); + } + + function enqueueTerminalDataReplyBoundary(gen) { + enqueueWriteBoundary(function() { + if (gen === terminalGeneration) terminalDataRepliesEnabled = true; + }); + } + + function attachTerminalQueryReplyBridge(term, gen) { + // Why: parser replies require stdin enabled, but mobile input is owned by + // native controls. Keep xterm's textarea inert for touch/hardware keys. + try { + term.attachCustomKeyEventHandler(function() { return false; }); + if (term.textarea) { + term.textarea.readOnly = true; + term.textarea.tabIndex = -1; + term.textarea.setAttribute('inputmode', 'none'); + } + } catch (e) {} + try { + termObserverDisposables.push(term.onData(function(data) { + forwardTerminalDataReply(data); + })); + } catch (e) {} + // Why: live output can queue before initial replay finishes. Enable replies + // at the replay boundary so those live queries are answered, never replayed ones. + enqueueTerminalDataReplyBoundary(gen); + } + + + // Why: phone-fit startup can issue several init() calls before xterm finishes + // replaying. Track the last painted surface separately from its replacement. + var committedTerm = null; + var committedSurface = surface; + var pendingTerm = null; + var pendingSurface = null; + + 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) { + try { pendingSurface.remove(); } catch (e) {} + if (pendingTerm) try { pendingTerm.dispose(); } catch (e) {} + pendingSurface = null; + pendingTerm = null; + } + var swap = { + oldTerm: committedTerm, + oldSurface: committedSurface, + nextSurface: document.createElement('div') + }; + disposeTermObservers(); + swap.nextSurface.id = 'terminal-surface'; + swap.nextSurface.style.visibility = 'hidden'; + swap.nextSurface.style.position = 'absolute'; + swap.nextSurface.style.left = '0'; + swap.nextSurface.style.top = '0'; + document.getElementById('terminal-container').appendChild(swap.nextSurface); + surface = swap.nextSurface; + pendingSurface = swap.nextSurface; + attachSurfaceEventHandlers(surface); + swap.oldSurface.removeAttribute('id'); + return swap; + } + + function commitTerminalSurfaceSwap(swap, nextTerm) { + swap.nextSurface.style.visibility = 'visible'; + swap.nextSurface.style.position = ''; + swap.nextSurface.style.left = ''; + swap.nextSurface.style.top = ''; + swap.oldSurface.remove(); + if (swap.oldTerm) swap.oldTerm.dispose(); + committedTerm = nextTerm; + committedSurface = swap.nextSurface; + pendingTerm = null; + pendingSurface = null; + } + + var scrollIndicator = document.getElementById('scroll-indicator'); + var scrollThumb = document.getElementById('scroll-thumb'); + var scrollIndicatorHideTimer = null; + var writeQueue = []; + var writeQueueHead = 0; + var writesDraining = false; + var afterDrainCallbacks = []; + var termObserverDisposables = []; + var ready = false; + // 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. + var everReady = false; + var currentScale = 1; + // Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a + // gesture only; it resets to 1 on release. The persistent "text size" is the + // real xterm fontSize (currentTextScale × BASE_FONT_PX), so changing it + // reflows the grid: a bigger cell means fewer columns fit, and RN re-measures + // and resizes the PTY (terminal.updateViewport) so the shell rewraps to the + // new width. A finished pinch snaps to the nearest preset and reports it to RN. + var userScale = 1; + var BASE_FONT_PX = 13; + var MIN_FONT_PX = 6; + var MIN_FIT_COLS = 20; + var currentTextScale = 1; + var TEXT_SCALE_PRESETS = [0.5,0.75,1,1.25,1.5,2]; + var MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0]; + var MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1]; + function snapToTextScalePreset(value) { + var best = TEXT_SCALE_PRESETS[0], bestDelta = Infinity; + for (var i = 0; i < TEXT_SCALE_PRESETS.length; i++) { + var delta = Math.abs(TEXT_SCALE_PRESETS[i] - value); + if (delta < bestDelta) { bestDelta = delta; best = TEXT_SCALE_PRESETS[i]; } + } + return best; + } + function fontPxForScale(scale) { + return Math.max(MIN_FONT_PX, Math.round(BASE_FONT_PX * scale)); + } + function isIOSWebView() { + if (/iP(ad|hone|od)/.test(navigator.userAgent)) return true; + return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1; + } + // Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can + // fall to a non-monospace face; lead with the ui-monospace generic to avoid that. + var TERMINAL_FONT_FALLBACKS = '"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace'; + var terminalFontFamily = (isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS; + // Why: change the real font size, then resize the grid to fit the viewport at + // the new cell metrics so the text shows at its true size immediately. RN's + // refit (measure → updateViewport) then makes the server reflow the PTY to the + // same column count so the shell rewraps. cell metrics update on the frame + // after fontSize changes, so the resize/fit is deferred one rAF. + function applyTextScale(scale) { + currentTextScale = scale; + if (!term) return; + var px = fontPxForScale(scale); + if (term.options.fontSize === px) return; + term.options.fontSize = px; + requestAnimationFrame(function() { + if (!term) return; + var cellW = getCellWidth(); + var cellH = getCellHeight(); + if (cellW > 0 && cellH > 0) { + var cols = Math.floor(window.innerWidth / cellW); + if (cols < MIN_FIT_COLS) return; + var rows = Math.max(8, Math.floor(window.innerHeight / cellH)); + term.resize(cols, rows); + emitKeyboardAvoidanceMetrics(); + } + applyFitScale('text-scale'); + }); + } + var panX = 0, panY = 0; + var smoothScrollOffsetY = 0; + var pendingNormalScrollDeltaY = 0; + var normalScrollFrameId = null; + var initRows = 24; + var terminalGeneration = 0; + var 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"}; + var terminalThemeInput = null; + var terminalTheme = defaultTheme; + var terminalMinimumContrastRatio = 3; + var webglAddon = null; + var webglRecoveryTimer = null; + var activeAltScreenSnapshot = false; + var trackedMouseTrackingMode = 'none'; + var sgrMouseMode = false; + var sgrMousePixelsMode = false; + var initialOscLinks = [], initialOscLinkRowOffset = 0; + var initialOscLinkEvictionReady = false; + var mouseModeScanTail = ''; + var handledMessageIds = []; + // Why: after init() the initial scrollback applyFitScale may have run + // against an empty buffer (or one without the widest line yet). Re-fit + // once when the first live data chunk arrives so a wider line that pushes + // scrollWidth past the previously-measured value gets re-scaled to fit. + var firstDataPending = false; + + // Diagnostic logger — bridges WebView console.log to RN via postMessage. + // Tag with [fit] so it's easy to filter in the Expo/Metro logs. + function flog(tag, payload) { + try { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'log', tag: '[fit]' + tag, payload: payload + })); + } + } catch (e) {} + } + + function getCellWidth() { + if (!term || !term._core) return 0; + var core = term._core; + if (core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.width || 0; + } + return 0; + } + + // Why: width measurement strategy. + // 1. Prefer cellWidth × term.cols — this is what xterm's renderer uses + // to lay out and is independent of buffer content. It's the "logical + // width" of the terminal grid. + // 2. Fall back to term.element.scrollWidth — the actual rendered DOM + // width — only when cellWidth isn't available yet (renderer not + // initialized). This is content-dependent (reflects widest row), + // but better than nothing. + // 3. If both are 0, return 1 (no scale change). The retry loop in + // applyFitScale will keep trying until one is positive. + function computeFitScale() { + if (!term) return 1; + var cellW = getCellWidth(); + var termWidth = cellW > 0 ? cellW * term.cols : (term.element ? term.element.scrollWidth : 0); + if (termWidth <= 0) return 1; + var vpWidth = window.innerWidth; + return Math.min(1, vpWidth / termWidth); + } + + function getTotalScale() { return currentScale * userScale; } + + function updateTransform() { + surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')'; + updateScrollIndicator(false); + if (selMode === 'select') repositionOverlay(); + } + + function updateScrollIndicator(reveal) { + if (!scrollIndicator || !scrollThumb || !term || !term.buffer || !term.buffer.active) return; + var buffer = term.buffer.active; + var maxViewportY = buffer.baseY || 0; + if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) { + scrollIndicator.classList.remove('visible'); + return; + } + var trackHeight = Math.max(0, window.innerHeight - 8); + var totalRows = maxViewportY + (term.rows || 0); + if (trackHeight <= 0 || totalRows <= 0) return; + var thumbHeight = Math.max(24, trackHeight * (term.rows || 0) / totalRows); + var maxTop = Math.max(0, trackHeight - thumbHeight); + var top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0; + scrollThumb.style.height = thumbHeight + 'px'; + scrollThumb.style.transform = 'translateY(' + top + 'px)'; + if (!reveal) return; + scrollIndicator.classList.add('visible'); + if (scrollIndicatorHideTimer) clearTimeout(scrollIndicatorHideTimer); + scrollIndicatorHideTimer = setTimeout(function() { + scrollIndicator.classList.remove('visible'); + scrollIndicatorHideTimer = null; + }, 550); + } + + + var DARK_BG_MIN_CONTRAST = 3; + var LIGHT_BG_MIN_CONTRAST = 4.5; + // Dark app surface a transparent terminal background composites over (matches desktop APP_SURFACE_COLORS.dark). + var CONTRAST_APP_SURFACE = { r: 10, g: 10, b: 10 }; + + function parseTerminalBackgroundRgba(value) { + if (typeof value !== 'string') return null; + var v = value.trim().toLowerCase(); + if (!v) return null; + if (v === 'black') return { r: 0, g: 0, b: 0, a: 1 }; + if (v === 'white') return { r: 255, g: 255, b: 255, a: 1 }; + if (v === 'transparent') return { r: 0, g: 0, b: 0, a: 0 }; + var hex = v.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); + if (hex) { + var h = hex[1]; + var ch; + if (h.length === 3 || h.length === 4) { + ch = h.split('').map(function (p) { return parseInt(p + p, 16); }); + } else { + ch = []; + for (var i = 0; i < h.length; i += 2) ch.push(parseInt(h.slice(i, i + 2), 16)); + } + return { r: ch[0], g: ch[1], b: ch[2], a: ch[3] === undefined ? 1 : ch[3] / 255 }; + } + var rgb = v.match(/^rgba?\(([^)]+)\)$/); + if (!rgb) return null; + var parts = rgb[1].indexOf(',') >= 0 ? rgb[1].split(',') : rgb[1].split(/[\s/]+/); + parts = parts.map(function (p) { return p.trim(); }).filter(function (p) { return p.length > 0; }); + if (parts.length < 3) return null; + var channel = function (p) { + var n = p.charAt(p.length - 1) === '%' ? (parseFloat(p) / 100) * 255 : parseFloat(p); + return isFinite(n) ? Math.min(255, Math.max(0, Math.round(n))) : null; + }; + var r = channel(parts[0]), g = channel(parts[1]), b = channel(parts[2]); + if (r === null || g === null || b === null) return null; + var a = 1; + if (parts[3] !== undefined) { + var raw = parts[3].charAt(parts[3].length - 1) === '%' ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]); + a = isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1; + } + return { r: r, g: g, b: b, a: a }; + } + + function terminalRelativeLuminance(rgb) { + var lin = function (c) { + var n = c / 255; + return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); + }; + return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b); + } + + function terminalContrastRatio(a, b) { + var la = terminalRelativeLuminance(a), lb = terminalRelativeLuminance(b); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); + } + + // Clamp an explicit desktop override to xterm's 1-21 range; null means "no usable override". + function normalizeTerminalContrastOverride(value) { + if (typeof value !== 'number' || !isFinite(value)) return null; + return Math.min(21, Math.max(1, value)); + } + + // Pick the xterm minimumContrastRatio floor from the composed terminal background. + // Unparseable input defaults to the dark floor so agent output never stays invisible. + function resolveTerminalContrastFloor(background) { + var color = parseTerminalBackgroundRgba(background); + if (!color) return DARK_BG_MIN_CONTRAST; + var composited = color.a < 1 + ? { + r: Math.round(color.r * color.a + CONTRAST_APP_SURFACE.r * (1 - color.a)), + g: Math.round(color.g * color.a + CONTRAST_APP_SURFACE.g * (1 - color.a)), + b: Math.round(color.b * color.a + CONTRAST_APP_SURFACE.b * (1 - color.a)) + } + : color; + var isLight = terminalContrastRatio({ r: 0, g: 0, b: 0 }, composited) >= + terminalContrastRatio({ r: 255, g: 255, b: 255 }, composited); + return isLight ? LIGHT_BG_MIN_CONTRAST : DARK_BG_MIN_CONTRAST; + } + + function normalizeTerminalTheme(input) { + var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object' + ? input.theme + : null; + if (!source) return defaultTheme; + var next = {}; + var keys = Object.keys(defaultTheme); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + if (typeof source[key] === 'string') next[key] = source[key]; + } + return Object.assign({}, defaultTheme, next); + } + + function applyTerminalTheme(input) { + terminalThemeInput = input; + terminalTheme = normalizeTerminalTheme(input); + var background = terminalTheme.background || '#1a1b26'; + document.documentElement.style.background = background; + document.body.style.background = background; + // Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754); + // an older host omits the field and the luminance gate stays authoritative. + var publishedFloor = normalizeTerminalContrastOverride( + input && typeof input === 'object' ? input.minimumContrastRatio : undefined + ); + terminalMinimumContrastRatio = + publishedFloor === null ? resolveTerminalContrastFloor(background) : publishedFloor; + if (term) { + term.options.theme = terminalTheme; + term.options.minimumContrastRatio = terminalMinimumContrastRatio; + } + } + + + function getCellHeight() { + if (!term || !term._core) return 15; + var core = term._core; + if (core._renderService && core._renderService.dimensions) { + return core._renderService.dimensions.css.cell.height || 15; + } + return 15; + } + + // Why: clamp pan so the terminal content always covers the viewport + // when zoomed in. When content is smaller than viewport in a + // dimension, pin to top-left (no floating in the middle). + function clampPan() { + if (!term || !term.element) return; + var ts = getTotalScale(); + var cw = term.element.scrollWidth * ts; + var ch = term.element.scrollHeight * ts; + var vpW = window.innerWidth; + var vpH = window.innerHeight; + if (cw > vpW) { + panX = Math.min(0, Math.max(vpW - cw, panX)); + } else { + panX = 0; + } + if (ch > vpH) { + panY = Math.min(0, Math.max(vpH - ch, panY)); + } else { + panY = 0; + } + } + + // Why: intentional no-op. Mobile replays a live PTY snapshot then applies + // live cursor-relative chunks from that same PTY; resizing only the WebView + // xterm changes cursor coordinates and makes TUI repaint chunks duplicate or + // overlap. Kept as a no-op so its call sites stay legible. + function adjustRowsForViewport() {} + + // Why: cold-start fit. After init() opens xterm, the renderer needs + // several frames before cell dimensions are computed. Reading too early + // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM + // not laid out), and computeFitScale returns 1 → no zoom. + // + // Gate: cellWidth × cols is the canonical "logical width" of the grid + // and reflects xterm's layout decision, independent of buffer content. + // We commit when cellWidth becomes positive (renderer ready). Fallback: + // if cellWidth never becomes available, gate on stable positive + // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) + // so a backgrounded WebView never spins forever. + var FIT_RETRY_MAX_FRAMES = 60; + var fitRetryToken = 0; + function applyFitScale(reason) { + if (!term || !term.element) return; + var token = ++fitRetryToken; + var attempts = 0; + var lastScrollWidth = -1; + function attempt() { + if (token !== fitRetryToken) return; + if (!term || !term.element) return; + attempts++; + var cellW = getCellWidth(); + if (cellW > 0 && term.cols > 0) { + commitFitScale(reason, attempts, 'cellW'); + return; + } + var w = term.element.scrollWidth; + if (w > 0 && w === lastScrollWidth) { + commitFitScale(reason, attempts, 'stableSW'); + return; + } + lastScrollWidth = w; + if (attempts >= FIT_RETRY_MAX_FRAMES) { + flog('commit-timeout', { + reason: reason, + attempts: attempts, + cellW: cellW, + scrollWidth: w, + cols: term.cols + }); + commitFitScale(reason, attempts, 'timeout'); + return; + } + requestAnimationFrame(attempt); + } + requestAnimationFrame(attempt); + } + + function commitFitScale(reason, attempts, gate) { + if (!term || !term.element) return; + var preSnapScale = computeFitScale(); + currentScale = preSnapScale; + // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar + // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents + // a second applyFitScale from observing a "no-op needed" state. + if (currentScale >= 0.95) currentScale = 1; + userScale = 1; + panX = 0; + panY = 0; + smoothScrollOffsetY = 0; + updateTransform(); + adjustRowsForViewport(); + + var cellW = getCellWidth(); + var sw = term.element.scrollWidth; + var vpW = window.innerWidth; + var expectedW = cellW * term.cols; + var suspect = + currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom + if (suspect) { + flog('commit-SUSPECT', { + reason: reason, + attempts: attempts, + gate: gate, + preSnapScale: preSnapScale, + finalScale: currentScale, + cellW: cellW, + cols: term.cols, + expectedW: expectedW, + scrollWidth: sw, + vpWidth: vpW + }); + } + repositionOverlay(); + } + + function isAltScreenActive(data) { + if (typeof data !== 'string') return false; + var on = data.lastIndexOf(ESC + '[?1049h'); + var off = data.lastIndexOf(ESC + '[?1049l'); + return on !== -1 && on > off; + } + + function normalizeInitialData(data) { + if (!isAltScreenActive(data)) return data; + var on = data.lastIndexOf(ESC + '[?1049h'); + // Why: SerializeAddon can include normal-buffer scrollback before the + // active alternate-screen snapshot. Replaying both into a fresh mobile + // xterm duplicates TUI frames and can flatten SGR attributes. + return on > 0 ? data.slice(on) : data; + } + + function updateMouseModeFromData(data) { + if (typeof data !== 'string' || data.length === 0) return; + var input = mouseModeScanTail + data; + mouseModeScanTail = extractMouseModeScanTail(input); + var re = new RegExp(ESC + 'c|' + ESC + '\\[\\?([0-9;]+)([hl])|' + C1_CSI + '\\?([0-9;]+)([hl])', 'g'); + var match; + while ((match = re.exec(input)) !== null) { + if (match[0] === ESC + 'c') { + trackedMouseTrackingMode = 'none'; + sgrMouseMode = false; + sgrMousePixelsMode = false; + continue; + } + var enabled = (match[2] || match[4]) === 'h'; + var params = (match[1] || match[3]).split(';'); + for (var i = 0; i < params.length; i++) { + if (params[i] === '') continue; + var param = Number(params[i]); + if (!Number.isInteger(param)) continue; + if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none'; + if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none'; + if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none'; + if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none'; + if (param === 1006) { + sgrMouseMode = enabled; + sgrMousePixelsMode = false; + } + if (param === 1016) { + sgrMouseMode = false; + sgrMousePixelsMode = enabled; + } + } + } + } + + function resetWriteQueue() { + writeQueue = []; + writeQueueHead = 0; + } + + function isStatusDotPresentationSelector(value) { + return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR; + } + + function endsWithStatusDotPresentationSequence(data) { + var i = data.length - 1; + while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--; + return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT; + } + + // Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph. + function normalizeStatusDotPresentation(data) { + if (typeof data !== 'string' || data.length === 0) return data; + if (statusDotPendingSelector) { + statusDotPendingSelector = false; + var strippedPendingSelectors = false; + while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1); + strippedPendingSelectors = data.length === 0; + if (strippedPendingSelectors) { + statusDotPendingSelector = true; + return ''; + } + } + var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR); + statusDotPendingSelector = endsWithStatusDotPresentationSequence(data); + return normalized; + } + + function enqueueWrite(data) { + writeQueue.push(normalizeStatusDotPresentation(data)); + } + + function enqueueWriteBoundary(callback) { + writeQueue.push(callback); + } + + function nextQueuedWrite() { + if (writeQueueHead >= writeQueue.length) { + resetWriteQueue(); + return undefined; + } + var next = writeQueue[writeQueueHead]; + writeQueue[writeQueueHead] = undefined; + writeQueueHead++; + // Why: high-throughput terminals can enqueue faster than xterm parses; + // compact consumed slots so drain work stays O(1) without retaining old chunks. + if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { + writeQueue = writeQueue.slice(writeQueueHead); + writeQueueHead = 0; + } + return next; + } + + function disposeTermObservers() { + var disposables = termObserverDisposables; + termObserverDisposables = []; + for (var i = 0; i < disposables.length; i++) { + try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} + } + } + + function extractMouseModeScanTail(input) { + var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); + if (start === -1) return ''; + var tail = input.slice(start); + // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. + // Keep parser state far beyond normal mode lists while still bounding memory. + if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return ''; + if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail; + if (tail.indexOf(ESC + '[?') === 0) { + return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''; + } + if (tail.indexOf(C1_CSI + '?') === 0) { + return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''; + } + return ''; + } + + function pumpWrites(gen) { + if (!ready || !term || writesDraining || gen !== terminalGeneration) return; + var next = nextQueuedWrite(); + if (typeof next !== 'string') { + if (typeof next === 'function') return next(), pumpWrites(gen); + var callbacks = afterDrainCallbacks; + afterDrainCallbacks = []; + for (var i = 0; i < callbacks.length; i++) callbacks[i](); + return; + } + writesDraining = true; + // Why: xterm.write() parses asynchronously. Row adjustment/resizing must + // wait until replayed SGR attributes have landed in the buffer. + term.write(next, function() { + if (gen !== terminalGeneration) return; + writesDraining = false; + pumpWrites(gen); + }); + } + + function afterWritesDrained(callback) { + afterDrainCallbacks.push(callback); + pumpWrites(terminalGeneration); + } + + + function refreshTerminalSurface() { + if (!term) return; + try { term.refresh(0, Math.max(0, term.rows - 1)); } catch (e) {} + } + + function cancelWebglContextRecovery() { + if (!webglRecoveryTimer) return; + clearTimeout(webglRecoveryTimer); + webglRecoveryTimer = null; + } + + function attachWebglAddon(allowRecovery) { + if (!term || !window.WebglAddon || !window.WebglAddon.WebglAddon) return false; + var addon = null; + try { + addon = new window.WebglAddon.WebglAddon(); + webglAddon = addon; + if (addon.onContextLoss) addon.onContextLoss(function() { + if (webglAddon !== addon) return; + flog('webgl-context-loss', { retry: allowRecovery }); + webglAddon = null; + try { addon.dispose(); } catch (e) {} + refreshTerminalSurface(); + if (!allowRecovery) return; + // Why: one delayed retry handles transient iOS context loss without + // entering a GPU crash loop; a second loss stays on the DOM renderer. + cancelWebglContextRecovery(); + var recoveryTerm = term; + var recoveryGeneration = terminalGeneration; + webglRecoveryTimer = setTimeout(function() { + webglRecoveryTimer = null; + if (term !== recoveryTerm || terminalGeneration !== recoveryGeneration) return; + attachWebglAddon(false); + }, 100); + }); + term.loadAddon(addon); + if (!allowRecovery) { + try { if (addon.clearTextureAtlas) addon.clearTextureAtlas(); } catch (e) {} + refreshTerminalSurface(); + } + return true; + } catch (e) { + flog('webgl-attach-failed', { retry: !allowRecovery, message: String(e) }); + if (webglAddon === addon) webglAddon = null; + try { if (addon) addon.dispose(); } catch (disposeError) {} + refreshTerminalSurface(); + return false; + } + } + + document.addEventListener('visibilitychange', function() { + if (document.visibilityState !== 'visible') return; + // Why: iOS may restore the xterm model while discarding GPU pixels/theme + // paint state, so visibility must rebuild the atlas and repaint every row. + applyTerminalTheme(terminalThemeInput); + try { if (webglAddon && webglAddon.clearTextureAtlas) webglAddon.clearTextureAtlas(); } catch (e) {} + refreshTerminalSurface(); + }); + + + function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) { + if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale; + // Why: a width-reflow re-stream rewraps the same content at new cols. + // Distance-from-bottom (rows) is the only stable anchor across reflow, + // since line counts and cell positions change. null = stay pinned to bottom. + var prevB = preserveScroll && term && term.buffer && term.buffer.active ? term.buffer.active : null; + var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1; + terminalGeneration++; + var gen = terminalGeneration; + // Why: snapshot replay can contain old queries whose replies must never + // re-enter the live PTY. Each replacement terminal earns authority anew. + resetTerminalDataReplyAuthority(); + cancelWebglContextRecovery(); + webglAddon = null; + ready = false; + resetWriteQueue(); + statusDotPendingSelector = false; + writesDraining = false; + afterDrainCallbacks = []; + initRows = rows || 24; + firstDataPending = true; + smoothScrollOffsetY = 0; + wheelAccumDeltaY = 0; + mouseModeScanTail = ''; + trackedMouseTrackingMode = 'none'; + sgrMouseMode = false; + sgrMousePixelsMode = false; + lastEmittedModes = { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false + }; + var replayData = normalizeInitialData(initialData); + // Why: normalizeInitialData can discard pre-alt-screen bytes. Keep the + // mirrored modes aligned with exactly what this mobile xterm replays. + updateMouseModeFromData(replayData); + activeAltScreenSnapshot = isAltScreenActive(replayData); + initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : []; + initialOscLinkRowOffset = 0; + initialOscLinkEvictionReady = false; + var surfaceSwap = beginTerminalSurfaceSwap(); + var nextSurface = surfaceSwap.nextSurface; + + applyTerminalTheme(nextTheme); + term = new Terminal({ + cols: cols || 80, + rows: rows || 24, + theme: terminalTheme, + minimumContrastRatio: terminalMinimumContrastRatio, + fontFamily: terminalFontFamily, + fontSize: fontPxForScale(currentTextScale), + fontWeight: '300', + fontWeightBold: '500', + scrollback: 5000, + // Why: xterm suppresses parser-generated query replies when disableStdin + // is true. Native accepts only validated reply grammars from onData. + disableStdin: false, + cursorBlink: false, + cursorStyle: "bar", + // Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret. + showCursorImmediately: true, + // A full inactive cell remains visible under the terminal's phone-fit scale. + cursorInactiveStyle: "block", + convertEol: false, + allowProposedApi: true + }); + var nextTerm = term; + pendingTerm = nextTerm; + term.open(surface); + attachWebglAddon(true); + if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {} + if (typeof replayData === 'string' && replayData.length > 0) { + // Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output. + enqueueWrite(ESC + '[0m' + replayData); + } + + // Why: reset eviction tracking + attach observers for the new term. + resetEvictionCounter(); + cancelSelect(); + attachTermObservers(); + attachTerminalQueryReplyBridge(term, gen); + + requestAnimationFrame(function() { + if (gen !== terminalGeneration) return; + ready = true; + everReady = true; + afterWritesDrained(function() { + if (gen !== terminalGeneration) return; + commitTerminalSurfaceSwap(surfaceSwap, nextTerm); + // Why: restore the reader's place after the rewrapped buffer replays. + // Replay lands at bottom, so only act when they were scrolled up (rows>0). + if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) { + try { term.scrollToLine(Math.max(0, (term.buffer.active.baseY || 0) - scrollAnchorRows)); } catch (e) {} + } + captureInitialOscLinkTexts(); + initialOscLinkRowOffset = 0; + initialOscLinkEvictionReady = true; + applyFitScale('init-replay'); + notify({ type: 'ready', cols: cols, rows: rows }); + }); + }); + } + + function write(data) { + updateMouseModeFromData(data); + enqueueWrite(data); + pumpWrites(terminalGeneration); + // Why: first live data chunk after init may widen the buffer past + // what the post-replay applyFitScale measured. Re-fit once after this + // chunk drains to catch the wider line. Subsequent chunks don't re-fit + // (the user's manual zoom is sticky after that). + if (firstDataPending) { + firstDataPending = false; + var gen = terminalGeneration; + afterWritesDrained(function() { + if (gen !== terminalGeneration) return; + applyFitScale('first-data'); + }); + } + } + + function resize(cols, rows) { + if (!term) return; + initRows = rows || initRows; + term.resize(cols || term.cols, rows || term.rows); + emitKeyboardAvoidanceMetrics(); + applyFitScale('resize-msg'); + notify({ type: 'ready', cols: cols, rows: rows }); + } + + // reflow(): see terminal-webview-reflow-injected.ts (extracted for max-lines). + + // Why: rewrap the local xterm buffer (scrollback included) to a new width + // after a server PTY reflow. Skip the alternate screen: those snapshots are + // fully repainted by the PTY and a local resize there can drop SGR attributes + // (see init's alt-screen handling), which shows as white text. + function reflow(cols, rows) { + if (!term || isAlternateBufferActive()) return; + var nextCols = cols || term.cols; + var nextRows = rows || term.rows; + if (nextCols === term.cols && nextRows === term.rows) return; + var buffer = term.buffer.active; + // Why: anchor reflow on whether the user was pinned to the live bottom so + // their scroll position survives the rewrap — if they were scrolled up, + // hold the same distance from the bottom; if at the bottom, stay there. + var wasAtBottom = buffer.viewportY >= buffer.baseY; + var distanceFromBottom = buffer.baseY - buffer.viewportY; + initRows = nextRows; + term.resize(nextCols, nextRows); + var rewrapped = term.buffer.active; + if (wasAtBottom) { + term.scrollToBottom(); + } else { + term.scrollLines(rewrapped.baseY - distanceFromBottom - rewrapped.viewportY); + } + applyFitScale('reflow-msg'); + updateScrollIndicator(false); + emitKeyboardAvoidanceMetrics(); + } + + + function notify(msg) { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify(msg)); + } + } + + function engineErrorText(err) { + if (!err) return ''; + if (typeof err === 'string') return err; + if (err && typeof err.message === 'string') return err.message; + try { return String(err); } catch (e) { return ''; } + } + + function chromeVersionText() { + var match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\/([0-9.]+)/); + return match ? 'Chrome ' + match[1] : 'Chrome version unknown'; + } + + var nonFatalErrorNotifies = 0; + + function reportEngineError(context, err, fatal) { + var isFatal = fatal === undefined ? !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) return; + } + var parts = [context]; + var errText = engineErrorText(err); + if (errText) parts.push(errText); + if (window.__engineErrors && window.__engineErrors.length) { + parts.push('captured: ' + window.__engineErrors.join(' | ')); + } + parts.push(chromeVersionText()); + notify({ + type: 'error', + fatal: isFatal, + message: parts.join(' - ') + }); + } + + window.onerror = function(msg, source, line, column, err) { + if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg)); + reportEngineError('terminal runtime error', err || msg); + }; + + function measureFitDimensions(containerHeightPx, retriesLeft) { + if (typeof retriesLeft !== 'number') retriesLeft = 30; + // Why: init and measure are posted back-to-back from React, but + // init has an async rAF chain. A measure that runs synchronously + // after init can find term null, disposed, lacking element, or + // with cells size 0. Retry the whole gate for ~500ms. + var notReady = !term || !term.element; + var cellWidth = 0; + var cellHeight = 0; + if (!notReady) { + var core = term._core; + if (core && core._renderService && core._renderService.dimensions) { + cellWidth = core._renderService.dimensions.css.cell.width; + cellHeight = core._renderService.dimensions.css.cell.height; + } + } + if (notReady || cellWidth <= 0 || cellHeight <= 0) { + if (retriesLeft > 0) { + requestAnimationFrame(function() { + measureFitDimensions(containerHeightPx, retriesLeft - 1); + }); + return; + } + flog('measure-fail', { + notReady: notReady, + cellWidth: cellWidth, + cellHeight: cellHeight, + retriesLeft: retriesLeft + }); + notify({ type: 'measure-result', cols: null, rows: null }); + return; + } + var vpWidth = window.innerWidth; + // Why: prefer the container height passed from React Native over + // window.innerHeight. The RN layout system knows the exact pixel + // height of the terminal frame after the accessory/input bars are + // subtracted, whereas innerHeight can overstate the visible area + // due to layout timing or safe-area insets. + var vpHeight = (typeof containerHeightPx === 'number' && containerHeightPx > 0) + ? containerHeightPx + : window.innerHeight; + var cols = Math.floor(vpWidth / cellWidth); + if (cols < MIN_FIT_COLS) { + flog('measure-skip-small-width', { + vpWidth: vpWidth, + cellWidth: cellWidth, + cols: cols + }); + notify({ type: 'measure-result', cols: null, rows: null }); + return; + } + // Why: the rows we report become the PTY's actual row count after the + // server fits to viewport, and xterm renders exactly that many lines + // anchored top-left of the WebView. Subtracting rows here would leave + // dead xterm-background space at the bottom of the container and make + // the last PTY rows visually appear above an "invisible line." Any + // safety margin between the prompt and the accessory bar must come + // from RN layout (terminalFrame's flex bounds), not from undersizing + // the PTY. + var rows = Math.max(8, Math.floor(vpHeight / cellHeight)); + notify({ type: 'measure-result', cols: cols, rows: rows }); + } + + function handleMsg(msg) { + if (typeof msg.id === 'number') { + if (handledMessageIds.indexOf(msg.id) !== -1) return; + handledMessageIds.push(msg.id); + if (handledMessageIds.length > 256) handledMessageIds.shift(); + } + if (msg.type === 'ping') { + notify({ type: 'pong', pingId: msg.id }); + } else if (msg.type === 'init') { + init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll, msg.oscLinks); + } else if (msg.type === 'set-font-scale') { + // Why: ignore RN echoing back the value a pinch just set (msg.fontScale === + // currentTextScale) so the post-pinch state isn't reset; only apply changes. + if (typeof msg.fontScale === 'number' && msg.fontScale > 0 && msg.fontScale !== currentTextScale) { + userScale = 1; + panX = 0; + panY = 0; + applyTextScale(msg.fontScale); + } + } else if (msg.type === 'resize') { + resize(msg.cols, msg.rows); + } else if (msg.type === 'reflow') { reflow(msg.cols, msg.rows); + } else if (msg.type === 'write') { + write(msg.data); + } else if (msg.type === 'clear') { + terminalGeneration++; + resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary. + statusDotPendingSelector = false; + afterDrainCallbacks = []; + writesDraining = false; + mouseModeScanTail = ''; + trackedMouseTrackingMode = 'none'; + sgrMouseMode = false; + sgrMousePixelsMode = false; + initialOscLinks = []; + initialOscLinkRowOffset = 0; + initialOscLinkEvictionReady = false; + if (term) { term.clear(); term.reset(); } + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + resetEvictionCounter(); + if (selMode === 'select') { + notify({ type: 'selection-evicted' }); + cancelSelect(); + } + } else if (msg.type === 'measure') { + measureFitDimensions(msg.containerHeight); + } else if (msg.type === 'reset-zoom') { + applyFitScale('reset-zoom-msg'); + } else if (msg.type === 'set-theme') { + applyTerminalTheme(msg.terminalTheme); + } else if (msg.type === 'cancel-select') { + if (selMode === 'select') cancelSelect(); + } else if (msg.type === 'do-select-all') { + if (term) { + try { + term.selectAll(); + var b = term.buffer.active; + if (selMode !== 'select') { + selMode = 'select'; + selectionOverlay.classList.add('active'); + notify({ type: 'set-select-mode', enabled: true }); + } + sel = { + anchor: { col: 0, row: 0 }, + focus: { col: term.cols - 1, row: b.length - 1 }, + activeHandle: null + }; + repositionOverlay(); + } catch (e) {} + } + } + } + + // ============================================================ + // SELECTION MODE (long-press → handles → Copy) + // ============================================================ + var WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u; + var LONG_PRESS_MS = 500; + var LONG_PRESS_SLOP = 10; + // Why: a tap that opens a link/path must survive small finger jitter. The + // long-press slop (10px) only cancels the press-to-select timer; reusing it + // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale + // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap + // window so deliberate scrolls/pans still don't fire a tap. + var TAP_SLOP = 24; + var TAP_MAX_MS = 700; + var EDGE_SCROLL_PX = 40; + var EDGE_SCROLL_INTERVAL = 60; + + var selectionOverlay = document.getElementById('selection-overlay'); + var handleStart = document.getElementById('sel-handle-start'); + var handleEnd = document.getElementById('sel-handle-end'); + var selMenu = document.getElementById('sel-menu'); + var btnCopy = document.getElementById('sel-menu-copy'); + var btnSelAll = document.getElementById('sel-menu-all'); + + // mode: 'navigate' | 'select' + var selMode = 'navigate'; + var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } + var longPressTimer = null; + var longPressOrigin = null; // {x,y, identifier} + // Why: tap detection is tracked separately from the long-press timer so a + // small jitter that cancels the press-to-select timer does not also cancel + // 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). + var tapCandidate = null; + var edgeScrollTimer = null; + var edgeScrollDir = 0; + var edgeScrollClientX = 0; + var edgeScrollClientY = 0; + + // Eviction watchdog: linesEverWritten counts onLineFeed since last init. + // Once buffer is full, every onLineFeed evicts the top row in xterm and + // we mirror that by decrementing stored absolute rows. + var linesEverWritten = 0; + + function resetEvictionCounter() { linesEverWritten = 0; } + + function isBufferFull() { + if (!term) return false; + return linesEverWritten >= 5000 + (term.rows || 0); + } + + function checkEviction() { + if (selMode !== 'select' || !sel) return; + var oldest = Math.min(sel.anchor.row, sel.focus.row); + if (oldest < 0) { + notify({ type: 'selection-evicted' }); + cancelSelect(); + } + } + + function logFeedAndEvict() { + linesEverWritten++; + if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1; + if (selMode === 'select' && sel && isBufferFull()) { + sel.anchor.row -= 1; + sel.focus.row -= 1; + checkEviction(); + repositionOverlay(); + } + } + + function emitModesIfChanged() { + if (!term) return; + var bp = !!(term.modes && term.modes.bracketedPasteMode); + var alt = false; + var mouseTrackingMode = getMouseTrackingMode(); + try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} + if ( + bp !== lastEmittedModes.bracketedPasteMode || + alt !== lastEmittedModes.altScreen || + mouseTrackingMode !== lastEmittedModes.mouseTrackingMode || + sgrMouseMode !== lastEmittedModes.sgrMouseMode || + sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode + ) { + lastEmittedModes = { + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: sgrMouseMode, + sgrMousePixelsMode: sgrMousePixelsMode + }; + notify({ + type: 'modes', + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: sgrMouseMode, + sgrMousePixelsMode: sgrMousePixelsMode + }); + } + } + var lastEmittedModes = { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false + }; + + + function lineHasVisibleContent(line, cell) { + if (line.translateToString(true).trim().length > 0) return true; + if (!cell || !line.getCell) return false; + var limit = Math.min(term.cols || 0, line.length || 0); + for (var x = 0; x < limit; x++) { + var current = line.getCell(x, cell); + if (!current) continue; + if (!current.isBgDefault() || current.isInverse()) return true; + if (typeof current.isUnderline === 'function' && current.isUnderline()) return true; + if (typeof current.isStrikethrough === 'function' && current.isStrikethrough()) return true; + if (typeof current.isOverline === 'function' && current.isOverline()) return true; + } + return false; + } + + function computeContentBottomRow() { + if (!term || !term.buffer || !term.buffer.active) return 0; + var buffer = term.buffer.active; + var top = buffer.viewportY || 0; + var cell = buffer.getNullCell ? buffer.getNullCell() : null; + for (var y = (term.rows || 0) - 1; y >= 0; y--) { + try { + var line = buffer.getLine(top + y); + if (line && lineHasVisibleContent(line, cell)) return y; + } catch (e) {} + } + return 0; + } + + function emitKeyboardAvoidanceMetrics() { + if (!term) return; + var alt = false; + try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} + notify({ + type: 'keyboard-avoidance-metrics', + cursorY: term.buffer && term.buffer.active ? term.buffer.active.cursorY : 0, + contentBottomRow: alt ? 0 : computeContentBottomRow(), + rows: term.rows || 0, + altScreen: alt + }); + } + + + function attachTermObservers() { + if (!term) return; + disposeTermObservers(); + try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} + try { + termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); + } catch (e) {} + // Why: emit modes on every parsed write so RN's mirror stays current + // without round-trip; covers \x1b[?2004h/l and alt-screen toggles. + try { + if (term.onWriteParsed) { + termObserverDisposables.push(term.onWriteParsed(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + })); + } + } catch (e) {} + // Initial emit once buffer settles. + afterWritesDrained(function() { + emitModesIfChanged(); + emitKeyboardAvoidanceMetrics(); + }); + } + + function viewportToCell(clientX, clientY) { + if (!term) return null; + var cellW = getCellWidth(); + var cellH = getCellHeight(); + if (cellW <= 0 || cellH <= 0) return null; + var total = getTotalScale(); + if (total <= 0) total = 1; + var sx = (clientX - panX) / total; + var sy = (clientY - panY) / total; + var col = Math.floor(sx / cellW); + var viewportRow = Math.floor(sy / cellH); + if (col < 0) col = 0; + if (col > term.cols - 1) col = term.cols - 1; + if (viewportRow < 0) viewportRow = 0; + if (viewportRow > term.rows - 1) viewportRow = term.rows - 1; + var viewportY = term.buffer.active.viewportY; + return { col: col, row: viewportRow + viewportY }; + } + + + function viewportToMouseReportCell(clientX, clientY) { + if (!term) return null; + var cellW = getCellWidth(); + var cellH = getCellHeight(); + if (cellW <= 0 || cellH <= 0) return null; + if (typeof clientX !== 'number') clientX = window.innerWidth / 2; + if (typeof clientY !== 'number') clientY = window.innerHeight / 2; + var total = getTotalScale(); + if (total <= 0) total = 1; + var sx = (clientX - panX) / total; + var sy = (clientY - panY) / total; + var maxX = Math.max(0, term.cols * cellW - 1); + var maxY = Math.max(0, term.rows * cellH - 1); + if (sx < 0) sx = 0; + if (sx > maxX) sx = maxX; + if (sy < 0) sy = 0; + if (sy > maxY) sy = maxY; + var col = Math.floor(sx / cellW); + var row = Math.floor(sy / cellH); + if (col < 0) col = 0; + if (col > term.cols - 1) col = term.cols - 1; + if (row < 0) row = 0; + if (row > term.rows - 1) row = term.rows - 1; + return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }; + } + + + function isAlternateBufferActive() { + try { + return !!(term && term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'); + } catch (e) { + return false; + } + } + + function getMouseTrackingMode() { + try { + if (term && term.modes && typeof term.modes.mouseTrackingMode === 'string') { + var mode = term.modes.mouseTrackingMode; + if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') return mode; + return 'none'; + } + } catch (e) {} + if ( + trackedMouseTrackingMode === 'x10' || + trackedMouseTrackingMode === 'vt200' || + trackedMouseTrackingMode === 'drag' || + trackedMouseTrackingMode === 'any' + ) { + return trackedMouseTrackingMode; + } + return 'none'; + } + + function repeatSequence(sequence, count) { + var out = ''; + for (var i = 0; i < count; i++) out += sequence; + return out; + } + + function buildArrowScrollSequence(lines) { + var prefix = '['; + try { + if (term && term.modes && term.modes.applicationCursorKeysMode) prefix = 'O'; + } catch (e) {} + return ESC + prefix + (lines < 0 ? 'A' : 'B'); + } + + function buildMouseWheelSequence(lines, clientX, clientY) { + var cell = viewportToMouseReportCell(clientX, clientY); + if (!cell) return ''; + var eventCode = lines < 0 ? 64 : 65; + if (sgrMousePixelsMode) { + if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; + return ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M'; + } + if (sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + return ESC + '[<' + eventCode + ';' + sgrCol + ';' + sgrRow + 'M'; + } + // Why: xterm increments zero-based mouse cells before encoding reports. + var button = eventCode + 32; + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + // Why: non-SGR mouse bytes above ASCII are not preserved reliably through + // the mobile JSON/RPC string path. Fall back to keys for wide terminals. + if (button > 126 || col > 126 || row > 126) return ''; + return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function isSafeSgrMouseCoordinate(value) { + return Number.isInteger(value) && value >= 0 && value <= 9999; + } + + function buildMouseClickInput(clientX, clientY) { + var mouseTrackingMode = getMouseTrackingMode(); + if (!isClickMouseTrackingMode(mouseTrackingMode)) return ''; + var cell = viewportToMouseReportCell(clientX, clientY); + if (!cell) return ''; + if (sgrMousePixelsMode) { + // Why: xterm 1016 keeps SGR syntax but reports raw zero-based pixel positions. + var pixelX = cell.x; + var pixelY = cell.y; + if (!isSafeSgrMouseCoordinate(pixelX) || !isSafeSgrMouseCoordinate(pixelY)) return ''; + var pixelPress = ESC + '[<0;' + pixelX + ';' + pixelY + 'M'; + if (mouseTrackingMode === 'x10') return pixelPress; + return pixelPress + ESC + '[<0;' + pixelX + ';' + pixelY + 'm'; + } + if (sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'; + if (mouseTrackingMode === 'x10') return sgrPress; + return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'; + } + // Why: non-SGR click coordinates use printable ASCII bytes on the mobile + // bridge; unsafe wide-terminal cells must not turn into corrupted input. + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + if (col > 126 || row > 126) return ''; + var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row); + if (mouseTrackingMode === 'x10') return press; + return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function isClickMouseTrackingMode(mode) { + return mode !== 'none'; + } + + function isWheelMouseTrackingMode(mode) { + return mode !== 'none' && mode !== 'x10'; + } + + function shouldRouteScrollToTerminalInput() { + return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive(); + } + + function buildMouseWheelScrollInput(lines, clientX, clientY) { + var count = Math.min(Math.abs(lines), 32); + if (count === 0) return ''; + var sequence = buildMouseWheelSequence(lines, clientX, clientY); + if (!sequence) return ''; + return repeatSequence(sequence, count); + } + + function buildTuiScrollInput(lines, clientX, clientY) { + var count = Math.min(Math.abs(lines), 32); + if (count === 0) return ''; + var mouseTrackingMode = getMouseTrackingMode(); + var sequence = ''; + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + sequence = buildMouseWheelSequence(lines, clientX, clientY); + } + if (!sequence) sequence = buildArrowScrollSequence(lines); + return repeatSequence(sequence, count); + } + + function routeScrollLines(lines, clientX, clientY) { + if (!term || lines === 0) return; + var mouseTrackingMode = getMouseTrackingMode(); + var alternateBufferActive = isAlternateBufferActive(); + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + // Why: xterm sends wheel events to mouse-aware TUIs before considering + // scrollback, even if the app stays on the normal buffer. + var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY); + if (mouseInput) { + notify({ type: 'terminal-input', bytes: mouseInput }); + return; + } + // Why: default mouse encoding can be unrepresentable in our ASCII-safe + // RPC path on wide terminals. Send bounded arrows instead of local + // scrollback/no-op while a mouse-aware app owns scroll gestures. + var fallbackInput = buildTuiScrollInput(lines, clientX, clientY); + if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput }); + return; + } + if (alternateBufferActive) { + // Why: alternate-screen TUIs own their scroll state and xterm has no + // scrollback there, so mobile scroll gestures must become terminal input. + var input = buildTuiScrollInput(lines, clientX, clientY); + if (input) notify({ type: 'terminal-input', bytes: input }); + return; + } + term.scrollLines(lines); + } + + function clampNormalScrollLines(lines) { + if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0; + var buffer = term.buffer.active; + if (lines > 0) { + return Math.min(lines, Math.max(0, buffer.baseY - buffer.viewportY)); + } + return Math.max(lines, -buffer.viewportY); + } + + function canScrollNormalBufferDelta(deltaY) { + if (!term || !term.buffer || !term.buffer.active || deltaY === 0) return false; + var buffer = term.buffer.active; + if (deltaY > 0) return buffer.viewportY < buffer.baseY; + return buffer.viewportY > 0; + } + + function applyNormalBufferScrollDelta(deltaY) { + if (!term || deltaY === 0) return false; + var effectiveCellH = getCellHeight() * getTotalScale(); + if (effectiveCellH <= 0) return false; + if (!canScrollNormalBufferDelta(deltaY)) { + resetSmoothScrollOffset(); + return false; + } + smoothScrollOffsetY -= deltaY; + var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH); + if (lines !== 0) { + var applied = clampNormalScrollLines(lines); + if (applied !== 0) { + term.scrollLines(applied); + // Why: xterm's renderer is row-based. Buffer touch pixels and only + // commit whole rows so TUI canvas layers do not shimmer between + // fractional transforms and xterm repaints. + smoothScrollOffsetY += applied * effectiveCellH; + } + if (applied !== lines) smoothScrollOffsetY = 0; + } + var limit = effectiveCellH - 1; + if (smoothScrollOffsetY > limit) smoothScrollOffsetY = limit; + if (smoothScrollOffsetY < -limit) smoothScrollOffsetY = -limit; + updateScrollIndicator(true); + return true; + } + + function enqueueNormalBufferScrollDelta(deltaY) { + if (!term || deltaY === 0) return false; + if (!canScrollNormalBufferDelta(deltaY)) { + resetSmoothScrollOffset(); + return false; + } + pendingNormalScrollDeltaY += deltaY; + if (normalScrollFrameId !== null) return true; + // 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. + normalScrollFrameId = requestAnimationFrame(function() { + normalScrollFrameId = null; + var delta = pendingNormalScrollDeltaY; + pendingNormalScrollDeltaY = 0; + if (!applyNormalBufferScrollDelta(delta)) { + resetSmoothScrollOffset(); + } + }); + return true; + } + + function resetSmoothScrollOffset() { + pendingNormalScrollDeltaY = 0; + if (normalScrollFrameId !== null) { + cancelAnimationFrame(normalScrollFrameId); + normalScrollFrameId = null; + } + if (smoothScrollOffsetY === 0) return; + smoothScrollOffsetY = 0; + updateScrollIndicator(false); + } + + function cellToViewportPx(col, absRow) { + if (!term) return { x: 0, y: 0 }; + var cellW = getCellWidth(); + var cellH = getCellHeight(); + var viewportRow = absRow - term.buffer.active.viewportY; + var sx = col * cellW; + var sy = viewportRow * cellH; + var total = getTotalScale(); + return { x: sx * total + panX, y: sy * total + panY }; + } + + function getLineText(absRow) { + if (!term) return ''; + var line = term.buffer.active.getLine(absRow); + if (!line) return ''; + return line.translateToString(false); + } + + // Why: getLineText collapses wide chars (emoji, CJK) to one string char, so a + // tap's CELL column no longer equals the STRING index that url/path matchers use. + // Convert by measuring the string length up to the tapped cell (the count of + // string chars before it). Without this, taps on lines with a leading wide char + // (e.g. agent output prefixed with ⏺) resolve to the wrong column and miss. + function cellColToStringIndex(absRow, col) { + if (!term) return col; + var line = term.buffer.active.getLine(absRow); + if (!line) return col; + return line.translateToString(false, 0, col).length; + } + + // File-path-under-tap detection (matchFilePathAtColumn). See + // terminal-path-tap-injected.ts; mirrors the unit-tested terminal-path-tap.ts. + + var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g; + var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g; + var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 }; + var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 }; + + function parsePathLineCol(value) { + var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value); + if (!m) return null; + var pathText = m[1]; + var last = pathText.charAt(pathText.length - 1); + if (!pathText || last === '/' || last === '\\') return null; + var line = m[2] ? parseInt(m[2], 10) : null; + var column = m[3] ? parseInt(m[3], 10) : null; + if ((line !== null && line < 1) || (column !== null && column < 1)) return null; + return { pathText: pathText, line: line, column: column }; + } + + function trimPathBoundaryPunctuation(raw, rawStart) { + var start = 0, end = raw.length; + while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1; + while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1; + if (start >= end) return null; + return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end }; + } + + function hasSeparatorAfterWhitespace(text) { + var sawWhitespace = false; + for (var i = 0; i < text.length; i++) { + var ch = text.charAt(i); + if (/\s/.test(ch)) { sawWhitespace = true; continue; } + if (sawWhitespace && (ch === '/' || ch === '\\')) return true; + } + return false; + } + + function trimSpacedPathTrailingProse(range, col) { + // A line-end extension token only extends the span when the added segment + // is path-like (contains a separator) — prose must not be swallowed. + var selected = null; + var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g; + var match; + while ((match = extensionPrefixPattern.exec(range.text)) !== null) { + var end = match.index + match[0].length; + var text = range.text.slice(0, end); + if (countPathStarts(text) > 1) continue; + if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) { + selected = text; + } + } + if (selected) { + if (col !== undefined && col >= range.startIndex + selected.length) return null; + return { text: selected, startIndex: range.startIndex, endIndex: range.startIndex + selected.length }; + } + var text = range.text.replace(/\s+$/, ''); + return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length }; + } + + function countPathStarts(text) { + var count = 0; + var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g; + while (pathStartPattern.exec(text) !== null) count += 1; + return count; + } + + function hasSpacedPathExtension(text) { + var range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length }); + if (!range) return false; + var trimmed = range.text.replace(/\s+$/, ''); + return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed); + } + + function matchSpacedFilePathAtColumn(lineText, col) { + SPACED_PATH_RE.lastIndex = 0; + var match; + while ((match = SPACED_PATH_RE.exec(lineText)) !== null) { + var trimmed = trimPathBoundaryPunctuation(match[0], match.index); + if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue; + var candidate = trimSpacedPathTrailingProse(trimmed, col); + if (!candidate) continue; + if (col < candidate.startIndex || col >= candidate.endIndex) continue; + var parsed = parsePathLineCol(candidate.text); + if (parsed) return parsed; + } + return null; + } + + function matchFilePathAtColumn(lineText, col) { + var spaced = matchSpacedFilePathAtColumn(lineText, col); + if (spaced) return spaced; + FILE_PATH_RE.lastIndex = 0; + var match; + while ((match = FILE_PATH_RE.exec(lineText)) !== null) { + var raw = match[0]; + if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; } + var trimmed = trimPathBoundaryPunctuation(raw, match.index); + if (!trimmed) continue; + if (col < trimmed.startIndex || col >= trimmed.endIndex) continue; + var parsed = parsePathLineCol(trimmed.text); + if (parsed) return parsed; + } + return null; + } + + // Returns the path candidate under the tap, or null. Query-only so the tap + // handler can try file detection before forwarding a mouse click — which lets + // file paths open even inside a mouse-tracking TUI. Relies on viewportToCell/ + // getLineText from the host script scope. + function filePathAtViewportPoint(originX, originY) { + var tapCell = viewportToCell(originX, originY); + if (!tapCell) return null; + // Map the cell column to a string index so wide chars (emoji/CJK) earlier on + // the line don't shift the match column off the tapped path. + return matchFilePathAtColumn( + getLineText(tapCell.row), + cellColToStringIndex(tapCell.row, tapCell.col) + ); + } + + + var URL_TAP_RE_SOURCE = "\\bhttps?:\\/\\/[^\\s\"'!*(){}|\\\\^<>`]*[^\\s\"':,.!?{}|\\\\^~[\\]`()<>]"; + var FILE_URL_TAP_RE_SOURCE = "\\bfile:\\/\\/[^\\s\"'!*(){}|\\\\^<>`]*[^\\s\"',!?{}|\\\\^~[\\]`()<>]"; + var URL_TAP_MAX_LENGTH = 2048; + function findUrlAtColumn(lineText, col) { + return findTerminalUrlAtColumn(lineText, col, URL_TAP_RE_SOURCE); + } + function findFileUrlAtColumn(lineText, col) { + return findTerminalUrlAtColumn(lineText, col, FILE_URL_TAP_RE_SOURCE); + } + function findTerminalUrlAtColumn(lineText, col, source) { + if (typeof lineText !== 'string' || lineText.length === 0) return null; + var re = new RegExp(source, 'gi'); + var match; + while ((match = re.exec(lineText)) !== null) { + var end = match.index + match[0].length; + if (match[0].length <= URL_TAP_MAX_LENGTH && col >= match.index && col < end) return match[0]; + if (match[0].length === 0) re.lastIndex++; + } + return null; + } + function fileUrlAtViewportPoint(clientX, clientY) { + var cell = viewportToCell(clientX, clientY); + if (!cell) return null; + return findFileUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col)); + } + function urlAtViewportPoint(clientX, clientY) { + var cell = viewportToCell(clientX, clientY); + if (!cell) return null; + // Map the cell column to a string index so wide chars earlier on the line + // don't shift the match column off the tapped URL. + return findUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col)); + } + + // Why: OSC 8 links can render as labels like "#1234"; the URI lives in + // xterm's internal link service, so every access is guarded and falls through. + function oscLinkService() { + try { + var core = term && term._core; + if (!core) return null; + return core._oscLinkService + || (core._inputHandler && core._inputHandler._oscLinkService) + || null; + } catch (e) { return null; } + } + function oscLinkAtViewportPoint(clientX, clientY) { + try { + var cell = viewportToCell(clientX, clientY); + if (!cell) return null; + var line = term.buffer.active.getLine(cell.row); + if (!line) return null; + var urlId = oscLinkIdAtCell(line, cell.col); + if (!urlId) return initialOscLinkAtCell(cell.row, cell.col); + var svc = oscLinkService(); + if (!svc || !svc.getLinkData) return initialOscLinkAtCell(cell.row, cell.col); + var data = svc.getLinkData(urlId); + var uri = data && data.uri; + return terminalOscLinkTarget(uri); + } catch (e) { return null; } + } + function initialOscLinkAtCell(row, col) { + for (var i = 0; i < initialOscLinks.length; i++) { + var link = initialOscLinks[i]; + if (!link || typeof link.uri !== 'string') continue; + if (link.row < initialOscLinkRowOffset) continue; + var shiftedRow = link.row - initialOscLinkRowOffset; + if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return terminalOscLinkTarget(link.uri); + } + return null; + } + function terminalOscLinkTarget(uri) { + if (typeof uri !== 'string') return null; + if (/^https?:/i.test(uri)) return { kind: 'url', url: uri }; + var fileTap = resolveTerminalOscFileTap(uri); + return fileTap ? { kind: 'file', fileTap: fileTap } : null; + } + function resolveTerminalOscFileTap(uri) { + return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri); + } + function resolveTerminalFileUrlTap(uri) { + var parsed; + try { + parsed = new URL(uri); + } catch (e) { + return null; + } + if (parsed.protocol !== 'file:') return null; + var filePath; + try { + filePath = decodeURIComponent(parsed.pathname || ''); + } catch (e) { + return null; + } + if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) { + filePath = '//' + parsed.hostname + filePath; + } else if (/^\/[A-Za-z]:\//.test(filePath)) { + filePath = filePath.slice(1); + } + if (!filePath) return null; + var hashTarget = parseFileUrlLineHash(parsed.hash || ''); + if (hashTarget) { + return { pathText: filePath, line: hashTarget.line, column: hashTarget.column }; + } + if (/%3a/i.test(parsed.pathname || '')) { + return { pathText: filePath, line: null, column: null }; + } + return parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null }; + } + function isLocalFileUriHostname(hostname) { + var normalized = String(hostname).toLowerCase(); + return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]'; + } + function parseOscPathLikeTarget(value) { + if (!/^(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))/.test(value)) return null; + return parsePathLineCol(value); + } + function parseFileUrlLineHash(hash) { + var match = /^#?L(\d+)(?:C(\d+))?$/i.exec(hash); + if (!match) return null; + var line = parseInt(match[1], 10); + var column = match[2] ? parseInt(match[2], 10) : null; + if (line < 1 || (column !== null && column < 1)) return null; + return { line: line, column: column }; + } + function parseFilePathTrailingLineTarget(filePath) { + var match = /^(.*?)(?::(\d+))(?::(\d+))?$/.exec(filePath); + if (!match || !match[1] || match[1].charAt(match[1].length - 1) === '/' || match[1].charAt(match[1].length - 1) === '\\') return null; + var line = parseInt(match[2], 10); + var column = match[3] ? parseInt(match[3], 10) : null; + if (line < 1 || (column !== null && column < 1)) return null; + return { pathText: match[1], line: line, column: column }; + } + function captureInitialOscLinkTexts() { + if (!Array.isArray(initialOscLinks)) return; + for (var i = 0; i < initialOscLinks.length; i++) { + var link = initialOscLinks[i]; + if (!link || typeof link.text === 'string') continue; + link.text = initialOscLinkTextAtRow(link, link.row); + } + } + function initialOscLinkTextStillMatches(link, row) { + if (typeof link.text !== 'string') return false; + return link.text.length > 0 && initialOscLinkTextAtRow(link, row) === link.text; + } + function initialOscLinkTextAtRow(link, row) { + try { + var lineText = getLineText(row); + var start = cellColToStringIndex(row, link.startCol); + var end = cellColToStringIndex(row, link.endCol); + return lineText.slice(start, end); + } catch (e) { + return ''; + } + } + function oscLinkIdAtCell(line, col) { + try { + var bufCell = line.getCell(col); + return bufCell && bufCell.extended && bufCell.extended.urlId ? bufCell.extended.urlId : 0; + } catch (e) { return 0; } + } + + function notifyTerminalSurfaceTap(originX, originY, focusKeyboard) { + var tappedOscLink = oscLinkAtViewportPoint(originX, originY); + if (tappedOscLink && tappedOscLink.kind === 'file') { + notify({ + type: 'terminal-file-tap', + pathText: tappedOscLink.fileTap.pathText, + line: tappedOscLink.fileTap.line, + column: tappedOscLink.fileTap.column + }); + return; + } + var tappedFileUrl = fileUrlAtViewportPoint(originX, originY); + var tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null; + if (tappedFileUrlPath) { + notify({ + type: 'terminal-file-tap', + pathText: tappedFileUrlPath.pathText, + line: tappedFileUrlPath.line, + column: tappedFileUrlPath.column + }); + return; + } + var tappedUrl = tappedOscLink && tappedOscLink.kind === 'url' ? tappedOscLink.url : urlAtViewportPoint(originX, originY); + if (tappedUrl) { + notify({ type: 'open-url', url: tappedUrl }); + return; + } + var tappedPath = filePathAtViewportPoint(originX, originY); + if (tappedPath) { + notify({ + type: 'terminal-file-tap', + pathText: tappedPath.pathText, + line: tappedPath.line, + column: tappedPath.column + }); + return; + } + var clickInput = buildMouseClickInput(originX, originY); + if (clickInput) { + notify({ type: 'terminal-input', bytes: clickInput }); + } + // Touch still needs native input focus after the TUI consumes its mouse click. + if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) { + notify({ type: 'terminal-tap' }); + } + } + + + function seedWordSelection(col, absRow) { + var line = getLineText(absRow); + if (!line) { + sel = { anchor: { col: col, row: absRow }, focus: { col: col, row: absRow }, activeHandle: null }; + applyXtermSelection(); + return; + } + var s = col; + var e = col; + if (col >= 0 && col < line.length && WORD_RE.test(line[col])) { + while (s > 0 && WORD_RE.test(line[s - 1])) s--; + while (e < line.length - 1 && WORD_RE.test(line[e + 1])) e++; + } + sel = { + anchor: { col: s, row: absRow }, + focus: { col: e, row: absRow }, + activeHandle: null + }; + applyXtermSelection(); + } + + function isStartFirst(a, b) { + if (a.row !== b.row) return a.row < b.row; + return a.col <= b.col; + } + + function selRange() { + if (!sel) return null; + if (isStartFirst(sel.anchor, sel.focus)) return { start: sel.anchor, end: sel.focus }; + return { start: sel.focus, end: sel.anchor }; + } + + function applyXtermSelection() { + if (!term || !sel) return; + var r = selRange(); + if (!r) return; + // Why: term.select(col, row, length) takes a buffer-absolute row, + // not a viewport-relative one. Subtracting viewportY here drifts the + // selection by the scrollback height — handles render where the user + // pressed (their math is independent), but xterm highlights an + // off-screen scrollback region and copies the wrong text. + var length; + if (r.start.row === r.end.row) { + length = Math.max(1, r.end.col - r.start.col + 1); + } else { + var first = term.cols - r.start.col; + var middle = Math.max(0, r.end.row - r.start.row - 1) * term.cols; + var last = r.end.col + 1; + length = first + middle + last; + } + try { term.select(r.start.col, r.start.row, length); } catch (e) {} + } + + function cancelSelect() { + selMode = 'navigate'; + sel = null; + stopEdgeScroll(); + if (term) { + try { term.clearSelection(); } catch (e) {} + // Why: some xterm renderers cache cells and skip repaint on + // clearSelection alone, leaving the previously-highlighted cells + // visually selected. Force a full refresh so the selection layer + // actually clears on screen. + try { term.refresh(0, term.rows - 1); } catch (e) {} + } + selectionOverlay.classList.remove('active'); + notify({ type: 'set-select-mode', enabled: false }); + } + + function enterSelect(col, absRow) { + selMode = 'select'; + seedWordSelection(col, absRow); + selectionOverlay.classList.add('active'); + notify({ type: 'set-select-mode', enabled: true }); + notify({ type: 'haptic', kind: 'selection' }); + repositionOverlay(); + } + + function repositionOverlay() { + if (selMode !== 'select' || !sel || !term) return; + var r = selRange(); + var sPx = cellToViewportPx(r.start.col, r.start.row); + var ePx = cellToViewportPx(r.end.col + 1, r.end.row); + var cellH = getCellHeight() * getTotalScale(); + // Why: native iOS pattern — start handle anchors at the TOP of the + // first selected cell (dot above, stem covers the cell going down); + // end handle anchors at the BOTTOM of the last selected cell (dot + // below, stem covers the cell going up). + handleStart.style.left = sPx.x + 'px'; + handleStart.style.top = sPx.y + 'px'; + handleEnd.style.left = ePx.x + 'px'; + handleEnd.style.top = (ePx.y + cellH) + 'px'; + var startVisible = sPx.y >= 0 && sPx.y <= window.innerHeight; + var endVisible = ePx.y >= 0 && ePx.y <= window.innerHeight; + handleStart.style.visibility = startVisible ? 'visible' : 'hidden'; + handleEnd.style.visibility = endVisible ? 'visible' : 'hidden'; + var menuCenterX, menuY, vTransform, marginTop; + if (startVisible && sPx.y > 56) { + menuCenterX = sPx.x; menuY = sPx.y; + vTransform = 'translateY(-100%)'; + marginTop = '-12px'; + } else if (endVisible && ePx.y + cellH + 56 < window.innerHeight) { + menuCenterX = ePx.x; menuY = ePx.y + cellH; + vTransform = 'translateY(0)'; + marginTop = '12px'; + } else { + // selection covers full viewport — pin to visible center + menuCenterX = window.innerWidth / 2; + menuY = window.innerHeight / 2; + vTransform = 'translateY(-50%)'; + marginTop = '0'; + } + // Why: clamp horizontally so the pill stays fully visible when the + // selection sits near a screen edge. We position via plain left + // (no horizontal translate) so the clamp math is straightforward. + selMenu.style.transform = vTransform; + selMenu.style.marginTop = marginTop; + selMenu.style.top = menuY + 'px'; + selMenu.style.left = '0px'; + var EDGE_MARGIN = 8; + var menuW = selMenu.offsetWidth || 0; + var minLeft = EDGE_MARGIN; + var maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - menuW - EDGE_MARGIN); + var desiredLeft = menuCenterX - menuW / 2; + var clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft)); + selMenu.style.left = clampedLeft + 'px'; + } + + function syncSelectionHandleToViewportPoint(handle, clientX, clientY) { + var c = viewportToCell(clientX, clientY); + if (!c || !sel) return false; + if (handle === 'start') sel.anchor = c; + else sel.focus = c; + applyXtermSelection(); + return true; + } + + function syncEdgeScrollSelectionEndpoint() { + if (!sel || !sel.activeHandle) return false; + // Why: WebView may not emit new touchmove events while a handle is held + // at the edge; resample the stored finger point after each viewport scroll. + return syncSelectionHandleToViewportPoint( + sel.activeHandle, + edgeScrollClientX, + edgeScrollClientY + ); + } + + function startEdgeScroll(dir) { + if (edgeScrollDir === dir) return; + stopEdgeScroll(); + edgeScrollDir = dir; + edgeScrollTimer = setInterval(function() { + if (!term || edgeScrollDir === 0) return; + var beforeY = term.buffer.active.viewportY; + term.scrollLines(edgeScrollDir); + var afterY = term.buffer.active.viewportY; + if (beforeY === afterY) { + notify({ type: 'haptic', kind: 'edge-bump' }); + stopEdgeScroll(); + return; + } + syncEdgeScrollSelectionEndpoint(); + repositionOverlay(); + }, EDGE_SCROLL_INTERVAL); + } + + function stopEdgeScroll() { + if (edgeScrollTimer) { + clearInterval(edgeScrollTimer); + edgeScrollTimer = null; + } + edgeScrollDir = 0; + } + + function handleDragMove(handle, clientX, clientY) { + edgeScrollClientX = clientX; + edgeScrollClientY = clientY; + if (!syncSelectionHandleToViewportPoint(handle, clientX, clientY)) return; + repositionOverlay(); + if (clientY < EDGE_SCROLL_PX) startEdgeScroll(-1); + else if (clientY > window.innerHeight - EDGE_SCROLL_PX) startEdgeScroll(1); + else stopEdgeScroll(); + } + + // Latching document-level touch dispatcher: see + // terminal-webview-tap-dispatch-injected.ts (extracted for max-lines). + + // ============================================================ + // LATCHING TOUCH DISPATCHER (document-level) + // ============================================================ + var dispatch = { mode: 'idle', touchId: null, touchIds: null, longPressFingerInsideOverlay: false }; + + function touchById(touches, id) { + for (var i = 0; i < touches.length; i++) { + if (touches[i].identifier === id) return touches[i]; + } + return null; + } + + function targetInside(target, el) { + if (!target || !el) return false; + return el.contains(target); + } + + function clearLongPress() { + if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } + longPressOrigin = null; + } + + function armLongPress(touch) { + longPressOrigin = { x: touch.clientX, y: touch.clientY, identifier: touch.identifier }; + longPressTimer = setTimeout(function() { + longPressTimer = null; + if (!longPressOrigin) return; + var c = viewportToCell(longPressOrigin.x, longPressOrigin.y); + if (!c) return; + enterSelect(c.col, c.row); + }, LONG_PRESS_MS); + } + + function touchSlopExceeded(t) { + if (!longPressOrigin) return false; + var dx = Math.abs(t.clientX - longPressOrigin.x); + var dy = Math.abs(t.clientY - longPressOrigin.y); + return (dx + dy) > LONG_PRESS_SLOP; + } + + // Why: existing surface handlers stay attached to surface but we wrap + // their entry to no-op when the dispatcher latches into select-drag. + function dispatcherShouldBlockSurface() { + return dispatch.mode === 'select-drag'; + } + + document.addEventListener('touchstart', function(e) { + var t = e.touches[0]; + var target = e.target; + var onHandle = target === handleStart || target === handleEnd; + var inOverlay = targetInside(target, selectionOverlay); + var inSurface = targetInside(target, surface); + // Why: clear any stale tap candidate up front; only a fresh single-finger + // surface touch (below) re-arms it, so handle drags / pinches / dismiss + // taps never resolve as a link tap on touchend. + tapCandidate = null; + + if (e.touches.length === 2) { + // pinch latch + if (selMode === 'select') { + notify({ type: 'mobile-clip-cancel-by-pinch' }); + cancelSelect(); + } + dispatch.mode = 'pinch'; + dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier]; + clearLongPress(); + return; + } + + if (onHandle && selMode === 'select') { + // start handle drag + var handleName = (target === handleStart) ? 'start' : 'end'; + sel.activeHandle = handleName; + dispatch.mode = 'select-drag'; + dispatch.touchId = t.identifier; + e.preventDefault(); + return; + } + + if (inOverlay) { + // tap on menu pill — let the buttons' own handlers fire + return; + } + + if (inSurface && selMode === 'select') { + // Why: tap-to-dismiss matches native iOS/Android — touching outside the + // 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; + return; + } + + if (inSurface) { + dispatch.mode = 'surface'; + dispatch.touchId = t.identifier; + tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier }; + armLongPress(t); + } + }, { capture: true, passive: false }); + + document.addEventListener('touchmove', function(e) { + if (dispatch.mode === 'select-drag') { + var t = touchById(e.touches, dispatch.touchId); + if (!t || !sel || !sel.activeHandle) return; + e.preventDefault(); + handleDragMove(sel.activeHandle, t.clientX, t.clientY); + return; + } + if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') { + // long-press slop check + if (longPressTimer && e.touches.length === 1) { + if (touchSlopExceeded(e.touches[0])) clearLongPress(); + } + // Why: disqualify the tap only once the finger travels past TAP_SLOP + // (a scroll/pan), independent of the long-press timer — so a tap that + // jitters under TAP_SLOP still opens the link/path under the finger. + if (tapCandidate && e.touches.length === 1) { + var mt = e.touches[0]; + if (mt.identifier === tapCandidate.identifier) { + var dx = Math.abs(mt.clientX - tapCandidate.x); + var dy = Math.abs(mt.clientY - tapCandidate.y); + if (dx + dy > TAP_SLOP) tapCandidate = null; + } + } else if (e.touches.length !== 1) { + tapCandidate = null; + } + // existing surface handler will run from its own listener + } + }, { capture: true, passive: false }); + + document.addEventListener('touchend', function(e) { + if (dispatch.mode === 'select-drag') { + if (sel) sel.activeHandle = null; + stopEdgeScroll(); + dispatch.mode = 'idle'; + dispatch.touchId = null; + return; + } + if (dispatch.mode === 'pinch') { + if (e.touches.length < 2) { + dispatch.mode = (e.touches.length === 1) ? 'surface' : 'idle'; + dispatch.touchIds = null; + if (e.touches.length === 1) dispatch.touchId = e.touches[0].identifier; + } + return; + } + if (dispatch.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. + if ( + e.touches.length === 0 && + tapCandidate && + selMode !== 'select' && + Date.now() - tapCandidate.t <= TAP_MAX_MS + ) { + notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true); + } + clearLongPress(); + tapCandidate = null; + if (e.touches.length === 0) { + dispatch.mode = 'idle'; + dispatch.touchId = null; + } + } + }, { capture: true, passive: true }); + + document.addEventListener('touchcancel', function() { + clearLongPress(); + tapCandidate = null; + stopEdgeScroll(); + if (dispatch.mode === 'select-drag') { + if (sel) sel.activeHandle = null; + } + dispatch.mode = 'idle'; + dispatch.touchId = null; + dispatch.touchIds = null; + }, { capture: true, passive: true }); + + + // External mouse / trackpad scroll: see + // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines). + + var wheelAccumDeltaY = 0; + + function wheelEventPixelDeltaY(e) { + var delta = e.deltaY; + if (typeof delta !== 'number' || !isFinite(delta) || delta === 0) return 0; + // DOM_DELTA_LINE / DOM_DELTA_PAGE: Android WebView reports line-mode deltas + // for external mouse wheels, iOS trackpads report pixels. + if (e.deltaMode === 1) return delta * getCellHeight() * getTotalScale(); + if (e.deltaMode === 2) return delta * window.innerHeight; + return delta; + } + + function attachSurfaceWheelHandler(targetSurface) { + targetSurface.addEventListener('wheel', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (!term) return; + // Why: xterm's own wheel handler scrolls its hidden viewport or emits + // cursor keys through onData, which the mobile query-reply gate drops. + // Claim the event so indirect pointers share the touch scroll router. + e.preventDefault(); + e.stopPropagation(); + + // Why: a trackpad pinch arrives as ctrl+wheel. Swallow it rather than + // firing cursor keys at the TUI; two-finger pinch still drives text size. + if (e.ctrlKey) return; + + var deltaY = wheelEventPixelDeltaY(e); + if (deltaY === 0) return; + + if (shouldRouteScrollToTerminalInput()) { + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + if (!(effectiveCellH > 0)) return; + wheelAccumDeltaY += deltaY; + var lines = Math.trunc(wheelAccumDeltaY / effectiveCellH); + if (lines !== 0) { + wheelAccumDeltaY -= lines * effectiveCellH; + routeScrollLines(lines, e.clientX, e.clientY); + } + return; + } + wheelAccumDeltaY = 0; + enqueueNormalBufferScrollDelta(deltaY); + }, { capture: true, passive: false }); + } + + + // External mouse click/drag: see + // terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines). + + var mouseGesture = 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 + // drag/any report motion) or the cell is not encodable. + function buildMouseButtonReport(kind, clientX, clientY) { + var mouseTrackingMode = getMouseTrackingMode(); + if (mouseTrackingMode === 'none') return ''; + if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') return ''; + if (kind === 'release' && mouseTrackingMode === 'x10') return ''; + var cell = viewportToMouseReportCell(clientX, clientY); + if (!cell) return ''; + var sgrButton = kind === 'motion' ? 32 : 0; + var sgrFinal = kind === 'release' ? 'm' : 'M'; + if (sgrMousePixelsMode) { + if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; + return ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal; + } + if (sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + return ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal; + } + var button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32; + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + // Why: non-SGR mouse bytes above ASCII are not preserved reliably through + // the mobile JSON/RPC string path; drop instead of corrupting input. + if (col > 126 || row > 126) return ''; + return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function mouseReportCellKey(clientX, clientY) { + var cell = viewportToMouseReportCell(clientX, clientY); + return cell ? cell.col + ',' + cell.row : null; + } + + function abandonMouseGesture() { + var gesture = mouseGesture; + mouseGesture = null; + if (!gesture) return; + if (gesture.mode === 'tracking') { + // Why: the press report already went to the TUI; a lost pointer must not + // leave the button latched down on the far side. + var release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY); + if (release) notify({ type: 'terminal-input', bytes: release }); + } else if (gesture.mode === 'selecting') { + if (sel) sel.activeHandle = null; + stopEdgeScroll(); + } + } + + function beginMouseDrag(gesture) { + gesture.moved = true; + if (getMouseTrackingMode() !== 'none') { + gesture.mode = 'tracking'; + gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY); + var press = buildMouseButtonReport('press', gesture.startX, gesture.startY); + if (press) notify({ type: 'terminal-input', bytes: press }); + return; + } + var anchor = viewportToCell(gesture.startX, gesture.startY); + if (!anchor) { + gesture.mode = 'cancelled'; + return; + } + // Why: mouse drags select character-anchored ranges like desktop terminals, + // not the word-seeded long-press selection; reuse the touch handle-drag + // plumbing (edge scroll included) by acting as a live 'end' handle. + gesture.mode = 'selecting'; + selMode = 'select'; + sel = { anchor: anchor, focus: anchor, activeHandle: 'end' }; + selectionOverlay.classList.add('active'); + notify({ type: 'set-select-mode', enabled: true }); + applyXtermSelection(); + repositionOverlay(); + } + + function attachSurfaceMouseClickDragHandler(targetSurface) { + targetSurface.addEventListener('pointerdown', function(e) { + if (e.pointerType !== 'mouse' || e.button !== 0) return; + if (dispatcherShouldBlockSurface() || !term) return; + // 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) abandonMouseGesture(); + // Why: mouse pointers have no implicit capture; without it a drag that + // leaves the surface drops pointermove/pointerup and strands the gesture. + try { + if (targetSurface.setPointerCapture) targetSurface.setPointerCapture(e.pointerId); + } catch (err) {} + mouseGesture = { + startX: e.clientX, startY: e.clientY, + lastX: e.clientX, lastY: e.clientY, + lastCellKey: null, + moved: false, + mode: 'pending', + dismissedSelection: false + }; + if (selMode === 'select') { + // 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; + } + }, true); + + targetSurface.addEventListener('pointermove', function(e) { + var gesture = mouseGesture; + if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return; + if (!term) return; + gesture.lastX = e.clientX; + gesture.lastY = e.clientY; + if ((e.buttons & 1) === 0) { + // Why: a pointerup lost outside the WebView (capture unavailable) must + // end the gesture here, or a tracked press stays latched at the TUI. + // Coordinates first, so the synthesized release lands where the + // pointer re-entered rather than at the previous cell. + abandonMouseGesture(); + return; + } + if (!gesture.moved) { + var dx = Math.abs(e.clientX - gesture.startX); + var dy = Math.abs(e.clientY - gesture.startY); + if (dx + dy <= TAP_SLOP) return; + beginMouseDrag(gesture); + } + if (gesture.mode === 'tracking') { + // Why: one motion report per cell keeps drags bounded by grid size, not + // by pointermove cadence, so the RN rate limiter is never the bottleneck. + var cellKey = mouseReportCellKey(e.clientX, e.clientY); + if (cellKey && cellKey !== gesture.lastCellKey) { + gesture.lastCellKey = cellKey; + var motion = buildMouseButtonReport('motion', e.clientX, e.clientY); + if (motion) notify({ type: 'terminal-input', bytes: motion }); + } + } else if (gesture.mode === 'selecting') { + handleDragMove('end', e.clientX, e.clientY); + } + }, true); + + targetSurface.addEventListener('pointerup', function(e) { + var gesture = mouseGesture; + if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) return; + mouseGesture = null; + if (gesture.mode === 'cancelled' || !term) return; + if (gesture.mode === 'tracking') { + var release = buildMouseButtonReport('release', e.clientX, e.clientY); + if (release) notify({ type: 'terminal-input', bytes: release }); + return; + } + if (gesture.mode === 'selecting') { + if (sel) sel.activeHandle = null; + stopEdgeScroll(); + repositionOverlay(); + return; + } + if (dispatcherShouldBlockSurface()) return; + // Why: a dismissing tap only clears the selection (touch parity); it must + // not also open a link or focus the keyboard underneath. + if (gesture.dismissedSelection) return; + // Pointer clicks keep their current link, file, TUI mouse, and focus priority. + notifyTerminalSurfaceTap(e.clientX, e.clientY, false); + }, true); + + targetSurface.addEventListener('pointercancel', function(e) { + if (e.pointerType !== 'mouse') return; + abandonMouseGesture(); + }, true); + + // Why: Android input injection can pair a mouse-flavored pointerdown with + // real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives, + // the document touch dispatcher owns the gesture. + targetSurface.addEventListener('touchstart', function() { + if (mouseGesture) abandonMouseGesture(); + }, true); + } + + + btnCopy.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + if (!term) return; + var text = term.getSelection ? term.getSelection() : ''; + if (text && text.length > 0) { + notify({ type: 'selection', text: text }); + } else { + cancelSelect(); + } + }); + + btnSelAll.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + if (!term) return; + try { + term.selectAll(); + var b = term.buffer.active; + sel = { + anchor: { col: 0, row: 0 }, + focus: { col: term.cols - 1, row: b.length - 1 }, + activeHandle: null + }; + repositionOverlay(); + } catch (err) {} + }); + + var 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; + var instantVelocity = deltaY / dt; + if (!isFinite(instantVelocity)) return; + // 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; + } + + function getDistance(a, b) { + var dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; + return Math.sqrt(dx * dx + dy * dy); + } + + function attachSurfaceEventHandlers(targetSurface) { + if (!targetSurface || targetSurface.__orcaSurfaceHandlersAttached) return; + targetSurface.__orcaSurfaceHandlersAttached = true; + // Why: init() swaps in a new hidden surface to avoid flicker; each + // replacement needs gesture handlers or tab-switch replays stop scrolling. + targetSurface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true); + targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true); + + attachSurfaceWheelHandler(targetSurface); + attachSurfaceMouseClickDragHandler(targetSurface); + + targetSurface.addEventListener('touchstart', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (ts.momentumId) { + cancelAnimationFrame(ts.momentumId); + ts.momentumId = null; + } + if (e.touches.length === 2) { + ts.isPinching = true; + smoothScrollOffsetY = 0; + ts.pinchDist = getDistance(e.touches[0], e.touches[1]); + ts.pinchScale = userScale; + var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; + var my = (e.touches[0].clientY + e.touches[1].clientY) / 2; + var total = getTotalScale(); + ts.pinchSurfX = (mx - panX) / total; + ts.pinchSurfY = (my - 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; + } + }, { capture: true, passive: true }); + + targetSurface.addEventListener('touchmove', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (!term) return; + e.preventDefault(); + e.stopPropagation(); + + if (e.touches.length === 2) { + ts.isPinching = true; + var dist = getDistance(e.touches[0], e.touches[1]); + var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; + var my = (e.touches[0].clientY + e.touches[1].clientY) / 2; + + var ratio = dist / ts.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. + var loScale = MIN_TEXT_SCALE / currentTextScale; + var hiScale = MAX_TEXT_SCALE / currentTextScale; + userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio)); + + var total = getTotalScale(); + panX = mx - ts.pinchSurfX * total; + panY = my - ts.pinchSurfY * total; + clampPan(); + updateTransform(); + + } else if (e.touches.length === 1 && !ts.isPinching) { + var x = e.touches[0].clientX, y = e.touches[0].clientY; + var now = Date.now(), dt = now - ts.lastTime; + + // Why: pan horizontally only when content overflows the viewport (larger + // than fit) — same check clampPan() uses. Vertical always drives buffer + // scroll so scrollback stays reachable at any text size; calling the + // never-defined contentWiderThanViewport() here threw and killed all + // single-finger scrolling, scrollback included. + if (term.element && term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) { + panX += x - ts.lastX; + clampPan(); + updateTransform(); + } + + var deltaY = ts.lastY - y; + ts.lastTime = now; + if (shouldRouteScrollToTerminalInput()) { + updateTouchVelocity(deltaY, dt); + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + ts.accumDelta += deltaY; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, x, y); + } + } else { + if (enqueueNormalBufferScrollDelta(deltaY)) { + updateTouchVelocity(deltaY, dt); + } else { + ts.velY = 0; + } + } + ts.lastX = x; + ts.lastY = y; + } + }, { capture: true, passive: false }); + + targetSurface.addEventListener('touchend', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (!term) return; + + if (ts.isPinching && e.touches.length < 2) { + ts.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 + // size change reflows columns and RN persists + resizes the PTY to match. + var target = snapToTextScalePreset(currentTextScale * userScale); + var changed = target !== currentTextScale; + userScale = 1; + panX = 0; panY = 0; + applyTextScale(target); + updateTransform(); + notify({ type: 'font-scale-changed', fontScale: target }); + if (changed) 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; + } + return; + } + + if (e.touches.length === 0) { + var vel = ts.velY; + var FRICTION = 0.972; + var MIN_VEL = 0.012; + function momentumStep() { + vel *= FRICTION; + if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } + var delta = vel * 16; + if (shouldRouteScrollToTerminalInput()) { + resetSmoothScrollOffset(); + var effectiveCellH = getCellHeight() * getTotalScale(); + ts.accumDelta += delta; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, ts.lastX, ts.lastY); + } + } else { + if (!applyNormalBufferScrollDelta(delta)) { + ts.momentumId = null; + return; + } + } + ts.momentumId = requestAnimationFrame(momentumStep); + } + if (Math.abs(vel) > MIN_VEL) { + ts.momentumId = requestAnimationFrame(momentumStep); + } + } + }, { capture: true, passive: true }); + } + + attachSurfaceEventHandlers(surface); + + function handleIncomingMessage(e) { + var msg; + try { + msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; + } catch (ex) { + return; + } + try { + handleMsg(msg); + } catch(ex) { + reportEngineError( + msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', + ex, + msg && msg.type === 'init' && !everReady + ); + } + } + + window.addEventListener('message', handleIncomingMessage); + + document.addEventListener('message', handleIncomingMessage); + + window.addEventListener('resize', function() { + // Why: viewport changed (keyboard open/close, orientation, RN container + // size update). Re-fit so the scale matches the new vpWidth — without + // this, opening the keyboard leaves the terminal at the old scale even + // though there's now less vertical room and the fit ratio may differ. + applyFitScale('window-resize'); + adjustRowsForViewport(); + repositionOverlay(); + clampPan(); + updateTransform(); + }); + + if (window.Terminal) { + notify({ type: 'web-ready' }); + } else { + reportEngineError('terminal engine missing', 'xterm failed to load', true); + } +})(); diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts b/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts deleted file mode 100644 index a3a29ac61f9..00000000000 --- a/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts +++ /dev/null @@ -1,43 +0,0 @@ -export const TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS = ` - function lineHasVisibleContent(line, cell) { - if (line.translateToString(true).trim().length > 0) return true; - if (!cell || !line.getCell) return false; - var limit = Math.min(term.cols || 0, line.length || 0); - for (var x = 0; x < limit; x++) { - var current = line.getCell(x, cell); - if (!current) continue; - if (!current.isBgDefault() || current.isInverse()) return true; - if (typeof current.isUnderline === 'function' && current.isUnderline()) return true; - if (typeof current.isStrikethrough === 'function' && current.isStrikethrough()) return true; - if (typeof current.isOverline === 'function' && current.isOverline()) return true; - } - return false; - } - - function computeContentBottomRow() { - if (!term || !term.buffer || !term.buffer.active) return 0; - var buffer = term.buffer.active; - var top = buffer.viewportY || 0; - var cell = buffer.getNullCell ? buffer.getNullCell() : null; - for (var y = (term.rows || 0) - 1; y >= 0; y--) { - try { - var line = buffer.getLine(top + y); - if (line && lineHasVisibleContent(line, cell)) return y; - } catch (e) {} - } - return 0; - } - - function emitKeyboardAvoidanceMetrics() { - if (!term) return; - var alt = false; - try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} - notify({ - type: 'keyboard-avoidance-metrics', - cursorY: term.buffer && term.buffer.active ? term.buffer.active.cursorY : 0, - contentBottomRow: alt ? 0 : computeContentBottomRow(), - rows: term.rows || 0, - altScreen: alt - }); - } -` diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts index 203db63bc65..1b028395003 100644 --- a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts +++ b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts @@ -1,16 +1,16 @@ -import { readFileSync } from 'node:fs' import { Script } from 'node:vm' import { Terminal } from '@xterm/xterm' import { describe, expect, it, vi } from 'vitest' -import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected' +import { + documentScopePreamble, + generatedDocumentModule +} from './document/generated-document-region.test-support' import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract' -import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' +import { XTERM_HTML } from './terminal-webview-html' -const terminalHtmlSource = readTerminalWebViewHtmlSource() -const reflowSource = readFileSync( - new URL('./terminal-webview-reflow-injected.ts', import.meta.url), - 'utf8' -) +const terminalHtmlSource = XTERM_HTML +// The scope object plus the metrics block, exactly as the document carries them. +const keyboardAvoidanceMetricsScript = `${documentScopePreamble()}\nscope.term = term;\n${await generatedDocumentModule('keyboard-avoidance-metrics')}` type Cell = { isBgDefault: () => boolean; isInverse: () => number } type MetricsNotification = { @@ -48,17 +48,15 @@ function runMetrics(lines: (ReturnType | undefined)[], altScree notify: (message: Record) => notifications.push(message), term: { buffer: { active: buffer }, cols: 10, rows: lines.length } } - new Script( - `${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();` - ).runInNewContext(context) + new Script(`${keyboardAvoidanceMetricsScript}\nemitKeyboardAvoidanceMetrics();`).runInNewContext( + context + ) return notifications[0] as MetricsNotification } function runTerminalMetrics(term: Terminal) { const notifications: Record[] = [] - new Script( - `${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();` - ).runInNewContext({ + new Script(`${keyboardAvoidanceMetricsScript}\nemitKeyboardAvoidanceMetrics();`).runInNewContext({ notify: (message: Record) => notifications.push(message), term }) @@ -193,17 +191,19 @@ describe('terminal keyboard-avoidance WebView metrics', () => { it('refreshes metrics after every buffer geometry reset', () => { const resizeStart = terminalHtmlSource.indexOf(' function resize(cols, rows)') - const resizeEnd = terminalHtmlSource.indexOf('\n // reflow()', resizeStart) - const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {") - const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart) + const resizeEnd = terminalHtmlSource.indexOf('\n function reflow(', resizeStart) + const clearStart = terminalHtmlSource.indexOf('} else if (msg.type === "clear") {') + const clearEnd = terminalHtmlSource.indexOf('} else if (msg.type === "measure")', clearStart) const textScaleStart = terminalHtmlSource.indexOf(' function applyTextScale(scale)') - const textScaleEnd = terminalHtmlSource.indexOf('\n var panX', textScaleStart) + const textScaleEnd = terminalHtmlSource.indexOf('\n scope.panX', textScaleStart) + const reflowStart = terminalHtmlSource.indexOf(' function reflow(cols, rows)') + const reflowEnd = terminalHtmlSource.indexOf('\n function notify(', reflowStart) for (const block of [ terminalHtmlSource.slice(resizeStart, resizeEnd), terminalHtmlSource.slice(clearStart, clearEnd), terminalHtmlSource.slice(textScaleStart, textScaleEnd), - reflowSource + terminalHtmlSource.slice(reflowStart, reflowEnd) ]) { expect(block.indexOf('emitKeyboardAvoidanceMetrics()')).toBeGreaterThan( block.includes('term.resize') ? block.indexOf('term.resize') : block.indexOf('term.reset') diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts deleted file mode 100644 index 766c381ea22..00000000000 --- a/mobile/src/terminal/terminal-path-tap-injected.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Plain-JS file-path-under-tap detection, injected verbatim into the terminal -// WebView's xterm script (XTERM_HTML). It is interpolated with ${...}, so the -// regex backslashes here are single (the real runtime form) — not the doubled -// form a backtick template literal would otherwise require. -// -// This mirrors the unit-tested mobile/src/terminal/terminal-path-tap.ts; keep -// the two in sync. The TS module is the source of truth for the algorithm and -// has the regression tests; this string only exists because the WebView can't -// import RN modules. -// -// Matches both slash-bearing paths AND bare filenames with an extension -// (README.md, src/index.ts:5) — like desktop, we propose candidates and let the -// host's files.resolveTerminalPath existence check reject non-files. Agents -// often print a bare filename (the markdown link target is consumed, leaving -// only the label text), so requiring a slash would miss the common case. -export const TERMINAL_PATH_TAP_JS = String.raw` - var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g; - var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g; - var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 }; - var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 }; - - function parsePathLineCol(value) { - var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value); - if (!m) return null; - var pathText = m[1]; - var last = pathText.charAt(pathText.length - 1); - if (!pathText || last === '/' || last === '\\') return null; - var line = m[2] ? parseInt(m[2], 10) : null; - var column = m[3] ? parseInt(m[3], 10) : null; - if ((line !== null && line < 1) || (column !== null && column < 1)) return null; - return { pathText: pathText, line: line, column: column }; - } - - function trimPathBoundaryPunctuation(raw, rawStart) { - var start = 0, end = raw.length; - while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1; - while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1; - if (start >= end) return null; - return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end }; - } - - function hasSeparatorAfterWhitespace(text) { - var sawWhitespace = false; - for (var i = 0; i < text.length; i++) { - var ch = text.charAt(i); - if (/\s/.test(ch)) { sawWhitespace = true; continue; } - if (sawWhitespace && (ch === '/' || ch === '\\')) return true; - } - return false; - } - - function trimSpacedPathTrailingProse(range, col) { - // A line-end extension token only extends the span when the added segment - // is path-like (contains a separator) — prose must not be swallowed. - var selected = null; - var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g; - var match; - while ((match = extensionPrefixPattern.exec(range.text)) !== null) { - var end = match.index + match[0].length; - var text = range.text.slice(0, end); - if (countPathStarts(text) > 1) continue; - if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) { - selected = text; - } - } - if (selected) { - if (col !== undefined && col >= range.startIndex + selected.length) return null; - return { text: selected, startIndex: range.startIndex, endIndex: range.startIndex + selected.length }; - } - var text = range.text.replace(/\s+$/, ''); - return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length }; - } - - function countPathStarts(text) { - var count = 0; - var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g; - while (pathStartPattern.exec(text) !== null) count += 1; - return count; - } - - function hasSpacedPathExtension(text) { - var range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length }); - if (!range) return false; - var trimmed = range.text.replace(/\s+$/, ''); - return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed); - } - - function matchSpacedFilePathAtColumn(lineText, col) { - SPACED_PATH_RE.lastIndex = 0; - var match; - while ((match = SPACED_PATH_RE.exec(lineText)) !== null) { - var trimmed = trimPathBoundaryPunctuation(match[0], match.index); - if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue; - var candidate = trimSpacedPathTrailingProse(trimmed, col); - if (!candidate) continue; - if (col < candidate.startIndex || col >= candidate.endIndex) continue; - var parsed = parsePathLineCol(candidate.text); - if (parsed) return parsed; - } - return null; - } - - function matchFilePathAtColumn(lineText, col) { - var spaced = matchSpacedFilePathAtColumn(lineText, col); - if (spaced) return spaced; - FILE_PATH_RE.lastIndex = 0; - var match; - while ((match = FILE_PATH_RE.exec(lineText)) !== null) { - var raw = match[0]; - if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; } - var trimmed = trimPathBoundaryPunctuation(raw, match.index); - if (!trimmed) continue; - if (col < trimmed.startIndex || col >= trimmed.endIndex) continue; - var parsed = parsePathLineCol(trimmed.text); - if (parsed) return parsed; - } - return null; - } - - // Returns the path candidate under the tap, or null. Query-only so the tap - // handler can try file detection before forwarding a mouse click — which lets - // file paths open even inside a mouse-tracking TUI. Relies on viewportToCell/ - // getLineText from the host script scope. - function filePathAtViewportPoint(originX, originY) { - var tapCell = viewportToCell(originX, originY); - if (!tapCell) return null; - // Map the cell column to a string index so wide chars (emoji/CJK) earlier on - // the line don't shift the match column off the tapped path. - return matchFilePathAtColumn( - getLineText(tapCell.row), - cellColToStringIndex(tapCell.row, tapCell.col) - ); - } -` diff --git a/mobile/src/terminal/terminal-path-tap.test.ts b/mobile/src/terminal/terminal-path-tap.test.ts index ebb8e0b9ec3..417d7ca24cc 100644 --- a/mobile/src/terminal/terminal-path-tap.test.ts +++ b/mobile/src/terminal/terminal-path-tap.test.ts @@ -4,9 +4,11 @@ import { TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES, columnForTerminalFileLinkTap } from '../../../src/shared/terminal-file-link-conformance' -import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' +import { generatedDocumentModule } from './document/generated-document-region.test-support' import { matchFilePathAtColumn, parsePathWithOptionalLineColumn } from './terminal-path-tap' +const pathTapSource = await generatedDocumentModule('path-tap') + type InjectedPathMatcher = typeof matchFilePathAtColumn // Returns the column of the first occurrence of `needle` in `line` (+offset). @@ -182,7 +184,7 @@ describe('injected matchFilePathAtColumn', () => { function createInjectedPathMatcher(): InjectedPathMatcher { const context = createContext({}) new Script( - `${TERMINAL_PATH_TAP_JS}\nthis.__matchFilePathAtColumn = matchFilePathAtColumn;` + `${pathTapSource}\nthis.__matchFilePathAtColumn = matchFilePathAtColumn;` ).runInContext(context) return (context as { __matchFilePathAtColumn: InjectedPathMatcher }).__matchFilePathAtColumn } diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts index ac415922851..08058f3e6f1 100644 --- a/mobile/src/terminal/terminal-webview-engine.test.ts +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -2,22 +2,18 @@ import { Script } from 'node:vm' import { parse } from 'acorn' import { describe, expect, it, vi } from 'vitest' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' +import { documentScopePreamble } from './document/generated-document-region.test-support' import { XTERM_HTML } from './terminal-webview-html' -import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' -import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' // Assert against the assembled document so extracted fragments cannot silently // disappear from the WebView while source-level checks still pass. -const terminalHtmlSource = readTerminalWebViewHtmlSource() +const terminalHtmlSource = XTERM_HTML function createWebglRecoveryHarness(failSecondAttach = false) { - const variablesStart = terminalHtmlSource.indexOf(' var webglAddon = null;') - const variablesEnd = terminalHtmlSource.indexOf( - '\n', - terminalHtmlSource.indexOf(' var webglRecoveryTimer = null;') - ) - expect(variablesStart).toBeGreaterThanOrEqual(0) - expect(variablesEnd).toBeGreaterThan(variablesStart) + const recoveryStart = terminalHtmlSource.indexOf(' function refreshTerminalSurface()') + const recoveryEnd = terminalHtmlSource.indexOf(' function init(', recoveryStart) + expect(recoveryStart).toBeGreaterThanOrEqual(0) + expect(recoveryEnd).toBeGreaterThan(recoveryStart) const timers: Array<() => void> = [] const addons: Array<{ @@ -74,8 +70,11 @@ function createWebglRecoveryHarness(failSecondAttach = false) { terminalThemeInput, window: { WebglAddon: { WebglAddon } } } - new Script(`${terminalHtmlSource.slice(variablesStart, variablesEnd)} -${TERMINAL_WEBGL_RECOVERY_JS} + new Script(`${documentScopePreamble()} +scope.term = term; +scope.terminalGeneration = terminalGeneration; +scope.terminalThemeInput = terminalThemeInput; +${terminalHtmlSource.slice(recoveryStart, recoveryEnd)} attachWebglAddon(true);`).runInNewContext(context) return { addons, @@ -154,14 +153,14 @@ describe('terminal WebView bundled engine', () => { it('reports WebView message handler failures instead of swallowing them', () => { const start = terminalHtmlSource.indexOf('function handleIncomingMessage') - const end = terminalHtmlSource.indexOf("window.addEventListener('resize'", start) + const end = terminalHtmlSource.indexOf('window.addEventListener("resize"', start) expect(start).toBeGreaterThanOrEqual(0) expect(end).toBeGreaterThan(start) const handlerSource = terminalHtmlSource.slice(start, end) expect(handlerSource).toContain('reportEngineError(') - expect(handlerSource).toContain("'terminal init failed'") - expect(handlerSource).toContain("'terminal message failed'") + expect(handlerSource).toContain('"terminal init failed"') + expect(handlerSource).toContain('"terminal message failed"') expect(handlerSource).not.toContain('catch(ex) {}') }) @@ -170,11 +169,11 @@ 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('var everReady = false;') - expect(terminalHtmlSource).toContain('everReady = true;') - expect(terminalHtmlSource).toContain('fatal === undefined ? !everReady : !!fatal') - expect(terminalHtmlSource).toContain("msg.type === 'init' && !everReady") - expect(terminalHtmlSource).not.toMatch(/fatal === undefined \? !ready\b/) + expect(terminalHtmlSource).toContain('scope.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') + expect(terminalHtmlSource).not.toMatch(/fatal === void 0 \? !scope\.ready\b/) }) it('bounds error capture and non-fatal reporting on a degraded engine', () => { @@ -235,7 +234,7 @@ describe('terminal WebView bundled engine', () => { }) it('answers native readiness probes from the live document', () => { - expect(terminalHtmlSource).toContain("if (msg.type === 'ping')") - expect(terminalHtmlSource).toContain("notify({ type: 'pong', pingId: msg.id })") + expect(terminalHtmlSource).toContain('if (msg.type === "ping")') + expect(terminalHtmlSource).toContain('notify({ type: "pong", pingId: msg.id })') }) }) diff --git a/mobile/src/terminal/terminal-webview-html-source.test-support.ts b/mobile/src/terminal/terminal-webview-html-source.test-support.ts deleted file mode 100644 index 19a9cfc07ba..00000000000 --- a/mobile/src/terminal/terminal-webview-html-source.test-support.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { readFileSync } from 'node:fs' - -const COMPOSER_FILE = './terminal-webview-html.ts' -const SLICE_IMPORT_RE = /^import \{[^}]*\} from '(\.\/terminal-webview-html\/[\w-]+)'$/gm -const COMPOSED_ENTRY_RE = /^ {2}TERMINAL_HTML_\w+,?$/gm - -function readSource(relativePath: string): string { - return readFileSync(new URL(relativePath, import.meta.url), 'utf8') -} - -/** - * Reads the TypeScript source that assembles the in-WebView document. - * - * Why: the slice list is derived from the composer's own imports rather than duplicated, so a - * new slice cannot join the emitted document while staying invisible to the tests that search - * this source. The count cross-check catches an import shape the regex cannot see. - */ -export function readTerminalWebViewHtmlSource(): string { - const composer = readSource(COMPOSER_FILE) - const slices = [...composer.matchAll(SLICE_IMPORT_RE)].map((match) => `${match[1]}.ts`) - const composedCount = [...composer.matchAll(COMPOSED_ENTRY_RE)].length - if (composedCount === 0) { - throw new Error('no composed WebView document slices found') - } - if (slices.length !== composedCount) { - throw new Error( - `WebView document slice imports (${slices.length}) do not match composed entries (${composedCount})` - ) - } - return [composer, ...slices.map(readSource)].join('\n') -} diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 17fadd4d26c..54769545deb 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -1,38 +1,16 @@ +import { TERMINAL_DOCUMENT_SCRIPT } from './terminal-webview-document-script.generated' +import { TERMINAL_HTML_DOCUMENT_CLOSE } from './terminal-webview-html/document-close' import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell' -import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from './terminal-webview-html/runtime-state-and-text-scaling' -import { TERMINAL_HTML_FIT_SCALE } from './terminal-webview-html/terminal-fit-scale' -import { TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN } from './terminal-webview-html/mouse-mode-decset-scan' -import { TERMINAL_HTML_WRITE_QUEUE } from './terminal-webview-html/write-queue' -import { TERMINAL_HTML_INIT_AND_WRITE } from './terminal-webview-html/terminal-init-and-write' -import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from './terminal-webview-html/host-message-router' -import { TERMINAL_HTML_SELECTION_STATE_AND_EVICTION } from './terminal-webview-html/selection-state-and-eviction' -import { TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING } from './terminal-webview-html/term-observers-and-mode-mirroring' -import { TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING } from './terminal-webview-html/mouse-report-and-scroll-routing' -import { TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY } from './terminal-webview-html/smooth-scroll-and-cell-geometry' -import { TERMINAL_HTML_SELECTION_OVERLAY } from './terminal-webview-html/selection-overlay' -import { TERMINAL_HTML_SURFACE_TOUCH_GESTURES } from './terminal-webview-html/surface-touch-gestures' -import { TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE } from './terminal-webview-html/message-bridge-and-document-close' export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme' -// Why: keep the document source stable while each script/style concern remains independently -// reviewable. Boundaries can only fall where the emitted document allows, so a few modules -// carry a second concern noted at the top of the file. +// Why: the script the WebView runs is generated from `src/terminal/document/`, the same modules the +// web page imports, so there is one source for both. The shell and the close are still text: they +// are markup, not program. export const XTERM_HTML = [ TERMINAL_HTML_DOCUMENT_SHELL, - TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING, - TERMINAL_HTML_FIT_SCALE, - TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN, - TERMINAL_HTML_WRITE_QUEUE, - TERMINAL_HTML_INIT_AND_WRITE, - TERMINAL_HTML_HOST_MESSAGE_ROUTER, - TERMINAL_HTML_SELECTION_STATE_AND_EVICTION, - TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING, - TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING, - TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY, - TERMINAL_HTML_SELECTION_OVERLAY, - TERMINAL_HTML_SURFACE_TOUCH_GESTURES, - TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE + TERMINAL_DOCUMENT_SCRIPT, + TERMINAL_HTML_DOCUMENT_CLOSE ].join('') export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML } diff --git a/mobile/src/terminal/terminal-webview-html/document-close.ts b/mobile/src/terminal/terminal-webview-html/document-close.ts new file mode 100644 index 00000000000..693edf2201e --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/document-close.ts @@ -0,0 +1,5 @@ +// Closes the document after the generated script. +export const TERMINAL_HTML_DOCUMENT_CLOSE = ` + + +` diff --git a/mobile/src/terminal/terminal-webview-html/document-shell.ts b/mobile/src/terminal/terminal-webview-html/document-shell.ts index d6e733cdd77..7dd869b1df9 100644 --- a/mobile/src/terminal/terminal-webview-html/document-shell.ts +++ b/mobile/src/terminal/terminal-webview-html/document-shell.ts @@ -161,13 +161,4 @@ window.onerror = function(msg) { - -` diff --git a/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts b/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts deleted file mode 100644 index 6f0685df87e..00000000000 --- a/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts +++ /dev/null @@ -1,52 +0,0 @@ -export const TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN = ` function isAltScreenActive(data) { - if (typeof data !== 'string') return false; - var on = data.lastIndexOf(ESC + '[?1049h'); - var off = data.lastIndexOf(ESC + '[?1049l'); - return on !== -1 && on > off; - } - - function normalizeInitialData(data) { - if (!isAltScreenActive(data)) return data; - var on = data.lastIndexOf(ESC + '[?1049h'); - // Why: SerializeAddon can include normal-buffer scrollback before the - // active alternate-screen snapshot. Replaying both into a fresh mobile - // xterm duplicates TUI frames and can flatten SGR attributes. - return on > 0 ? data.slice(on) : data; - } - - function updateMouseModeFromData(data) { - if (typeof data !== 'string' || data.length === 0) return; - var input = mouseModeScanTail + data; - mouseModeScanTail = extractMouseModeScanTail(input); - var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g'); - var match; - while ((match = re.exec(input)) !== null) { - if (match[0] === ESC + 'c') { - trackedMouseTrackingMode = 'none'; - sgrMouseMode = false; - sgrMousePixelsMode = false; - continue; - } - var enabled = (match[2] || match[4]) === 'h'; - var params = (match[1] || match[3]).split(';'); - for (var i = 0; i < params.length; i++) { - if (params[i] === '') continue; - var param = Number(params[i]); - if (!Number.isInteger(param)) continue; - if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none'; - if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none'; - if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none'; - if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none'; - if (param === 1006) { - sgrMouseMode = enabled; - sgrMousePixelsMode = false; - } - if (param === 1016) { - sgrMouseMode = false; - sgrMousePixelsMode = enabled; - } - } - } - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts b/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts deleted file mode 100644 index 3b7e7f24cf3..00000000000 --- a/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { TERMINAL_MOUSE_REPORT_CELL_JS } from '../terminal-webview-mouse-report-cell-injected' - -export const TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING = ` function viewportToCell(clientX, clientY) { - if (!term) return null; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - if (cellW <= 0 || cellH <= 0) return null; - var total = getTotalScale(); - if (total <= 0) total = 1; - var sx = (clientX - panX) / total; - var sy = (clientY - panY) / total; - var col = Math.floor(sx / cellW); - var viewportRow = Math.floor(sy / cellH); - if (col < 0) col = 0; - if (col > term.cols - 1) col = term.cols - 1; - if (viewportRow < 0) viewportRow = 0; - if (viewportRow > term.rows - 1) viewportRow = term.rows - 1; - var viewportY = term.buffer.active.viewportY; - return { col: col, row: viewportRow + viewportY }; - } - - ${TERMINAL_MOUSE_REPORT_CELL_JS} - - function isAlternateBufferActive() { - try { - return !!(term && term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'); - } catch (e) { - return false; - } - } - - function getMouseTrackingMode() { - try { - if (term && term.modes && typeof term.modes.mouseTrackingMode === 'string') { - var mode = term.modes.mouseTrackingMode; - if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') return mode; - return 'none'; - } - } catch (e) {} - if ( - trackedMouseTrackingMode === 'x10' || - trackedMouseTrackingMode === 'vt200' || - trackedMouseTrackingMode === 'drag' || - trackedMouseTrackingMode === 'any' - ) { - return trackedMouseTrackingMode; - } - return 'none'; - } - - function repeatSequence(sequence, count) { - var out = ''; - for (var i = 0; i < count; i++) out += sequence; - return out; - } - - function buildArrowScrollSequence(lines) { - var prefix = '['; - try { - if (term && term.modes && term.modes.applicationCursorKeysMode) prefix = 'O'; - } catch (e) {} - return ESC + prefix + (lines < 0 ? 'A' : 'B'); - } - - function buildMouseWheelSequence(lines, clientX, clientY) { - var cell = viewportToMouseReportCell(clientX, clientY); - if (!cell) return ''; - var eventCode = lines < 0 ? 64 : 65; - if (sgrMousePixelsMode) { - if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; - return ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M'; - } - if (sgrMouseMode) { - // Why: xterm increments zero-based mouse cells before encoding reports. - var sgrCol = cell.col + 1; - var sgrRow = cell.row + 1; - if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; - return ESC + '[<' + eventCode + ';' + sgrCol + ';' + sgrRow + 'M'; - } - // Why: xterm increments zero-based mouse cells before encoding reports. - var button = eventCode + 32; - var col = cell.col + 1 + 32; - var row = cell.row + 1 + 32; - // Why: non-SGR mouse bytes above ASCII are not preserved reliably through - // the mobile JSON/RPC string path. Fall back to keys for wide terminals. - if (button > 126 || col > 126 || row > 126) return ''; - return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); - } - - function isSafeSgrMouseCoordinate(value) { - return Number.isInteger(value) && value >= 0 && value <= 9999; - } - - function buildMouseClickInput(clientX, clientY) { - var mouseTrackingMode = getMouseTrackingMode(); - if (!isClickMouseTrackingMode(mouseTrackingMode)) return ''; - var cell = viewportToMouseReportCell(clientX, clientY); - if (!cell) return ''; - if (sgrMousePixelsMode) { - // Why: xterm 1016 keeps SGR syntax but reports raw zero-based pixel positions. - var pixelX = cell.x; - var pixelY = cell.y; - if (!isSafeSgrMouseCoordinate(pixelX) || !isSafeSgrMouseCoordinate(pixelY)) return ''; - var pixelPress = ESC + '[<0;' + pixelX + ';' + pixelY + 'M'; - if (mouseTrackingMode === 'x10') return pixelPress; - return pixelPress + ESC + '[<0;' + pixelX + ';' + pixelY + 'm'; - } - if (sgrMouseMode) { - // Why: xterm increments zero-based mouse cells before encoding reports. - var sgrCol = cell.col + 1; - var sgrRow = cell.row + 1; - if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; - var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'; - if (mouseTrackingMode === 'x10') return sgrPress; - return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'; - } - // Why: non-SGR click coordinates use printable ASCII bytes on the mobile - // bridge; unsafe wide-terminal cells must not turn into corrupted input. - var col = cell.col + 1 + 32; - var row = cell.row + 1 + 32; - if (col > 126 || row > 126) return ''; - var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row); - if (mouseTrackingMode === 'x10') return press; - return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row); - } - - function isClickMouseTrackingMode(mode) { - return mode !== 'none'; - } - - function isWheelMouseTrackingMode(mode) { - return mode !== 'none' && mode !== 'x10'; - } - - function shouldRouteScrollToTerminalInput() { - return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive(); - } - - function buildMouseWheelScrollInput(lines, clientX, clientY) { - var count = Math.min(Math.abs(lines), 32); - if (count === 0) return ''; - var sequence = buildMouseWheelSequence(lines, clientX, clientY); - if (!sequence) return ''; - return repeatSequence(sequence, count); - } - - function buildTuiScrollInput(lines, clientX, clientY) { - var count = Math.min(Math.abs(lines), 32); - if (count === 0) return ''; - var mouseTrackingMode = getMouseTrackingMode(); - var sequence = ''; - if (isWheelMouseTrackingMode(mouseTrackingMode)) { - sequence = buildMouseWheelSequence(lines, clientX, clientY); - } - if (!sequence) sequence = buildArrowScrollSequence(lines); - return repeatSequence(sequence, count); - } - - function routeScrollLines(lines, clientX, clientY) { - if (!term || lines === 0) return; - var mouseTrackingMode = getMouseTrackingMode(); - var alternateBufferActive = isAlternateBufferActive(); - if (isWheelMouseTrackingMode(mouseTrackingMode)) { - // Why: xterm sends wheel events to mouse-aware TUIs before considering - // scrollback, even if the app stays on the normal buffer. - var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY); - if (mouseInput) { - notify({ type: 'terminal-input', bytes: mouseInput }); - return; - } - // Why: default mouse encoding can be unrepresentable in our ASCII-safe - // RPC path on wide terminals. Send bounded arrows instead of local - // scrollback/no-op while a mouse-aware app owns scroll gestures. - var fallbackInput = buildTuiScrollInput(lines, clientX, clientY); - if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput }); - return; - } - if (alternateBufferActive) { - // Why: alternate-screen TUIs own their scroll state and xterm has no - // scrollback there, so mobile scroll gestures must become terminal input. - var input = buildTuiScrollInput(lines, clientX, clientY); - if (input) notify({ type: 'terminal-input', bytes: input }); - return; - } - term.scrollLines(lines); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts b/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts deleted file mode 100644 index 44ce1d39042..00000000000 --- a/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { TERMINAL_QUERY_REPLY_JS } from '../terminal-webview-query-reply-injected' -import { TERMINAL_SURFACE_SWAP_JS } from '../terminal-webview-surface-swap-injected' -import { TERMINAL_TEXT_SCALES } from '../../storage/preferences' -import { DEFAULT_TERMINAL_THEME } from './theme' - -// Also carries the scroll-indicator painter, which reads the scale state declared here. -export const TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING = ` var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; - var term = null; ${TERMINAL_QUERY_REPLY_JS} - ${TERMINAL_SURFACE_SWAP_JS} - var scrollIndicator = document.getElementById('scroll-indicator'); - var scrollThumb = document.getElementById('scroll-thumb'); - var scrollIndicatorHideTimer = null; - var writeQueue = []; - var writeQueueHead = 0; - var writesDraining = false; - var afterDrainCallbacks = []; - var termObserverDisposables = []; - var ready = false; - // 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. - var everReady = false; - var currentScale = 1; - // Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a - // gesture only; it resets to 1 on release. The persistent "text size" is the - // real xterm fontSize (currentTextScale × BASE_FONT_PX), so changing it - // reflows the grid: a bigger cell means fewer columns fit, and RN re-measures - // and resizes the PTY (terminal.updateViewport) so the shell rewraps to the - // new width. A finished pinch snaps to the nearest preset and reports it to RN. - var userScale = 1; - var BASE_FONT_PX = 13; - var MIN_FONT_PX = 6; - var MIN_FIT_COLS = 20; - var currentTextScale = 1; - var TEXT_SCALE_PRESETS = ${JSON.stringify([...TERMINAL_TEXT_SCALES])}; - var MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0]; - var MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1]; - function snapToTextScalePreset(value) { - var best = TEXT_SCALE_PRESETS[0], bestDelta = Infinity; - for (var i = 0; i < TEXT_SCALE_PRESETS.length; i++) { - var delta = Math.abs(TEXT_SCALE_PRESETS[i] - value); - if (delta < bestDelta) { bestDelta = delta; best = TEXT_SCALE_PRESETS[i]; } - } - return best; - } - function fontPxForScale(scale) { - return Math.max(MIN_FONT_PX, Math.round(BASE_FONT_PX * scale)); - } - function isIOSWebView() { - if (/iP(ad|hone|od)/.test(navigator.userAgent)) return true; - return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1; - } - // Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can - // fall to a non-monospace face; lead with the ui-monospace generic to avoid that. - var TERMINAL_FONT_FALLBACKS = '"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace'; - var terminalFontFamily = (isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS; - // Why: change the real font size, then resize the grid to fit the viewport at - // the new cell metrics so the text shows at its true size immediately. RN's - // refit (measure → updateViewport) then makes the server reflow the PTY to the - // same column count so the shell rewraps. cell metrics update on the frame - // after fontSize changes, so the resize/fit is deferred one rAF. - function applyTextScale(scale) { - currentTextScale = scale; - if (!term) return; - var px = fontPxForScale(scale); - if (term.options.fontSize === px) return; - term.options.fontSize = px; - requestAnimationFrame(function() { - if (!term) return; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - if (cellW > 0 && cellH > 0) { - var cols = Math.floor(window.innerWidth / cellW); - if (cols < MIN_FIT_COLS) return; - var rows = Math.max(8, Math.floor(window.innerHeight / cellH)); - term.resize(cols, rows); - emitKeyboardAvoidanceMetrics(); - } - applyFitScale('text-scale'); - }); - } - var panX = 0, panY = 0; - var smoothScrollOffsetY = 0; - var pendingNormalScrollDeltaY = 0; - var normalScrollFrameId = null; - var initRows = 24; - var terminalGeneration = 0; - var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)}; - var terminalThemeInput = null; - var terminalTheme = defaultTheme; - var terminalMinimumContrastRatio = 3; - var webglAddon = null; - var webglRecoveryTimer = null; - var activeAltScreenSnapshot = false; - var trackedMouseTrackingMode = 'none'; - var sgrMouseMode = false; - var sgrMousePixelsMode = false; - var initialOscLinks = [], initialOscLinkRowOffset = 0; - var initialOscLinkEvictionReady = false; - var mouseModeScanTail = ''; - var handledMessageIds = []; - // Why: after init() the initial scrollback applyFitScale may have run - // against an empty buffer (or one without the widest line yet). Re-fit - // once when the first live data chunk arrives so a wider line that pushes - // scrollWidth past the previously-measured value gets re-scaled to fit. - var firstDataPending = false; - - // Diagnostic logger — bridges WebView console.log to RN via postMessage. - // Tag with [fit] so it's easy to filter in the Expo/Metro logs. - function flog(tag, payload) { - try { - if (window.ReactNativeWebView) { - window.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'log', tag: '[fit]' + tag, payload: payload - })); - } - } catch (e) {} - } - - function getCellWidth() { - if (!term || !term._core) return 0; - var core = term._core; - if (core._renderService && core._renderService.dimensions) { - return core._renderService.dimensions.css.cell.width || 0; - } - return 0; - } - - // Why: width measurement strategy. - // 1. Prefer cellWidth × term.cols — this is what xterm's renderer uses - // to lay out and is independent of buffer content. It's the "logical - // width" of the terminal grid. - // 2. Fall back to term.element.scrollWidth — the actual rendered DOM - // width — only when cellWidth isn't available yet (renderer not - // initialized). This is content-dependent (reflects widest row), - // but better than nothing. - // 3. If both are 0, return 1 (no scale change). The retry loop in - // applyFitScale will keep trying until one is positive. - function computeFitScale() { - if (!term) return 1; - var cellW = getCellWidth(); - var termWidth = cellW > 0 ? cellW * term.cols : (term.element ? term.element.scrollWidth : 0); - if (termWidth <= 0) return 1; - var vpWidth = window.innerWidth; - return Math.min(1, vpWidth / termWidth); - } - - function getTotalScale() { return currentScale * userScale; } - - function updateTransform() { - surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')'; - updateScrollIndicator(false); - if (selMode === 'select') repositionOverlay(); - } - - function updateScrollIndicator(reveal) { - if (!scrollIndicator || !scrollThumb || !term || !term.buffer || !term.buffer.active) return; - var buffer = term.buffer.active; - var maxViewportY = buffer.baseY || 0; - if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) { - scrollIndicator.classList.remove('visible'); - return; - } - var trackHeight = Math.max(0, window.innerHeight - 8); - var totalRows = maxViewportY + (term.rows || 0); - if (trackHeight <= 0 || totalRows <= 0) return; - var thumbHeight = Math.max(24, trackHeight * (term.rows || 0) / totalRows); - var maxTop = Math.max(0, trackHeight - thumbHeight); - var top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0; - scrollThumb.style.height = thumbHeight + 'px'; - scrollThumb.style.transform = 'translateY(' + top + 'px)'; - if (!reveal) return; - scrollIndicator.classList.add('visible'); - if (scrollIndicatorHideTimer) clearTimeout(scrollIndicatorHideTimer); - scrollIndicatorHideTimer = setTimeout(function() { - scrollIndicator.classList.remove('visible'); - scrollIndicatorHideTimer = null; - }, 550); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/selection-overlay.ts b/mobile/src/terminal/terminal-webview-html/selection-overlay.ts deleted file mode 100644 index 335407af51a..00000000000 --- a/mobile/src/terminal/terminal-webview-html/selection-overlay.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { TERMINAL_PATH_TAP_JS } from '../terminal-path-tap-injected' -import { URL_TAP_WEBVIEW_JS } from '../terminal-webview-url-tap' - -// Opens with the path/url tap matchers: they land at this point in the emitted document. -export const TERMINAL_HTML_SELECTION_OVERLAY = ` ${TERMINAL_PATH_TAP_JS} - ${URL_TAP_WEBVIEW_JS} - - function seedWordSelection(col, absRow) { - var line = getLineText(absRow); - if (!line) { - sel = { anchor: { col: col, row: absRow }, focus: { col: col, row: absRow }, activeHandle: null }; - applyXtermSelection(); - return; - } - var s = col; - var e = col; - if (col >= 0 && col < line.length && WORD_RE.test(line[col])) { - while (s > 0 && WORD_RE.test(line[s - 1])) s--; - while (e < line.length - 1 && WORD_RE.test(line[e + 1])) e++; - } - sel = { - anchor: { col: s, row: absRow }, - focus: { col: e, row: absRow }, - activeHandle: null - }; - applyXtermSelection(); - } - - function isStartFirst(a, b) { - if (a.row !== b.row) return a.row < b.row; - return a.col <= b.col; - } - - function selRange() { - if (!sel) return null; - if (isStartFirst(sel.anchor, sel.focus)) return { start: sel.anchor, end: sel.focus }; - return { start: sel.focus, end: sel.anchor }; - } - - function applyXtermSelection() { - if (!term || !sel) return; - var r = selRange(); - if (!r) return; - // Why: term.select(col, row, length) takes a buffer-absolute row, - // not a viewport-relative one. Subtracting viewportY here drifts the - // selection by the scrollback height — handles render where the user - // pressed (their math is independent), but xterm highlights an - // off-screen scrollback region and copies the wrong text. - var length; - if (r.start.row === r.end.row) { - length = Math.max(1, r.end.col - r.start.col + 1); - } else { - var first = term.cols - r.start.col; - var middle = Math.max(0, r.end.row - r.start.row - 1) * term.cols; - var last = r.end.col + 1; - length = first + middle + last; - } - try { term.select(r.start.col, r.start.row, length); } catch (e) {} - } - - function cancelSelect() { - selMode = 'navigate'; - sel = null; - stopEdgeScroll(); - if (term) { - try { term.clearSelection(); } catch (e) {} - // Why: some xterm renderers cache cells and skip repaint on - // clearSelection alone, leaving the previously-highlighted cells - // visually selected. Force a full refresh so the selection layer - // actually clears on screen. - try { term.refresh(0, term.rows - 1); } catch (e) {} - } - selectionOverlay.classList.remove('active'); - notify({ type: 'set-select-mode', enabled: false }); - } - - function enterSelect(col, absRow) { - selMode = 'select'; - seedWordSelection(col, absRow); - selectionOverlay.classList.add('active'); - notify({ type: 'set-select-mode', enabled: true }); - notify({ type: 'haptic', kind: 'selection' }); - repositionOverlay(); - } - - function repositionOverlay() { - if (selMode !== 'select' || !sel || !term) return; - var r = selRange(); - var sPx = cellToViewportPx(r.start.col, r.start.row); - var ePx = cellToViewportPx(r.end.col + 1, r.end.row); - var cellH = getCellHeight() * getTotalScale(); - // Why: native iOS pattern — start handle anchors at the TOP of the - // first selected cell (dot above, stem covers the cell going down); - // end handle anchors at the BOTTOM of the last selected cell (dot - // below, stem covers the cell going up). - handleStart.style.left = sPx.x + 'px'; - handleStart.style.top = sPx.y + 'px'; - handleEnd.style.left = ePx.x + 'px'; - handleEnd.style.top = (ePx.y + cellH) + 'px'; - var startVisible = sPx.y >= 0 && sPx.y <= window.innerHeight; - var endVisible = ePx.y >= 0 && ePx.y <= window.innerHeight; - handleStart.style.visibility = startVisible ? 'visible' : 'hidden'; - handleEnd.style.visibility = endVisible ? 'visible' : 'hidden'; - var menuCenterX, menuY, vTransform, marginTop; - if (startVisible && sPx.y > 56) { - menuCenterX = sPx.x; menuY = sPx.y; - vTransform = 'translateY(-100%)'; - marginTop = '-12px'; - } else if (endVisible && ePx.y + cellH + 56 < window.innerHeight) { - menuCenterX = ePx.x; menuY = ePx.y + cellH; - vTransform = 'translateY(0)'; - marginTop = '12px'; - } else { - // selection covers full viewport — pin to visible center - menuCenterX = window.innerWidth / 2; - menuY = window.innerHeight / 2; - vTransform = 'translateY(-50%)'; - marginTop = '0'; - } - // Why: clamp horizontally so the pill stays fully visible when the - // selection sits near a screen edge. We position via plain left - // (no horizontal translate) so the clamp math is straightforward. - selMenu.style.transform = vTransform; - selMenu.style.marginTop = marginTop; - selMenu.style.top = menuY + 'px'; - selMenu.style.left = '0px'; - var EDGE_MARGIN = 8; - var menuW = selMenu.offsetWidth || 0; - var minLeft = EDGE_MARGIN; - var maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - menuW - EDGE_MARGIN); - var desiredLeft = menuCenterX - menuW / 2; - var clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft)); - selMenu.style.left = clampedLeft + 'px'; - } - - function syncSelectionHandleToViewportPoint(handle, clientX, clientY) { - var c = viewportToCell(clientX, clientY); - if (!c || !sel) return false; - if (handle === 'start') sel.anchor = c; - else sel.focus = c; - applyXtermSelection(); - return true; - } - - function syncEdgeScrollSelectionEndpoint() { - if (!sel || !sel.activeHandle) return false; - // Why: WebView may not emit new touchmove events while a handle is held - // at the edge; resample the stored finger point after each viewport scroll. - return syncSelectionHandleToViewportPoint( - sel.activeHandle, - edgeScrollClientX, - edgeScrollClientY - ); - } - - function startEdgeScroll(dir) { - if (edgeScrollDir === dir) return; - stopEdgeScroll(); - edgeScrollDir = dir; - edgeScrollTimer = setInterval(function() { - if (!term || edgeScrollDir === 0) return; - var beforeY = term.buffer.active.viewportY; - term.scrollLines(edgeScrollDir); - var afterY = term.buffer.active.viewportY; - if (beforeY === afterY) { - notify({ type: 'haptic', kind: 'edge-bump' }); - stopEdgeScroll(); - return; - } - syncEdgeScrollSelectionEndpoint(); - repositionOverlay(); - }, EDGE_SCROLL_INTERVAL); - } - - function stopEdgeScroll() { - if (edgeScrollTimer) { - clearInterval(edgeScrollTimer); - edgeScrollTimer = null; - } - edgeScrollDir = 0; - } - - function handleDragMove(handle, clientX, clientY) { - edgeScrollClientX = clientX; - edgeScrollClientY = clientY; - if (!syncSelectionHandleToViewportPoint(handle, clientX, clientY)) return; - repositionOverlay(); - if (clientY < EDGE_SCROLL_PX) startEdgeScroll(-1); - else if (clientY > window.innerHeight - EDGE_SCROLL_PX) startEdgeScroll(1); - else stopEdgeScroll(); - } - - // Latching document-level touch dispatcher: see - // terminal-webview-tap-dispatch-injected.ts (extracted for max-lines). -` diff --git a/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts b/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts deleted file mode 100644 index 48c05b6ad85..00000000000 --- a/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts +++ /dev/null @@ -1,71 +0,0 @@ -export const TERMINAL_HTML_SELECTION_STATE_AND_EVICTION = ` // ============================================================ - // SELECTION MODE (long-press → handles → Copy) - // ============================================================ - var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u; - var LONG_PRESS_MS = 500; - var LONG_PRESS_SLOP = 10; - // Why: a tap that opens a link/path must survive small finger jitter. The - // long-press slop (10px) only cancels the press-to-select timer; reusing it - // to gate the tap dropped any URL/file tap that wandered >10px — at fit scale - // a few screen px of jitter is a normal tap. Use a wider, time-bounded tap - // window so deliberate scrolls/pans still don't fire a tap. - var TAP_SLOP = 24; - var TAP_MAX_MS = 700; - var EDGE_SCROLL_PX = 40; - var EDGE_SCROLL_INTERVAL = 60; - - var selectionOverlay = document.getElementById('selection-overlay'); - var handleStart = document.getElementById('sel-handle-start'); - var handleEnd = document.getElementById('sel-handle-end'); - var selMenu = document.getElementById('sel-menu'); - var btnCopy = document.getElementById('sel-menu-copy'); - var btnSelAll = document.getElementById('sel-menu-all'); - - // mode: 'navigate' | 'select' - var selMode = 'navigate'; - var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' } - var longPressTimer = null; - var longPressOrigin = null; // {x,y, identifier} - // Why: tap detection is tracked separately from the long-press timer so a - // small jitter that cancels the press-to-select timer does not also cancel - // 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). - var tapCandidate = null; - var edgeScrollTimer = null; - var edgeScrollDir = 0; - var edgeScrollClientX = 0; - var edgeScrollClientY = 0; - - // Eviction watchdog: linesEverWritten counts onLineFeed since last init. - // Once buffer is full, every onLineFeed evicts the top row in xterm and - // we mirror that by decrementing stored absolute rows. - var linesEverWritten = 0; - - function resetEvictionCounter() { linesEverWritten = 0; } - - function isBufferFull() { - if (!term) return false; - return linesEverWritten >= 5000 + (term.rows || 0); - } - - function checkEviction() { - if (selMode !== 'select' || !sel) return; - var oldest = Math.min(sel.anchor.row, sel.focus.row); - if (oldest < 0) { - notify({ type: 'selection-evicted' }); - cancelSelect(); - } - } - - function logFeedAndEvict() { - linesEverWritten++; - if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1; - if (selMode === 'select' && sel && isBufferFull()) { - sel.anchor.row -= 1; - sel.focus.row -= 1; - checkEviction(); - repositionOverlay(); - } - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts b/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts deleted file mode 100644 index de9db152fbf..00000000000 --- a/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts +++ /dev/null @@ -1,110 +0,0 @@ -export const TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY = ` function clampNormalScrollLines(lines) { - if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0; - var buffer = term.buffer.active; - if (lines > 0) { - return Math.min(lines, Math.max(0, buffer.baseY - buffer.viewportY)); - } - return Math.max(lines, -buffer.viewportY); - } - - function canScrollNormalBufferDelta(deltaY) { - if (!term || !term.buffer || !term.buffer.active || deltaY === 0) return false; - var buffer = term.buffer.active; - if (deltaY > 0) return buffer.viewportY < buffer.baseY; - return buffer.viewportY > 0; - } - - function applyNormalBufferScrollDelta(deltaY) { - if (!term || deltaY === 0) return false; - var effectiveCellH = getCellHeight() * getTotalScale(); - if (effectiveCellH <= 0) return false; - if (!canScrollNormalBufferDelta(deltaY)) { - resetSmoothScrollOffset(); - return false; - } - smoothScrollOffsetY -= deltaY; - var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH); - if (lines !== 0) { - var applied = clampNormalScrollLines(lines); - if (applied !== 0) { - term.scrollLines(applied); - // Why: xterm's renderer is row-based. Buffer touch pixels and only - // commit whole rows so TUI canvas layers do not shimmer between - // fractional transforms and xterm repaints. - smoothScrollOffsetY += applied * effectiveCellH; - } - if (applied !== lines) smoothScrollOffsetY = 0; - } - var limit = effectiveCellH - 1; - if (smoothScrollOffsetY > limit) smoothScrollOffsetY = limit; - if (smoothScrollOffsetY < -limit) smoothScrollOffsetY = -limit; - updateScrollIndicator(true); - return true; - } - - function enqueueNormalBufferScrollDelta(deltaY) { - if (!term || deltaY === 0) return false; - if (!canScrollNormalBufferDelta(deltaY)) { - resetSmoothScrollOffset(); - return false; - } - pendingNormalScrollDeltaY += deltaY; - if (normalScrollFrameId !== null) return true; - // 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. - normalScrollFrameId = requestAnimationFrame(function() { - normalScrollFrameId = null; - var delta = pendingNormalScrollDeltaY; - pendingNormalScrollDeltaY = 0; - if (!applyNormalBufferScrollDelta(delta)) { - resetSmoothScrollOffset(); - } - }); - return true; - } - - function resetSmoothScrollOffset() { - pendingNormalScrollDeltaY = 0; - if (normalScrollFrameId !== null) { - cancelAnimationFrame(normalScrollFrameId); - normalScrollFrameId = null; - } - if (smoothScrollOffsetY === 0) return; - smoothScrollOffsetY = 0; - updateScrollIndicator(false); - } - - function cellToViewportPx(col, absRow) { - if (!term) return { x: 0, y: 0 }; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - var viewportRow = absRow - term.buffer.active.viewportY; - var sx = col * cellW; - var sy = viewportRow * cellH; - var total = getTotalScale(); - return { x: sx * total + panX, y: sy * total + panY }; - } - - function getLineText(absRow) { - if (!term) return ''; - var line = term.buffer.active.getLine(absRow); - if (!line) return ''; - return line.translateToString(false); - } - - // Why: getLineText collapses wide chars (emoji, CJK) to one string char, so a - // tap's CELL column no longer equals the STRING index that url/path matchers use. - // Convert by measuring the string length up to the tapped cell (the count of - // string chars before it). Without this, taps on lines with a leading wide char - // (e.g. agent output prefixed with ⏺) resolve to the wrong column and miss. - function cellColToStringIndex(absRow, col) { - if (!term) return col; - var line = term.buffer.active.getLine(absRow); - if (!line) return col; - return line.translateToString(false, 0, col).length; - } - - // File-path-under-tap detection (matchFilePathAtColumn). See - // terminal-path-tap-injected.ts; mirrors the unit-tested terminal-path-tap.ts. -` diff --git a/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts b/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts deleted file mode 100644 index 515d7fd9d26..00000000000 --- a/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { TERMINAL_TAP_DISPATCH_JS } from '../terminal-webview-tap-dispatch-injected' -import { TERMINAL_WHEEL_SCROLL_JS } from '../terminal-webview-wheel-scroll-injected' -import { TERMINAL_MOUSE_CLICK_DRAG_JS } from '../terminal-webview-mouse-click-drag-injected' - -// Also wires the selection menu's Copy/Select All buttons, which sit here in the emitted document. -export const TERMINAL_HTML_SURFACE_TOUCH_GESTURES = ` ${TERMINAL_TAP_DISPATCH_JS} - - // External mouse / trackpad scroll: see - // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines). - ${TERMINAL_WHEEL_SCROLL_JS} - - // External mouse click/drag: see - // terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines). - ${TERMINAL_MOUSE_CLICK_DRAG_JS} - - btnCopy.addEventListener('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - if (!term) return; - var text = term.getSelection ? term.getSelection() : ''; - if (text && text.length > 0) { - notify({ type: 'selection', text: text }); - } else { - cancelSelect(); - } - }); - - btnSelAll.addEventListener('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - if (!term) return; - try { - term.selectAll(); - var b = term.buffer.active; - sel = { - anchor: { col: 0, row: 0 }, - focus: { col: term.cols - 1, row: b.length - 1 }, - activeHandle: null - }; - repositionOverlay(); - } catch (err) {} - }); - - var 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; - var instantVelocity = deltaY / dt; - if (!isFinite(instantVelocity)) return; - // 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; - } - - function getDistance(a, b) { - var dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; - return Math.sqrt(dx * dx + dy * dy); - } - - function attachSurfaceEventHandlers(targetSurface) { - if (!targetSurface || targetSurface.__orcaSurfaceHandlersAttached) return; - targetSurface.__orcaSurfaceHandlersAttached = true; - // Why: init() swaps in a new hidden surface to avoid flicker; each - // replacement needs gesture handlers or tab-switch replays stop scrolling. - targetSurface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true); - targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true); - - attachSurfaceWheelHandler(targetSurface); - attachSurfaceMouseClickDragHandler(targetSurface); - - targetSurface.addEventListener('touchstart', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (ts.momentumId) { - cancelAnimationFrame(ts.momentumId); - ts.momentumId = null; - } - if (e.touches.length === 2) { - ts.isPinching = true; - smoothScrollOffsetY = 0; - ts.pinchDist = getDistance(e.touches[0], e.touches[1]); - ts.pinchScale = userScale; - var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; - var my = (e.touches[0].clientY + e.touches[1].clientY) / 2; - var total = getTotalScale(); - ts.pinchSurfX = (mx - panX) / total; - ts.pinchSurfY = (my - 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; - } - }, { capture: true, passive: true }); - - targetSurface.addEventListener('touchmove', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - e.preventDefault(); - e.stopPropagation(); - - if (e.touches.length === 2) { - ts.isPinching = true; - var dist = getDistance(e.touches[0], e.touches[1]); - var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; - var my = (e.touches[0].clientY + e.touches[1].clientY) / 2; - - var ratio = dist / ts.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. - var loScale = MIN_TEXT_SCALE / currentTextScale; - var hiScale = MAX_TEXT_SCALE / currentTextScale; - userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio)); - - var total = getTotalScale(); - panX = mx - ts.pinchSurfX * total; - panY = my - ts.pinchSurfY * total; - clampPan(); - updateTransform(); - - } else if (e.touches.length === 1 && !ts.isPinching) { - var x = e.touches[0].clientX, y = e.touches[0].clientY; - var now = Date.now(), dt = now - ts.lastTime; - - // Why: pan horizontally only when content overflows the viewport (larger - // than fit) — same check clampPan() uses. Vertical always drives buffer - // scroll so scrollback stays reachable at any text size; calling the - // never-defined contentWiderThanViewport() here threw and killed all - // single-finger scrolling, scrollback included. - if (term.element && term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) { - panX += x - ts.lastX; - clampPan(); - updateTransform(); - } - - var deltaY = ts.lastY - y; - ts.lastTime = now; - if (shouldRouteScrollToTerminalInput()) { - updateTouchVelocity(deltaY, dt); - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += deltaY; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, x, y); - } - } else { - if (enqueueNormalBufferScrollDelta(deltaY)) { - updateTouchVelocity(deltaY, dt); - } else { - ts.velY = 0; - } - } - ts.lastX = x; - ts.lastY = y; - } - }, { capture: true, passive: false }); - - targetSurface.addEventListener('touchend', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - - if (ts.isPinching && e.touches.length < 2) { - ts.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 - // size change reflows columns and RN persists + resizes the PTY to match. - var target = snapToTextScalePreset(currentTextScale * userScale); - var changed = target !== currentTextScale; - userScale = 1; - panX = 0; panY = 0; - applyTextScale(target); - updateTransform(); - notify({ type: 'font-scale-changed', fontScale: target }); - if (changed) 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; - } - return; - } - - if (e.touches.length === 0) { - var vel = ts.velY; - var FRICTION = 0.972; - var MIN_VEL = 0.012; - function momentumStep() { - vel *= FRICTION; - if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } - var delta = vel * 16; - if (shouldRouteScrollToTerminalInput()) { - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - ts.accumDelta += delta; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - routeScrollLines(lines, ts.lastX, ts.lastY); - } - } else { - if (!applyNormalBufferScrollDelta(delta)) { - ts.momentumId = null; - return; - } - } - ts.momentumId = requestAnimationFrame(momentumStep); - } - if (Math.abs(vel) > MIN_VEL) { - ts.momentumId = requestAnimationFrame(momentumStep); - } - } - }, { capture: true, passive: true }); - } - - attachSurfaceEventHandlers(surface); - -` diff --git a/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts b/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts deleted file mode 100644 index b69547affb5..00000000000 --- a/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from '../terminal-keyboard-avoidance-metrics-injected' - -export const TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING = ` function emitModesIfChanged() { - if (!term) return; - var bp = !!(term.modes && term.modes.bracketedPasteMode); - var alt = false; - var mouseTrackingMode = getMouseTrackingMode(); - try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} - if ( - bp !== lastEmittedModes.bracketedPasteMode || - alt !== lastEmittedModes.altScreen || - mouseTrackingMode !== lastEmittedModes.mouseTrackingMode || - sgrMouseMode !== lastEmittedModes.sgrMouseMode || - sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode - ) { - lastEmittedModes = { - bracketedPasteMode: bp, - altScreen: alt, - mouseTrackingMode: mouseTrackingMode, - sgrMouseMode: sgrMouseMode, - sgrMousePixelsMode: sgrMousePixelsMode - }; - notify({ - type: 'modes', - bracketedPasteMode: bp, - altScreen: alt, - mouseTrackingMode: mouseTrackingMode, - sgrMouseMode: sgrMouseMode, - sgrMousePixelsMode: sgrMousePixelsMode - }); - } - } - var lastEmittedModes = { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false - }; - - ${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS} - - function attachTermObservers() { - if (!term) return; - disposeTermObservers(); - try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {} - try { - termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); })); - } catch (e) {} - // Why: emit modes on every parsed write so RN's mirror stays current - // without round-trip; covers \\x1b[?2004h/l and alt-screen toggles. - try { - if (term.onWriteParsed) { - termObserverDisposables.push(term.onWriteParsed(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - })); - } - } catch (e) {} - // Initial emit once buffer settles. - afterWritesDrained(function() { - emitModesIfChanged(); - emitKeyboardAvoidanceMetrics(); - }); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts b/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts deleted file mode 100644 index b756bcb550c..00000000000 --- a/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected' - -// Opens with the injected theme block: it lands at this point in the emitted document. -export const TERMINAL_HTML_FIT_SCALE = `${TERMINAL_WEBVIEW_THEME_JS} - - function getCellHeight() { - if (!term || !term._core) return 15; - var core = term._core; - if (core._renderService && core._renderService.dimensions) { - return core._renderService.dimensions.css.cell.height || 15; - } - return 15; - } - - // Why: clamp pan so the terminal content always covers the viewport - // when zoomed in. When content is smaller than viewport in a - // dimension, pin to top-left (no floating in the middle). - function clampPan() { - if (!term || !term.element) return; - var ts = getTotalScale(); - var cw = term.element.scrollWidth * ts; - var ch = term.element.scrollHeight * ts; - var vpW = window.innerWidth; - var vpH = window.innerHeight; - if (cw > vpW) { - panX = Math.min(0, Math.max(vpW - cw, panX)); - } else { - panX = 0; - } - if (ch > vpH) { - panY = Math.min(0, Math.max(vpH - ch, panY)); - } else { - panY = 0; - } - } - - // Why: intentional no-op. Mobile replays a live PTY snapshot then applies - // live cursor-relative chunks from that same PTY; resizing only the WebView - // xterm changes cursor coordinates and makes TUI repaint chunks duplicate or - // overlap. Kept as a no-op so its call sites stay legible. - function adjustRowsForViewport() {} - - // Why: cold-start fit. After init() opens xterm, the renderer needs - // several frames before cell dimensions are computed. Reading too early - // gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM - // not laid out), and computeFitScale returns 1 → no zoom. - // - // Gate: cellWidth × cols is the canonical "logical width" of the grid - // and reflects xterm's layout decision, independent of buffer content. - // We commit when cellWidth becomes positive (renderer ready). Fallback: - // if cellWidth never becomes available, gate on stable positive - // scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz) - // so a backgrounded WebView never spins forever. - var FIT_RETRY_MAX_FRAMES = 60; - var fitRetryToken = 0; - function applyFitScale(reason) { - if (!term || !term.element) return; - var token = ++fitRetryToken; - var attempts = 0; - var lastScrollWidth = -1; - function attempt() { - if (token !== fitRetryToken) return; - if (!term || !term.element) return; - attempts++; - var cellW = getCellWidth(); - if (cellW > 0 && term.cols > 0) { - commitFitScale(reason, attempts, 'cellW'); - return; - } - var w = term.element.scrollWidth; - if (w > 0 && w === lastScrollWidth) { - commitFitScale(reason, attempts, 'stableSW'); - return; - } - lastScrollWidth = w; - if (attempts >= FIT_RETRY_MAX_FRAMES) { - flog('commit-timeout', { - reason: reason, - attempts: attempts, - cellW: cellW, - scrollWidth: w, - cols: term.cols - }); - commitFitScale(reason, attempts, 'timeout'); - return; - } - requestAnimationFrame(attempt); - } - requestAnimationFrame(attempt); - } - - function commitFitScale(reason, attempts, gate) { - if (!term || !term.element) return; - var preSnapScale = computeFitScale(); - currentScale = preSnapScale; - // Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar - // sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents - // a second applyFitScale from observing a "no-op needed" state. - if (currentScale >= 0.95) currentScale = 1; - userScale = 1; - panX = 0; - panY = 0; - smoothScrollOffsetY = 0; - updateTransform(); - adjustRowsForViewport(); - - var cellW = getCellWidth(); - var sw = term.element.scrollWidth; - var vpW = window.innerWidth; - var expectedW = cellW * term.cols; - var suspect = - currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom - if (suspect) { - flog('commit-SUSPECT', { - reason: reason, - attempts: attempts, - gate: gate, - preSnapScale: preSnapScale, - finalScale: currentScale, - cellW: cellW, - cols: term.cols, - expectedW: expectedW, - scrollWidth: sw, - vpWidth: vpW - }); - } - repositionOverlay(); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts b/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts deleted file mode 100644 index 90dba7cbdbc..00000000000 --- a/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { TERMINAL_WEBGL_RECOVERY_JS } from '../terminal-webview-webgl-recovery-injected' -import { MOBILE_TERMINAL_CARET_OPTIONS } from './theme' - -export const TERMINAL_HTML_INIT_AND_WRITE = `${TERMINAL_WEBGL_RECOVERY_JS} - - function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) { - if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale; - // Why: a width-reflow re-stream rewraps the same content at new cols. - // Distance-from-bottom (rows) is the only stable anchor across reflow, - // since line counts and cell positions change. null = stay pinned to bottom. - var prevB = preserveScroll && term && term.buffer && term.buffer.active ? term.buffer.active : null; - var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1; - terminalGeneration++; - var gen = terminalGeneration; - // Why: snapshot replay can contain old queries whose replies must never - // re-enter the live PTY. Each replacement terminal earns authority anew. - resetTerminalDataReplyAuthority(); - cancelWebglContextRecovery(); - webglAddon = null; - ready = false; - resetWriteQueue(); - statusDotPendingSelector = false; - writesDraining = false; - afterDrainCallbacks = []; - initRows = rows || 24; - firstDataPending = true; - smoothScrollOffsetY = 0; - wheelAccumDeltaY = 0; - mouseModeScanTail = ''; - trackedMouseTrackingMode = 'none'; - sgrMouseMode = false; - sgrMousePixelsMode = false; - lastEmittedModes = { - bracketedPasteMode: false, - altScreen: false, - mouseTrackingMode: 'none', - sgrMouseMode: false, - sgrMousePixelsMode: false - }; - var replayData = normalizeInitialData(initialData); - // Why: normalizeInitialData can discard pre-alt-screen bytes. Keep the - // mirrored modes aligned with exactly what this mobile xterm replays. - updateMouseModeFromData(replayData); - activeAltScreenSnapshot = isAltScreenActive(replayData); - initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : []; - initialOscLinkRowOffset = 0; - initialOscLinkEvictionReady = false; - var surfaceSwap = beginTerminalSurfaceSwap(); - var nextSurface = surfaceSwap.nextSurface; - - applyTerminalTheme(nextTheme); - term = new Terminal({ - cols: cols || 80, - rows: rows || 24, - theme: terminalTheme, - minimumContrastRatio: terminalMinimumContrastRatio, - fontFamily: terminalFontFamily, - fontSize: fontPxForScale(currentTextScale), - fontWeight: '300', - fontWeightBold: '500', - scrollback: 5000, - // Why: xterm suppresses parser-generated query replies when disableStdin - // is true. Native accepts only validated reply grammars from onData. - disableStdin: false, - cursorBlink: ${MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink}, - cursorStyle: ${JSON.stringify(MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle)}, - // Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret. - showCursorImmediately: ${MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately}, - // A full inactive cell remains visible under the terminal's phone-fit scale. - cursorInactiveStyle: ${JSON.stringify(MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle)}, - convertEol: false, - allowProposedApi: true - }); - var nextTerm = term; - pendingTerm = nextTerm; - term.open(surface); - attachWebglAddon(true); - if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {} - if (typeof replayData === 'string' && replayData.length > 0) { - // Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output. - enqueueWrite(ESC + '[0m' + replayData); - } - - // Why: reset eviction tracking + attach observers for the new term. - resetEvictionCounter(); - cancelSelect(); - attachTermObservers(); - attachTerminalQueryReplyBridge(term, gen); - - requestAnimationFrame(function() { - if (gen !== terminalGeneration) return; - ready = true; - everReady = true; - afterWritesDrained(function() { - if (gen !== terminalGeneration) return; - commitTerminalSurfaceSwap(surfaceSwap, nextTerm); - // Why: restore the reader's place after the rewrapped buffer replays. - // Replay lands at bottom, so only act when they were scrolled up (rows>0). - if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) { - try { term.scrollToLine(Math.max(0, (term.buffer.active.baseY || 0) - scrollAnchorRows)); } catch (e) {} - } - captureInitialOscLinkTexts(); - initialOscLinkRowOffset = 0; - initialOscLinkEvictionReady = true; - applyFitScale('init-replay'); - notify({ type: 'ready', cols: cols, rows: rows }); - }); - }); - } - - function write(data) { - updateMouseModeFromData(data); - enqueueWrite(data); - pumpWrites(terminalGeneration); - // Why: first live data chunk after init may widen the buffer past - // what the post-replay applyFitScale measured. Re-fit once after this - // chunk drains to catch the wider line. Subsequent chunks don't re-fit - // (the user's manual zoom is sticky after that). - if (firstDataPending) { - firstDataPending = false; - var gen = terminalGeneration; - afterWritesDrained(function() { - if (gen !== terminalGeneration) return; - applyFitScale('first-data'); - }); - } - } - - function resize(cols, rows) { - if (!term) return; - initRows = rows || initRows; - term.resize(cols || term.cols, rows || term.rows); - emitKeyboardAvoidanceMetrics(); - applyFitScale('resize-msg'); - notify({ type: 'ready', cols: cols, rows: rows }); - } - - // reflow(): see terminal-webview-reflow-injected.ts (extracted for max-lines). -` diff --git a/mobile/src/terminal/terminal-webview-html/write-queue.ts b/mobile/src/terminal/terminal-webview-html/write-queue.ts deleted file mode 100644 index cdb45bb0b91..00000000000 --- a/mobile/src/terminal/terminal-webview-html/write-queue.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Also carries disposeTermObservers() and extractMouseModeScanTail(): both belong to -// other concerns, but emitted-document order pins them inside this queue. -// nextQueuedWrite() clears each slot before advancing the head; otherwise consumed slots keep -// already-submitted chunks reachable until compaction, which is up to half a backlog away. -// Kept out of the template literal below: anything inside it ships to every device. -export const TERMINAL_HTML_WRITE_QUEUE = ` function resetWriteQueue() { - writeQueue = []; - writeQueueHead = 0; - } - - function isStatusDotPresentationSelector(value) { - return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR; - } - - function endsWithStatusDotPresentationSequence(data) { - var i = data.length - 1; - while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--; - return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT; - } - - // Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph. - function normalizeStatusDotPresentation(data) { - if (typeof data !== 'string' || data.length === 0) return data; - if (statusDotPendingSelector) { - statusDotPendingSelector = false; - var strippedPendingSelectors = false; - while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1); - strippedPendingSelectors = data.length === 0; - if (strippedPendingSelectors) { - statusDotPendingSelector = true; - return ''; - } - } - var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR); - statusDotPendingSelector = endsWithStatusDotPresentationSequence(data); - return normalized; - } - - function enqueueWrite(data) { - writeQueue.push(normalizeStatusDotPresentation(data)); - } - - function enqueueWriteBoundary(callback) { - writeQueue.push(callback); - } - - function nextQueuedWrite() { - if (writeQueueHead >= writeQueue.length) { - resetWriteQueue(); - return undefined; - } - var next = writeQueue[writeQueueHead]; - writeQueue[writeQueueHead] = undefined; - writeQueueHead++; - // Why: high-throughput terminals can enqueue faster than xterm parses; - // compact consumed slots so drain work stays O(1) without retaining old chunks. - if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) { - writeQueue = writeQueue.slice(writeQueueHead); - writeQueueHead = 0; - } - return next; - } - - function disposeTermObservers() { - var disposables = termObserverDisposables; - termObserverDisposables = []; - for (var i = 0; i < disposables.length; i++) { - try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {} - } - } - - function extractMouseModeScanTail(input) { - var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); - if (start === -1) return ''; - var tail = input.slice(start); - // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. - // Keep parser state far beyond normal mode lists while still bounding memory. - if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return ''; - if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail; - if (tail.indexOf(ESC + '[?') === 0) { - return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''; - } - if (tail.indexOf(C1_CSI + '?') === 0) { - return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''; - } - return ''; - } - - function pumpWrites(gen) { - if (!ready || !term || writesDraining || gen !== terminalGeneration) return; - var next = nextQueuedWrite(); - if (typeof next !== 'string') { - if (typeof next === 'function') return next(), pumpWrites(gen); - var callbacks = afterDrainCallbacks; - afterDrainCallbacks = []; - for (var i = 0; i < callbacks.length; i++) callbacks[i](); - return; - } - writesDraining = true; - // Why: xterm.write() parses asynchronously. Row adjustment/resizing must - // wait until replayed SGR attributes have landed in the buffer. - term.write(next, function() { - if (gen !== terminalGeneration) return; - writesDraining = false; - pumpWrites(gen); - }); - } - - function afterWritesDrained(callback) { - afterDrainCallbacks.push(callback); - pumpWrites(terminalGeneration); - } - -` diff --git a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts deleted file mode 100644 index 54ee8d430a4..00000000000 --- a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts +++ /dev/null @@ -1,198 +0,0 @@ -// Indirect-pointer (external mouse / trackpad) click and drag for the terminal -// surface, injected into XTERM_HTML. Extracted from terminal-webview-html.ts to -// keep that file within its max-lines budget. Companion to -// terminal-webview-wheel-scroll-injected.ts, which owns the wheel half (#11247); -// this owns the click/drag half of #8818. Closes over host-IIFE state/functions: -// term, ESC, sel, selMode, selectionOverlay, TAP_SLOP, getMouseTrackingMode, -// viewportToCell, viewportToMouseReportCell, isSafeSgrMouseCoordinate, -// sgrMouseMode, sgrMousePixelsMode, notify, notifyTerminalSurfaceTap, -// cancelSelect, applyXtermSelection, repositionOverlay, handleDragMove, -// stopEdgeScroll, and dispatcherShouldBlockSurface. -// -// Why pointer events: a hardware mouse on Android/iPadOS raises pointer events -// with pointerType 'mouse' and NO touch events, while a finger raises -// pointerType 'touch' plus the touch events the document dispatcher owns. The -// capture-phase mousedown/click suppression in attachSurfaceEventHandlers stays: -// it is what keeps xterm's own mouse handling inert (its onData output is -// dropped by the mobile bridge), and pointer events are unaffected by it. -export const TERMINAL_MOUSE_CLICK_DRAG_JS = ` - var mouseGesture = 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 - // drag/any report motion) or the cell is not encodable. - function buildMouseButtonReport(kind, clientX, clientY) { - var mouseTrackingMode = getMouseTrackingMode(); - if (mouseTrackingMode === 'none') return ''; - if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') return ''; - if (kind === 'release' && mouseTrackingMode === 'x10') return ''; - var cell = viewportToMouseReportCell(clientX, clientY); - if (!cell) return ''; - var sgrButton = kind === 'motion' ? 32 : 0; - var sgrFinal = kind === 'release' ? 'm' : 'M'; - if (sgrMousePixelsMode) { - if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; - return ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal; - } - if (sgrMouseMode) { - // Why: xterm increments zero-based mouse cells before encoding reports. - var sgrCol = cell.col + 1; - var sgrRow = cell.row + 1; - if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; - return ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal; - } - var button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32; - var col = cell.col + 1 + 32; - var row = cell.row + 1 + 32; - // Why: non-SGR mouse bytes above ASCII are not preserved reliably through - // the mobile JSON/RPC string path; drop instead of corrupting input. - if (col > 126 || row > 126) return ''; - return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); - } - - function mouseReportCellKey(clientX, clientY) { - var cell = viewportToMouseReportCell(clientX, clientY); - return cell ? cell.col + ',' + cell.row : null; - } - - function abandonMouseGesture() { - var gesture = mouseGesture; - mouseGesture = null; - if (!gesture) return; - if (gesture.mode === 'tracking') { - // Why: the press report already went to the TUI; a lost pointer must not - // leave the button latched down on the far side. - var release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY); - if (release) notify({ type: 'terminal-input', bytes: release }); - } else if (gesture.mode === 'selecting') { - if (sel) sel.activeHandle = null; - stopEdgeScroll(); - } - } - - function beginMouseDrag(gesture) { - gesture.moved = true; - if (getMouseTrackingMode() !== 'none') { - gesture.mode = 'tracking'; - gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY); - var press = buildMouseButtonReport('press', gesture.startX, gesture.startY); - if (press) notify({ type: 'terminal-input', bytes: press }); - return; - } - var anchor = viewportToCell(gesture.startX, gesture.startY); - if (!anchor) { - gesture.mode = 'cancelled'; - return; - } - // Why: mouse drags select character-anchored ranges like desktop terminals, - // not the word-seeded long-press selection; reuse the touch handle-drag - // plumbing (edge scroll included) by acting as a live 'end' handle. - gesture.mode = 'selecting'; - selMode = 'select'; - sel = { anchor: anchor, focus: anchor, activeHandle: 'end' }; - selectionOverlay.classList.add('active'); - notify({ type: 'set-select-mode', enabled: true }); - applyXtermSelection(); - repositionOverlay(); - } - - function attachSurfaceMouseClickDragHandler(targetSurface) { - targetSurface.addEventListener('pointerdown', function(e) { - if (e.pointerType !== 'mouse' || e.button !== 0) return; - if (dispatcherShouldBlockSurface() || !term) return; - // 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) abandonMouseGesture(); - // Why: mouse pointers have no implicit capture; without it a drag that - // leaves the surface drops pointermove/pointerup and strands the gesture. - try { - if (targetSurface.setPointerCapture) targetSurface.setPointerCapture(e.pointerId); - } catch (err) {} - mouseGesture = { - startX: e.clientX, startY: e.clientY, - lastX: e.clientX, lastY: e.clientY, - lastCellKey: null, - moved: false, - mode: 'pending', - dismissedSelection: false - }; - if (selMode === 'select') { - // 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; - } - }, true); - - targetSurface.addEventListener('pointermove', function(e) { - var gesture = mouseGesture; - if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return; - if (!term) return; - gesture.lastX = e.clientX; - gesture.lastY = e.clientY; - if ((e.buttons & 1) === 0) { - // Why: a pointerup lost outside the WebView (capture unavailable) must - // end the gesture here, or a tracked press stays latched at the TUI. - // Coordinates first, so the synthesized release lands where the - // pointer re-entered rather than at the previous cell. - abandonMouseGesture(); - return; - } - if (!gesture.moved) { - var dx = Math.abs(e.clientX - gesture.startX); - var dy = Math.abs(e.clientY - gesture.startY); - if (dx + dy <= TAP_SLOP) return; - beginMouseDrag(gesture); - } - if (gesture.mode === 'tracking') { - // Why: one motion report per cell keeps drags bounded by grid size, not - // by pointermove cadence, so the RN rate limiter is never the bottleneck. - var cellKey = mouseReportCellKey(e.clientX, e.clientY); - if (cellKey && cellKey !== gesture.lastCellKey) { - gesture.lastCellKey = cellKey; - var motion = buildMouseButtonReport('motion', e.clientX, e.clientY); - if (motion) notify({ type: 'terminal-input', bytes: motion }); - } - } else if (gesture.mode === 'selecting') { - handleDragMove('end', e.clientX, e.clientY); - } - }, true); - - targetSurface.addEventListener('pointerup', function(e) { - var gesture = mouseGesture; - if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) return; - mouseGesture = null; - if (gesture.mode === 'cancelled' || !term) return; - if (gesture.mode === 'tracking') { - var release = buildMouseButtonReport('release', e.clientX, e.clientY); - if (release) notify({ type: 'terminal-input', bytes: release }); - return; - } - if (gesture.mode === 'selecting') { - if (sel) sel.activeHandle = null; - stopEdgeScroll(); - repositionOverlay(); - return; - } - if (dispatcherShouldBlockSurface()) return; - // Why: a dismissing tap only clears the selection (touch parity); it must - // not also open a link or focus the keyboard underneath. - if (gesture.dismissedSelection) return; - // Pointer clicks keep their current link, file, TUI mouse, and focus priority. - notifyTerminalSurfaceTap(e.clientX, e.clientY, false); - }, true); - - targetSurface.addEventListener('pointercancel', function(e) { - if (e.pointerType !== 'mouse') return; - abandonMouseGesture(); - }, true); - - // Why: Android input injection can pair a mouse-flavored pointerdown with - // real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives, - // the document touch dispatcher owns the gesture. - targetSurface.addEventListener('touchstart', function() { - if (mouseGesture) abandonMouseGesture(); - }, true); - } -` diff --git a/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts b/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts deleted file mode 100644 index 14baeeaf325..00000000000 --- a/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Mouse-report coordinate mapping injected into XTERM_HTML. Closes over term, -// panX/panY, getCellWidth/Height, and getTotalScale. -export const TERMINAL_MOUSE_REPORT_CELL_JS = ` - function viewportToMouseReportCell(clientX, clientY) { - if (!term) return null; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - if (cellW <= 0 || cellH <= 0) return null; - if (typeof clientX !== 'number') clientX = window.innerWidth / 2; - if (typeof clientY !== 'number') clientY = window.innerHeight / 2; - var total = getTotalScale(); - if (total <= 0) total = 1; - var sx = (clientX - panX) / total; - var sy = (clientY - panY) / total; - var maxX = Math.max(0, term.cols * cellW - 1); - var maxY = Math.max(0, term.rows * cellH - 1); - if (sx < 0) sx = 0; - if (sx > maxX) sx = maxX; - if (sy < 0) sy = 0; - if (sy > maxY) sy = maxY; - var col = Math.floor(sx / cellW); - var row = Math.floor(sy / cellH); - if (col < 0) col = 0; - if (col > term.cols - 1) col = term.cols - 1; - if (row < 0) row = 0; - if (row > term.rows - 1) row = term.rows - 1; - return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }; - } -` diff --git a/mobile/src/terminal/terminal-webview-payload-hash.test.ts b/mobile/src/terminal/terminal-webview-payload-hash.test.ts index 8c23de0ac3d..6d4380ba168 100644 --- a/mobile/src/terminal/terminal-webview-payload-hash.test.ts +++ b/mobile/src/terminal/terminal-webview-payload-hash.test.ts @@ -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 = '25b800f342c972f0b8eaba54367bd8b02b7518e9ea6a25e04ab89b3a2ad7d21b' -const EXPECTED_LENGTH = 730472 +const EXPECTED_SHA256 = 'c84ce5fc7343546427ad875aeebea90e54560579a1d18b3b700076a1c4b4623f' +const EXPECTED_LENGTH = 723480 describe('terminal WebView payload', () => { it('composes the expected document', () => { diff --git a/mobile/src/terminal/terminal-webview-query-reply-injected.ts b/mobile/src/terminal/terminal-webview-query-reply-injected.ts deleted file mode 100644 index a4ac34e5809..00000000000 --- a/mobile/src/terminal/terminal-webview-query-reply-injected.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Kept as one injectable unit so tests execute the same replay/generation gate -// that the WebView document runs, rather than a TypeScript reimplementation. -export const TERMINAL_QUERY_REPLY_JS = ` - var terminalDataRepliesEnabled = false; - - function resetTerminalDataReplyAuthority() { - terminalDataRepliesEnabled = false; - } - - function resumeTerminalDataReplyAuthority() { - terminalDataRepliesEnabled = true; - } - - function forwardTerminalDataReply(data) { - if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data }); - } - - function enqueueTerminalDataReplyBoundary(gen) { - enqueueWriteBoundary(function() { - if (gen === terminalGeneration) terminalDataRepliesEnabled = true; - }); - } - - function attachTerminalQueryReplyBridge(term, gen) { - // Why: parser replies require stdin enabled, but mobile input is owned by - // native controls. Keep xterm's textarea inert for touch/hardware keys. - try { - term.attachCustomKeyEventHandler(function() { return false; }); - if (term.textarea) { - term.textarea.readOnly = true; - term.textarea.tabIndex = -1; - term.textarea.setAttribute('inputmode', 'none'); - } - } catch (e) {} - try { - termObserverDisposables.push(term.onData(function(data) { - forwardTerminalDataReply(data); - })); - } catch (e) {} - // Why: live output can queue before initial replay finishes. Enable replies - // at the replay boundary so those live queries are answered, never replayed ones. - enqueueTerminalDataReplyBoundary(gen); - } -` diff --git a/mobile/src/terminal/terminal-webview-query-reply.test.ts b/mobile/src/terminal/terminal-webview-query-reply.test.ts index 4e8add33f2a..9d3b780a530 100644 --- a/mobile/src/terminal/terminal-webview-query-reply.test.ts +++ b/mobile/src/terminal/terminal-webview-query-reply.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' +import { + documentScopePreamble, + generatedDocumentModule +} from './document/generated-document-region.test-support' import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' -import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' + +const queryReplySource = await generatedDocumentModule('query-reply') type QueryReplyGate = { forward: (data: string) => void @@ -15,17 +20,18 @@ function createQueryReplyGate(notify: (message: unknown) => void): { queuedBoundaries: Array<() => void> } { const queuedBoundaries: Array<() => void> = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the body's return literal names exactly the five entries below. const factory = new Function( 'notify', 'enqueueWriteBoundary', - `var terminalGeneration = 0; - ${TERMINAL_QUERY_REPLY_JS} + `${documentScopePreamble()} + ${queryReplySource} return { forward: forwardTerminalDataReply, queueBoundary: enqueueTerminalDataReplyBoundary, reset: resetTerminalDataReplyAuthority, resume: resumeTerminalDataReplyAuthority, - setGeneration: function(next) { terminalGeneration = next; } + setGeneration: function(next) { scope.terminalGeneration = next; } };` ) as ( notify: (message: unknown) => void, @@ -39,7 +45,7 @@ describe('mobile terminal query replies', () => { it('forwards xterm-generated data only after initial replay drains', () => { const listenerIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('term.onData(function(data)') const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( - 'attachTerminalQueryReplyBridge(term, gen)', + 'attachTerminalQueryReplyBridge(scope.term, gen)', listenerIndex ) const notifyIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( @@ -52,9 +58,9 @@ describe('mobile terminal query replies', () => { expect(notifyIndex).toBeGreaterThan(listenerIndex) expect(XTERM_WEBVIEW_SOURCE.html).toContain('disableStdin: false') expect(XTERM_WEBVIEW_SOURCE.html).toContain( - 'term.attachCustomKeyEventHandler(function() { return false; })' + 'term.attachCustomKeyEventHandler(function() {\n return false;\n });' ) - expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true') + expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true;') }) it('mutes a replacement terminal until its own replay drains', () => { @@ -64,7 +70,7 @@ describe('mobile terminal query replies', () => { initIndex ) const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( - 'attachTerminalQueryReplyBridge(term, gen)', + 'attachTerminalQueryReplyBridge(scope.term, gen)', disableIndex ) @@ -114,9 +120,9 @@ describe('mobile terminal query replies', () => { gate.forward('\x1b[3;4R') expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }]) - const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf("} else if (msg.type === 'clear') {") + const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf('} else if (msg.type === "clear") {') const clearEnd = XTERM_WEBVIEW_SOURCE.html.indexOf( - "} else if (msg.type === 'measure')", + '} else if (msg.type === "measure")', clearStart ) expect(XTERM_WEBVIEW_SOURCE.html.slice(clearStart, clearEnd)).toContain( diff --git a/mobile/src/terminal/terminal-webview-reflow-injected.ts b/mobile/src/terminal/terminal-webview-reflow-injected.ts deleted file mode 100644 index ef704529e4b..00000000000 --- a/mobile/src/terminal/terminal-webview-reflow-injected.ts +++ /dev/null @@ -1,33 +0,0 @@ -// In-WebView reflow routine, injected into XTERM_HTML. Extracted from -// terminal-webview-html.ts to keep that file within its max-lines budget. -// Closes over term / isAlternateBufferActive / applyFitScale / -// updateScrollIndicator / initRows defined in the host IIFE. -export const TERMINAL_REFLOW_JS = ` - // Why: rewrap the local xterm buffer (scrollback included) to a new width - // after a server PTY reflow. Skip the alternate screen: those snapshots are - // fully repainted by the PTY and a local resize there can drop SGR attributes - // (see init's alt-screen handling), which shows as white text. - function reflow(cols, rows) { - if (!term || isAlternateBufferActive()) return; - var nextCols = cols || term.cols; - var nextRows = rows || term.rows; - if (nextCols === term.cols && nextRows === term.rows) return; - var buffer = term.buffer.active; - // Why: anchor reflow on whether the user was pinned to the live bottom so - // their scroll position survives the rewrap — if they were scrolled up, - // hold the same distance from the bottom; if at the bottom, stay there. - var wasAtBottom = buffer.viewportY >= buffer.baseY; - var distanceFromBottom = buffer.baseY - buffer.viewportY; - initRows = nextRows; - term.resize(nextCols, nextRows); - var rewrapped = term.buffer.active; - if (wasAtBottom) { - term.scrollToBottom(); - } else { - term.scrollLines(rewrapped.baseY - distanceFromBottom - rewrapped.viewportY); - } - applyFitScale('reflow-msg'); - updateScrollIndicator(false); - emitKeyboardAvoidanceMetrics(); - } -` diff --git a/mobile/src/terminal/terminal-webview-reflow.test.ts b/mobile/src/terminal/terminal-webview-reflow.test.ts index 90112740d1d..be24fbb3819 100644 --- a/mobile/src/terminal/terminal-webview-reflow.test.ts +++ b/mobile/src/terminal/terminal-webview-reflow.test.ts @@ -1,17 +1,14 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { generatedDocumentModule } from './document/generated-document-region.test-support' import { XTERM_HTML } from './terminal-webview-html' -import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' -// The reflow logic lives as injected in-WebView JS; the message dispatch and -// handle wiring live in terminal-webview-html.ts / TerminalWebView.tsx. Assert -// the load-bearing invariants from source, mirroring the other tests here. -const reflowSource = readFileSync( - new URL('./terminal-webview-reflow-injected.ts', import.meta.url), - 'utf8' -) -// Use the assembled document so the test covers the fragments that run in the WebView. -const htmlSource = readTerminalWebViewHtmlSource() +// The reflow logic runs inside the WebView document; the message dispatch and handle wiring live +// in terminal-webview-html.ts / TerminalWebView.tsx. Assert the load-bearing invariants from the +// document the WebView runs, mirroring the other tests here. +const reflowSource = await generatedDocumentModule('reflow') +// Use the assembled document so the test covers what the WebView actually runs. +const htmlSource = XTERM_HTML const handleSource = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') function reflowFnBody(): string { @@ -24,57 +21,53 @@ describe('terminal WebView reflow', () => { it('skips the alternate screen so TUI snapshots are not mutated', () => { // Why: alt-screen snapshots are repainted by the PTY; a local resize there // can drop SGR attributes (white text). Reflow must early-return. - expect(reflowFnBody()).toContain('if (!term || isAlternateBufferActive()) return;') + expect(reflowFnBody()).toContain('if (!scope.term || isAlternateBufferActive()) {') }) it('rewraps the local buffer via term.resize to the new cols', () => { - expect(reflowFnBody()).toContain('term.resize(nextCols, nextRows);') + expect(reflowFnBody()).toContain('scope.term.resize(nextCols, nextRows);') }) it('preserves the user scroll position across the rewrap', () => { const body = reflowFnBody() // At the live bottom -> stay pinned; scrolled up -> hold distance-from-bottom. - expect(body).toContain('var wasAtBottom = buffer.viewportY >= buffer.baseY;') - expect(body).toContain('term.scrollToBottom();') + expect(body).toContain('const wasAtBottom = buffer.viewportY >= buffer.baseY;') + expect(body).toContain('scope.term.scrollToBottom();') expect(body).toContain('rewrapped.baseY - distanceFromBottom - rewrapped.viewportY') }) it('is no-op when the dimensions are unchanged', () => { expect(reflowFnBody()).toContain( - 'if (nextCols === term.cols && nextRows === term.rows) return;' + 'if (nextCols === scope.term.cols && nextRows === scope.term.rows) {' ) }) it('is dispatched by the reflow WebView message and exposed on the handle', () => { - expect(htmlSource).toContain("} else if (msg.type === 'reflow') {") + expect(htmlSource).toContain('} else if (msg.type === "reflow") {') expect(htmlSource).toContain('reflow(msg.cols, msg.rows);') expect(handleSource).toContain("postMessage({ type: 'reflow', cols, rows })") }) it('does not locally resize hidden WebViews to a one-column grid', () => { - expect(htmlSource).toContain('var MIN_FIT_COLS = 20;') - expect(htmlSource).toContain('if (cols < MIN_FIT_COLS) return;') - expect(htmlSource).toContain("flog('measure-skip-small-width'") - expect(htmlSource).toContain("notify({ type: 'measure-result', cols: null, rows: null });") + expect(htmlSource).toContain('scope.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 });') }) - // Why: the raw-source assertions above pass even if the reflow module is - // dropped from the XTERM_HTML concatenation (a broken/removed import or an - // emptied TERMINAL_REFLOW_JS leaves the `${...}` placeholder in the template - // but never injects the routine). That was the regression class reported when - // a sibling refactor extracted the tap dispatcher next to the reflow inject. - // Guard the *assembled* document so the routine and its dispatch are really - // present in what the WebView runs. + // Why: the assertions above read the reflow module's own emission, which still reads whole if + // the generator drops the module from the document or emits it twice. That was the regression + // class reported when a sibling refactor extracted the tap dispatcher next to reflow. Guard the + // assembled document so the routine, once, and its dispatch are really in what the WebView runs. describe('assembled XTERM_HTML', () => { - it('still injects the reflow routine (placeholder fully expanded)', () => { + it('carries the reflow routine exactly once', () => { expect(XTERM_HTML).toContain('function reflow(cols, rows) {') - expect(XTERM_HTML).toContain('term.resize(nextCols, nextRows);') - // No unexpanded template placeholder for the injected reflow JS. - expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}') + expect(XTERM_HTML).toContain('scope.term.resize(nextCols, nextRows);') + expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1) }) it('still routes the reflow message to the injected routine', () => { - expect(XTERM_HTML).toContain("} else if (msg.type === 'reflow') {") + expect(XTERM_HTML).toContain('} else if (msg.type === "reflow") {') expect(XTERM_HTML).toContain('reflow(msg.cols, msg.rows);') }) @@ -84,8 +77,8 @@ 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("var dispatch = { mode: 'idle'") - const listenerAt = XTERM_HTML.indexOf("window.addEventListener('message'") + const dispatchAt = XTERM_HTML.indexOf('const dispatch = {\n mode: "idle"') + const listenerAt = XTERM_HTML.indexOf('window.addEventListener("message"') expect(reflowAt).toBeGreaterThanOrEqual(0) expect(dispatchAt).toBeGreaterThan(reflowAt) expect(listenerAt).toBeGreaterThan(dispatchAt) diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 9218e5d6ad9..53d580cf196 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -1,15 +1,13 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' +import { XTERM_HTML } from './terminal-webview-html' -// The in-WebView JS lives in terminal-webview-html.ts; the RN wrapper in -// TerminalWebView.tsx. Concatenate both so assertions resolve regardless of file. +// The RN wrapper and the pending-message queue are TypeScript; everything the WebView runs is the +// generated document. Concatenated so assertions resolve regardless of file. const source = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') + readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-url-tap.ts', import.meta.url), 'utf8') + - readFileSync(new URL('./terminal-webview-tap-dispatch-injected.ts', import.meta.url), 'utf8') + - readTerminalWebViewHtmlSource() + XTERM_HTML const sessionSource = readFileSync( new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url), 'utf8' @@ -33,9 +31,11 @@ describe('TerminalWebView scroll routing', () => { }) it('maps a downward pull at the bottom to older scrollback rows', () => { - expect(source).toContain('var deltaY = ts.lastY - y;') - expect(source).toContain('smoothScrollOffsetY -= deltaY;') - expect(source).toContain('var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);') + expect(source).toContain('const deltaY = ts.lastY - y;') + expect(source).toContain('scope.smoothScrollOffsetY -= deltaY;') + expect(source).toContain( + 'const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);' + ) const nextViewportY = simulateNormalBufferPull({ baseY: 120, @@ -54,15 +54,18 @@ describe('TerminalWebView scroll routing', () => { ) const touchMoveBlock = sliceBetween( - "targetSurface.addEventListener('touchmove'", - '}, { capture: true, passive: false });' + 'targetSurface.addEventListener(\n "touchmove"', + '{ capture: true, passive: false }' ) expect(touchMoveBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan( touchMoveBlock.indexOf('if (enqueueNormalBufferScrollDelta(deltaY))') ) expect(touchMoveBlock).toContain('routeScrollLines(lines, x, y);') - const momentumBlock = sliceBetween('function momentumStep()', 'if (Math.abs(vel) > MIN_VEL)') + const momentumBlock = sliceBetween( + 'let momentumStep = function()', + 'if (Math.abs(vel) > MIN_VEL)' + ) expect(momentumBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan( momentumBlock.indexOf('if (!applyNormalBufferScrollDelta(delta))') ) @@ -81,13 +84,16 @@ describe('TerminalWebView scroll routing', () => { expect(smoothScrollBlock).toContain('return true;') const touchMoveBlock = sliceBetween( - "targetSurface.addEventListener('touchmove'", - '}, { capture: true, passive: false });' + 'targetSurface.addEventListener(\n "touchmove"', + '{ capture: true, passive: false }' ) expect(touchMoveBlock).toContain('if (enqueueNormalBufferScrollDelta(deltaY))') expect(touchMoveBlock).toContain('ts.velY = 0;') - const momentumBlock = sliceBetween('function momentumStep()', 'if (Math.abs(vel) > MIN_VEL)') + const momentumBlock = sliceBetween( + 'let momentumStep = function()', + 'if (Math.abs(vel) > MIN_VEL)' + ) expect(momentumBlock).toContain('if (!applyNormalBufferScrollDelta(delta))') expect(momentumBlock).toContain('ts.momentumId = null;') }) @@ -97,24 +103,24 @@ describe('TerminalWebView scroll routing', () => { 'function enqueueNormalBufferScrollDelta(deltaY)', 'function resetSmoothScrollOffset()' ) - expect(enqueueBlock).toContain('pendingNormalScrollDeltaY += deltaY;') - expect(enqueueBlock).toContain('if (normalScrollFrameId !== null) return true;') - expect(enqueueBlock).toContain('normalScrollFrameId = requestAnimationFrame(function()') + expect(enqueueBlock).toContain('scope.pendingNormalScrollDeltaY += deltaY;') + expect(enqueueBlock).toContain('if (scope.normalScrollFrameId !== null) {') + expect(enqueueBlock).toContain('scope.normalScrollFrameId = requestAnimationFrame(function()') expect(enqueueBlock).toContain('applyNormalBufferScrollDelta(delta)') const resetBlock = sliceBetween( 'function resetSmoothScrollOffset()', 'function cellToViewportPx' ) - expect(resetBlock).toContain('pendingNormalScrollDeltaY = 0;') - expect(resetBlock).toContain('cancelAnimationFrame(normalScrollFrameId);') + expect(resetBlock).toContain('scope.pendingNormalScrollDeltaY = 0;') + expect(resetBlock).toContain('cancelAnimationFrame(scope.normalScrollFrameId);') }) it('drains terminal writes without shifting the queued array', () => { - expect(source).toContain('var writeQueueHead = 0;') + expect(source).toContain('scope.writeQueueHead = 0;') expect(source).toContain('function nextQueuedWrite()') - expect(source).toContain('writeQueueHead++;') - expect(source).toContain('writeQueue = writeQueue.slice(writeQueueHead);') + expect(source).toContain('scope.writeQueueHead++;') + expect(source).toContain('scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);') expect(source).not.toContain('writeQueue.shift()') }) @@ -159,67 +165,67 @@ describe('TerminalWebView scroll routing', () => { 'function updateScrollIndicator(reveal)' ) expect(updateTransformBlock).toContain( - "surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')';" + 'scope.surface.style.transform = "translate(" + scope.panX + "px," + scope.panY + "px) scale(" + getTotalScale() + ")"' ) expect(source).not.toContain("querySelector('.xterm-screen')") expect(source).not.toContain('updateTerminalScreenTransform') - expect(updateTransformBlock).not.toContain("getVisualPanY() + 'px) scale('") + expect(updateTransformBlock).not.toContain('getVisualPanY() + "px) scale("') expect(updateTransformBlock).not.toContain('smoothScrollOffsetY') }) 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('var FRICTION = 0.972;') - expect(source).toContain('var MIN_VEL = 0.012;') + expect(source).toContain('const FRICTION = 0.972;') + expect(source).toContain('const MIN_VEL = 0.012;') }) it('keeps selection edge autoscroll active and extends the dragged endpoint', () => { const startBlock = sliceBetween('function startEdgeScroll(dir)', 'function stopEdgeScroll()') expect(startBlock.indexOf('stopEdgeScroll();')).toBeLessThan( - startBlock.indexOf('edgeScrollDir = dir;') + startBlock.indexOf('scope.edgeScrollDir = dir;') ) - expect(startBlock.indexOf('term.scrollLines(edgeScrollDir);')).toBeLessThan( + expect(startBlock.indexOf('scope.term.scrollLines(scope.edgeScrollDir);')).toBeLessThan( startBlock.indexOf('syncEdgeScrollSelectionEndpoint();') ) const dragMoveBlock = sliceBetween( 'function handleDragMove(handle, clientX, clientY)', - ' // Latching document-level touch dispatcher: see' + 'function attachSurfaceEventHandlers(' ) - expect(dragMoveBlock).toContain('edgeScrollClientX = clientX;') - expect(dragMoveBlock).toContain('edgeScrollClientY = clientY;') + expect(dragMoveBlock).toContain('scope.edgeScrollClientX = clientX;') + expect(dragMoveBlock).toContain('scope.edgeScrollClientY = clientY;') expect(dragMoveBlock).toContain('syncSelectionHandleToViewportPoint(handle, clientX, clientY)') }) it('opens links and paths from surface taps before mouse/focus fallback', () => { expect(source).toContain('function buildMouseClickInput(clientX, clientY)') expect(source).toContain('function isClickMouseTrackingMode(mode)') - expect(source).toContain("return mode !== 'none';") - expect(source).toContain('var pixelX = cell.x;') - expect(source).toContain('var pixelY = cell.y;') + expect(source).toContain('return mode !== "none";') + expect(source).toContain('const pixelX = cell.x;') + expect(source).toContain('const pixelY = cell.y;') expect(source).toContain( - 'if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return' + 'if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) {' ) expect(source).toContain( - 'if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return' + 'if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) {' ) - expect(source).toContain("if (mouseTrackingMode === 'x10') return pixelPress;") - expect(source).toContain("if (mouseTrackingMode === 'x10') return sgrPress;") - expect(source).toContain("if (mouseTrackingMode === 'x10') return press;") - expect(source).toContain("if (col > 126 || row > 126) return '';") + expect(source).toContain('if (mouseTrackingMode === "x10") {\n return pixelPress;') + expect(source).toContain('if (mouseTrackingMode === "x10") {\n return sgrPress;') + expect(source).toContain('if (mouseTrackingMode === "x10") {\n return press;') + expect(source).toContain('if (col > 126 || row > 126) {\n return "";') const touchEndBlock = sliceBetween( - "document.addEventListener('touchend'", - '}, { capture: true, passive: true });' + 'document.addEventListener(\n "touchend"', + '{ capture: true, passive: true }' ) expect(touchEndBlock).toContain( - 'notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true)' + 'notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true)' ) const tapHandlerBlock = sliceBetween( 'function notifyTerminalSurfaceTap(originX, originY, focusKeyboard)', - "document.addEventListener('touchstart'" + 'document.addEventListener(\n "touchstart"' ) expect(tapHandlerBlock.indexOf('oscLinkAtViewportPoint')).toBeLessThan( tapHandlerBlock.indexOf('urlAtViewportPoint') @@ -228,14 +234,14 @@ describe('TerminalWebView scroll routing', () => { tapHandlerBlock.indexOf('filePathAtViewportPoint') ) expect(tapHandlerBlock.indexOf('filePathAtViewportPoint')).toBeLessThan( - tapHandlerBlock.indexOf('var clickInput = buildMouseClickInput') + tapHandlerBlock.indexOf('const clickInput = buildMouseClickInput') ) - expect(tapHandlerBlock).toContain("notify({ type: 'open-url', url: tappedUrl });") - expect(tapHandlerBlock).toContain("notify({ type: 'terminal-input', bytes: clickInput });") + expect(tapHandlerBlock).toContain('notify({ type: "open-url", url: tappedUrl });') + expect(tapHandlerBlock).toContain('notify({ type: "terminal-input", bytes: clickInput });') expect(tapHandlerBlock).toContain( 'if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode()))' ) - expect(tapHandlerBlock).toContain("notify({ type: 'terminal-tap' });") + expect(tapHandlerBlock).toContain('notify({ type: "terminal-tap" });') }) it('allows x10 mouse gesture reports through the mobile session gate', () => { diff --git a/mobile/src/terminal/terminal-webview-surface-swap-injected.ts b/mobile/src/terminal/terminal-webview-surface-swap-injected.ts deleted file mode 100644 index 0f7b1c9a21d..00000000000 --- a/mobile/src/terminal/terminal-webview-surface-swap-injected.ts +++ /dev/null @@ -1,49 +0,0 @@ -export const TERMINAL_SURFACE_SWAP_JS = String.raw` - // Why: phone-fit startup can issue several init() calls before xterm finishes - // replaying. Track the last painted surface separately from its replacement. - var committedTerm = null; - var committedSurface = surface; - var pendingTerm = null; - var pendingSurface = null; - - 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) { - try { pendingSurface.remove(); } catch (e) {} - if (pendingTerm) try { pendingTerm.dispose(); } catch (e) {} - pendingSurface = null; - pendingTerm = null; - } - var swap = { - oldTerm: committedTerm, - oldSurface: committedSurface, - nextSurface: document.createElement('div') - }; - disposeTermObservers(); - swap.nextSurface.id = 'terminal-surface'; - swap.nextSurface.style.visibility = 'hidden'; - swap.nextSurface.style.position = 'absolute'; - swap.nextSurface.style.left = '0'; - swap.nextSurface.style.top = '0'; - document.getElementById('terminal-container').appendChild(swap.nextSurface); - surface = swap.nextSurface; - pendingSurface = swap.nextSurface; - attachSurfaceEventHandlers(surface); - swap.oldSurface.removeAttribute('id'); - return swap; - } - - function commitTerminalSurfaceSwap(swap, nextTerm) { - swap.nextSurface.style.visibility = 'visible'; - swap.nextSurface.style.position = ''; - swap.nextSurface.style.left = ''; - swap.nextSurface.style.top = ''; - swap.oldSurface.remove(); - if (swap.oldTerm) swap.oldTerm.dispose(); - committedTerm = nextTerm; - committedSurface = swap.nextSurface; - pendingTerm = null; - pendingSurface = null; - } -` diff --git a/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts b/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts deleted file mode 100644 index 23036f84dfd..00000000000 --- a/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts +++ /dev/null @@ -1,188 +0,0 @@ -// Document-level latching touch dispatcher, injected into XTERM_HTML. Extracted -// from terminal-webview-html.ts to keep that file within its max-lines budget. -// Closes over host-IIFE state/functions: dispatch/tapCandidate/longPress*, -// viewportToCell, enterSelect, cancelSelect, handleDragMove, stopEdgeScroll, -// notify, notifyTerminalSurfaceTap, surface/handle/overlay elements, sel/selMode, -// and the LONG_PRESS_*/TAP_* constants. -export const TERMINAL_TAP_DISPATCH_JS = ` - // ============================================================ - // LATCHING TOUCH DISPATCHER (document-level) - // ============================================================ - var dispatch = { mode: 'idle', touchId: null, touchIds: null, longPressFingerInsideOverlay: false }; - - function touchById(touches, id) { - for (var i = 0; i < touches.length; i++) { - if (touches[i].identifier === id) return touches[i]; - } - return null; - } - - function targetInside(target, el) { - if (!target || !el) return false; - return el.contains(target); - } - - function clearLongPress() { - if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } - longPressOrigin = null; - } - - function armLongPress(touch) { - longPressOrigin = { x: touch.clientX, y: touch.clientY, identifier: touch.identifier }; - longPressTimer = setTimeout(function() { - longPressTimer = null; - if (!longPressOrigin) return; - var c = viewportToCell(longPressOrigin.x, longPressOrigin.y); - if (!c) return; - enterSelect(c.col, c.row); - }, LONG_PRESS_MS); - } - - function touchSlopExceeded(t) { - if (!longPressOrigin) return false; - var dx = Math.abs(t.clientX - longPressOrigin.x); - var dy = Math.abs(t.clientY - longPressOrigin.y); - return (dx + dy) > LONG_PRESS_SLOP; - } - - // Why: existing surface handlers stay attached to surface but we wrap - // their entry to no-op when the dispatcher latches into select-drag. - function dispatcherShouldBlockSurface() { - return dispatch.mode === 'select-drag'; - } - - document.addEventListener('touchstart', function(e) { - var t = e.touches[0]; - var target = e.target; - var onHandle = target === handleStart || target === handleEnd; - var inOverlay = targetInside(target, selectionOverlay); - var inSurface = targetInside(target, surface); - // Why: clear any stale tap candidate up front; only a fresh single-finger - // surface touch (below) re-arms it, so handle drags / pinches / dismiss - // taps never resolve as a link tap on touchend. - tapCandidate = null; - - if (e.touches.length === 2) { - // pinch latch - if (selMode === 'select') { - notify({ type: 'mobile-clip-cancel-by-pinch' }); - cancelSelect(); - } - dispatch.mode = 'pinch'; - dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier]; - clearLongPress(); - return; - } - - if (onHandle && selMode === 'select') { - // start handle drag - var handleName = (target === handleStart) ? 'start' : 'end'; - sel.activeHandle = handleName; - dispatch.mode = 'select-drag'; - dispatch.touchId = t.identifier; - e.preventDefault(); - return; - } - - if (inOverlay) { - // tap on menu pill — let the buttons' own handlers fire - return; - } - - if (inSurface && selMode === 'select') { - // Why: tap-to-dismiss matches native iOS/Android — touching outside the - // 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; - return; - } - - if (inSurface) { - dispatch.mode = 'surface'; - dispatch.touchId = t.identifier; - tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier }; - armLongPress(t); - } - }, { capture: true, passive: false }); - - document.addEventListener('touchmove', function(e) { - if (dispatch.mode === 'select-drag') { - var t = touchById(e.touches, dispatch.touchId); - if (!t || !sel || !sel.activeHandle) return; - e.preventDefault(); - handleDragMove(sel.activeHandle, t.clientX, t.clientY); - return; - } - if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') { - // long-press slop check - if (longPressTimer && e.touches.length === 1) { - if (touchSlopExceeded(e.touches[0])) clearLongPress(); - } - // Why: disqualify the tap only once the finger travels past TAP_SLOP - // (a scroll/pan), independent of the long-press timer — so a tap that - // jitters under TAP_SLOP still opens the link/path under the finger. - if (tapCandidate && e.touches.length === 1) { - var mt = e.touches[0]; - if (mt.identifier === tapCandidate.identifier) { - var dx = Math.abs(mt.clientX - tapCandidate.x); - var dy = Math.abs(mt.clientY - tapCandidate.y); - if (dx + dy > TAP_SLOP) tapCandidate = null; - } - } else if (e.touches.length !== 1) { - tapCandidate = null; - } - // existing surface handler will run from its own listener - } - }, { capture: true, passive: false }); - - document.addEventListener('touchend', function(e) { - if (dispatch.mode === 'select-drag') { - if (sel) sel.activeHandle = null; - stopEdgeScroll(); - dispatch.mode = 'idle'; - dispatch.touchId = null; - return; - } - if (dispatch.mode === 'pinch') { - if (e.touches.length < 2) { - dispatch.mode = (e.touches.length === 1) ? 'surface' : 'idle'; - dispatch.touchIds = null; - if (e.touches.length === 1) dispatch.touchId = e.touches[0].identifier; - } - return; - } - if (dispatch.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. - if ( - e.touches.length === 0 && - tapCandidate && - selMode !== 'select' && - Date.now() - tapCandidate.t <= TAP_MAX_MS - ) { - notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true); - } - clearLongPress(); - tapCandidate = null; - if (e.touches.length === 0) { - dispatch.mode = 'idle'; - dispatch.touchId = null; - } - } - }, { capture: true, passive: true }); - - document.addEventListener('touchcancel', function() { - clearLongPress(); - tapCandidate = null; - stopEdgeScroll(); - if (dispatch.mode === 'select-drag') { - if (sel) sel.activeHandle = null; - } - dispatch.mode = 'idle'; - dispatch.touchId = null; - dispatch.touchIds = null; - }, { capture: true, passive: true }); -` diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index d775237ddcc..23964d65969 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -1,7 +1,11 @@ import { readFileSync } from 'node:fs' import { Script } from 'node:vm' import { describe, expect, it } from 'vitest' -import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support' +import { + documentScopePreamble, + generatedDocumentModule +} from './document/generated-document-region.test-support' +import { XTERM_HTML } from './terminal-webview-html' const terminalWebViewSource = readFileSync( new URL('./TerminalWebView.tsx', import.meta.url), @@ -15,24 +19,22 @@ const terminalHtmlDocumentShellSource = readFileSync( new URL('./terminal-webview-html/document-shell.ts', import.meta.url), 'utf8' ) -// Read behavior from the assembled document; the module source only contains -// fragment imports and cannot prove the injected code is present. -const terminalHtmlSource = readTerminalWebViewHtmlSource() -const terminalWebglRecoverySource = readFileSync( - new URL('./terminal-webview-webgl-recovery-injected.ts', import.meta.url), - 'utf8' -) +// Read behavior from the assembled document: it is what the WebView runs, and the module source +// alone cannot prove the generated script carries the code. +const terminalHtmlSource = XTERM_HTML + +const terminalWebglRecoverySource = await generatedDocumentModule('webgl-recovery') function extractStatusDotNormalizer() { - const declarationStart = terminalHtmlSource.indexOf(' var CLAUDE_STATUS_DOT =') - const declarationEnd = terminalHtmlSource.indexOf(' var PRIVATE_MODE_SCAN_TAIL_LIMIT') + const declarationStart = terminalHtmlSource.indexOf(' scope.CLAUDE_STATUS_DOT =') + const declarationEnd = terminalHtmlSource.indexOf(' scope.PRIVATE_MODE_SCAN_TAIL_LIMIT') const functionStart = terminalHtmlSource.indexOf(' function isStatusDotPresentationSelector') - const functionEnd = terminalHtmlSource.indexOf('\n\n function enqueueWrite', functionStart) + const functionEnd = terminalHtmlSource.indexOf('\n function enqueueWrite', functionStart) expect(declarationStart).toBeGreaterThanOrEqual(0) expect(declarationEnd).toBeGreaterThan(declarationStart) expect(functionStart).toBeGreaterThan(declarationEnd) expect(functionEnd).toBeGreaterThan(functionStart) - return `${terminalHtmlSource.slice(declarationStart, declarationEnd)}\n${terminalHtmlSource.slice(functionStart, functionEnd)}` + return `${documentScopePreamble()}${terminalHtmlSource.slice(declarationStart, declarationEnd)}\n${terminalHtmlSource.slice(functionStart, functionEnd)}` } function normalizeStatusDotChunks(chunks: string[]) { @@ -52,8 +54,8 @@ function resolveTerminalFontFamily(navigatorValue: { // Slice only the font block itself (isIOSWebView + terminalFontFamily), anchored // on font-related markers so unrelated edits below it can't break this extraction. const functionStart = terminalHtmlSource.indexOf(' function isIOSWebView()') - const declarationLine = terminalHtmlSource.indexOf(' var terminalFontFamily =', functionStart) - const declarationEnd = terminalHtmlSource.indexOf('\n', declarationLine) + const declarationLine = terminalHtmlSource.indexOf(' scope.terminalFontFamily =', functionStart) + const declarationEnd = terminalHtmlSource.indexOf(';\n', declarationLine) + 1 expect(functionStart).toBeGreaterThanOrEqual(0) expect(declarationLine).toBeGreaterThan(functionStart) expect(declarationEnd).toBeGreaterThan(declarationLine) @@ -61,8 +63,8 @@ function resolveTerminalFontFamily(navigatorValue: { navigator: navigatorValue } new Script(` -${terminalHtmlSource.slice(functionStart, declarationEnd)} -output = terminalFontFamily; +${documentScopePreamble()}${terminalHtmlSource.slice(functionStart, declarationEnd)} +output = scope.terminalFontFamily; `).runInNewContext(context) return context.output ?? '' } @@ -92,16 +94,20 @@ 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('var CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa)') - expect(terminalHtmlSource).toContain('TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e)') + expect(terminalHtmlSource).toContain('scope.CLAUDE_STATUS_DOT = String.fromCharCode(9210)') expect(terminalHtmlSource).toContain( - 'EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f)' + 'scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)' + ) + expect(terminalHtmlSource).toContain( + 'scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)' ) expect(terminalHtmlSource).toContain('function normalizeStatusDotPresentation(data)') expect(terminalHtmlSource).toContain( - 'data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR)' + 'data.replace(\n scope.CLAUDE_STATUS_DOT_PATTERN,\n scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR\n )' + ) + expect(terminalHtmlSource).toContain( + 'scope.writeQueue.push(normalizeStatusDotPresentation(data))' ) - expect(terminalHtmlSource).toContain('writeQueue.push(normalizeStatusDotPresentation(data))') }) it('normalizes Claude status dots idempotently across write chunks', () => { @@ -133,28 +139,28 @@ describe('TerminalWebView text zoom', () => { it('resets pending Claude status dot selector state when the terminal lifecycle resets', () => { const initStart = terminalHtmlSource.indexOf('function init(') const initReplay = terminalHtmlSource.indexOf( - 'var replayData = normalizeInitialData(initialData)' + 'const replayData = normalizeInitialData(initialData)' ) - const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {") - const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart) + const clearStart = terminalHtmlSource.indexOf('} else if (msg.type === "clear") {') + const clearEnd = terminalHtmlSource.indexOf('} else if (msg.type === "measure")', clearStart) expect(initStart).toBeGreaterThanOrEqual(0) expect(initReplay).toBeGreaterThan(initStart) expect(clearStart).toBeGreaterThanOrEqual(0) expect(clearEnd).toBeGreaterThan(clearStart) expect(terminalHtmlSource.slice(initStart, initReplay)).toContain( - 'statusDotPendingSelector = false' + 'scope.statusDotPendingSelector = false' ) expect(terminalHtmlSource.slice(clearStart, clearEnd)).toContain( - 'statusDotPendingSelector = false' + 'scope.statusDotPendingSelector = false' ) }) it('loads Unicode 11 before replaying mobile terminal bytes', () => { expect(terminalHtmlDocumentShellSource).toContain('XTERM_ENGINE_JS') expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon') - const open = terminalHtmlSource.indexOf('term.open(surface)') - const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'") - const replay = terminalHtmlSource.indexOf("enqueueWrite(ESC + '[0m' + replayData)") + const open = terminalHtmlSource.indexOf('scope.term.open(scope.surface)') + const unicode = terminalHtmlSource.indexOf('scope.term.unicode.activeVersion = "11"') + const replay = terminalHtmlSource.indexOf('enqueueWrite(scope.ESC + "[0m" + replayData)') expect(open).toBeGreaterThanOrEqual(0) expect(unicode).toBeGreaterThan(open) expect(replay).toBeGreaterThan(unicode) @@ -164,9 +170,9 @@ describe('TerminalWebView text zoom', () => { expect(terminalHtmlSource).not.toContain('cdn.jsdelivr.net') expect(terminalWebglRecoverySource).toContain('window.WebglAddon.WebglAddon') expect(terminalHtmlSource).toContain('function isIOSWebView()') - expect(terminalHtmlSource).toContain('fontFamily: terminalFontFamily') - expect(terminalHtmlSource).toContain("fontWeight: '300'") - expect(terminalHtmlSource).toContain("fontWeightBold: '500'") + expect(terminalHtmlSource).toContain('fontFamily: scope.terminalFontFamily') + expect(terminalHtmlSource).toContain('fontWeight: "300"') + expect(terminalHtmlSource).toContain('fontWeightBold: "500"') expect(terminalWebglRecoverySource).toContain('new window.WebglAddon.WebglAddon()') }) diff --git a/mobile/src/terminal/terminal-webview-theme-injected.ts b/mobile/src/terminal/terminal-webview-theme-injected.ts deleted file mode 100644 index 98489b219c7..00000000000 --- a/mobile/src/terminal/terminal-webview-theme-injected.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { colors } from '../theme/mobile-theme' - -// Theme normalization and page-surface painting injected into the WebView IIFE. -// Mirrors the desktop minimumContrastRatio gate (src/renderer/src/lib/terminal-contrast-correction.ts, -// #7934/#10104): a dark composed background gets a mild floor of 3 to rescue near-background body text -// (e.g. Antigravity's #262b30 on #1e242a) without over-brightening vibrant ANSI colors; a light -// background keeps the WCAG-AA 4.5 floor. Gate on the composed background luminance, not app mode, -// because either theme slot can hold either kind of theme. An explicit desktop override published on -// the theme payload (#10754) wins over the luminance gate; older hosts simply omit it. -export const TERMINAL_WEBVIEW_THEME_JS = ` - var DARK_BG_MIN_CONTRAST = 3; - var LIGHT_BG_MIN_CONTRAST = 4.5; - // Dark app surface a transparent terminal background composites over (matches desktop APP_SURFACE_COLORS.dark). - var CONTRAST_APP_SURFACE = { r: 10, g: 10, b: 10 }; - - function parseTerminalBackgroundRgba(value) { - if (typeof value !== 'string') return null; - var v = value.trim().toLowerCase(); - if (!v) return null; - if (v === 'black') return { r: 0, g: 0, b: 0, a: 1 }; - if (v === 'white') return { r: 255, g: 255, b: 255, a: 1 }; - if (v === 'transparent') return { r: 0, g: 0, b: 0, a: 0 }; - var hex = v.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/); - if (hex) { - var h = hex[1]; - var ch; - if (h.length === 3 || h.length === 4) { - ch = h.split('').map(function (p) { return parseInt(p + p, 16); }); - } else { - ch = []; - for (var i = 0; i < h.length; i += 2) ch.push(parseInt(h.slice(i, i + 2), 16)); - } - return { r: ch[0], g: ch[1], b: ch[2], a: ch[3] === undefined ? 1 : ch[3] / 255 }; - } - var rgb = v.match(/^rgba?\\(([^)]+)\\)$/); - if (!rgb) return null; - var parts = rgb[1].indexOf(',') >= 0 ? rgb[1].split(',') : rgb[1].split(/[\\s/]+/); - parts = parts.map(function (p) { return p.trim(); }).filter(function (p) { return p.length > 0; }); - if (parts.length < 3) return null; - var channel = function (p) { - var n = p.charAt(p.length - 1) === '%' ? (parseFloat(p) / 100) * 255 : parseFloat(p); - return isFinite(n) ? Math.min(255, Math.max(0, Math.round(n))) : null; - }; - var r = channel(parts[0]), g = channel(parts[1]), b = channel(parts[2]); - if (r === null || g === null || b === null) return null; - var a = 1; - if (parts[3] !== undefined) { - var raw = parts[3].charAt(parts[3].length - 1) === '%' ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]); - a = isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1; - } - return { r: r, g: g, b: b, a: a }; - } - - function terminalRelativeLuminance(rgb) { - var lin = function (c) { - var n = c / 255; - return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); - }; - return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b); - } - - function terminalContrastRatio(a, b) { - var la = terminalRelativeLuminance(a), lb = terminalRelativeLuminance(b); - return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); - } - - // Clamp an explicit desktop override to xterm's 1-21 range; null means "no usable override". - function normalizeTerminalContrastOverride(value) { - if (typeof value !== 'number' || !isFinite(value)) return null; - return Math.min(21, Math.max(1, value)); - } - - // Pick the xterm minimumContrastRatio floor from the composed terminal background. - // Unparseable input defaults to the dark floor so agent output never stays invisible. - function resolveTerminalContrastFloor(background) { - var color = parseTerminalBackgroundRgba(background); - if (!color) return DARK_BG_MIN_CONTRAST; - var composited = color.a < 1 - ? { - r: Math.round(color.r * color.a + CONTRAST_APP_SURFACE.r * (1 - color.a)), - g: Math.round(color.g * color.a + CONTRAST_APP_SURFACE.g * (1 - color.a)), - b: Math.round(color.b * color.a + CONTRAST_APP_SURFACE.b * (1 - color.a)) - } - : color; - var isLight = terminalContrastRatio({ r: 0, g: 0, b: 0 }, composited) >= - terminalContrastRatio({ r: 255, g: 255, b: 255 }, composited); - return isLight ? LIGHT_BG_MIN_CONTRAST : DARK_BG_MIN_CONTRAST; - } - - function normalizeTerminalTheme(input) { - var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object' - ? input.theme - : null; - if (!source) return defaultTheme; - var next = {}; - var keys = Object.keys(defaultTheme); - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - if (typeof source[key] === 'string') next[key] = source[key]; - } - return Object.assign({}, defaultTheme, next); - } - - function applyTerminalTheme(input) { - terminalThemeInput = input; - terminalTheme = normalizeTerminalTheme(input); - var background = terminalTheme.background || '${colors.terminalBg}'; - document.documentElement.style.background = background; - document.body.style.background = background; - // Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754); - // an older host omits the field and the luminance gate stays authoritative. - var publishedFloor = normalizeTerminalContrastOverride( - input && typeof input === 'object' ? input.minimumContrastRatio : undefined - ); - terminalMinimumContrastRatio = - publishedFloor === null ? resolveTerminalContrastFloor(background) : publishedFloor; - if (term) { - term.options.theme = terminalTheme; - term.options.minimumContrastRatio = terminalMinimumContrastRatio; - } - } -` diff --git a/mobile/src/terminal/terminal-webview-theme-injected.test.ts b/mobile/src/terminal/terminal-webview-theme.test.ts similarity index 62% rename from mobile/src/terminal/terminal-webview-theme-injected.test.ts rename to mobile/src/terminal/terminal-webview-theme.test.ts index d0947ef3e92..2b1f5fcbffa 100644 --- a/mobile/src/terminal/terminal-webview-theme-injected.test.ts +++ b/mobile/src/terminal/terminal-webview-theme.test.ts @@ -1,49 +1,69 @@ import { Script } from 'node:vm' import { parse } from 'acorn' import { describe, expect, it } from 'vitest' -import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' +import { + documentDeclaredFunction, + documentScopePreamble, + generatedDocumentModule +} from './document/generated-document-region.test-support' +import type { TerminalDocumentThemeTarget } from './document/terminal-theme' + +const themeSource = await generatedDocumentModule('terminal-theme') const DARK_FLOOR = 3 const LIGHT_FLOOR = 4.5 -// Eval the injected theme JS in a bare context so the declared helpers become -// callable properties on it (mirrors terminal-webview-engine.test.ts). +// Eval the theme block the document carries in a bare context so its declared helpers become +// callable properties on it (mirrors terminal-webview-engine.test.ts). The terminal it drives is +// a scope field in the document, so it is handed in through the scope rather than as a global. function loadThemeInjected(extra: Record = {}): Record { - const context: Record = { - defaultTheme: { background: '#1a1b26', foreground: '#c0caf5' }, - ...extra - } - new Script(TERMINAL_WEBVIEW_THEME_JS).runInNewContext(context) + const { term, ...globals } = extra + const context: Record = { ...globals, hostTerm: term ?? null } + new Script( + `${documentScopePreamble()} +scope.defaultTheme = { background: "#1a1b26", foreground: "#c0caf5" }; +scope.term = hostTerm; +${themeSource}` + ).runInNewContext(context) return context } +function loadContrastFloorResolver(): (bg: unknown) => number { + return documentDeclaredFunction(loadThemeInjected(), 'resolveTerminalContrastFloor') +} + +function loadThemeApplier(term: TerminalDocumentThemeTarget): (input: unknown) => void { + const context = loadThemeInjected({ + term, + document: { + documentElement: { style: { background: '' } }, + body: { style: { background: '' } } + } + }) + return documentDeclaredFunction(context, 'applyTerminalTheme') +} + describe('mobile terminal-webview contrast floor gate', () => { it('parses at the Chrome 74 syntax floor', () => { - expect(() => parse(TERMINAL_WEBVIEW_THEME_JS, { ecmaVersion: 2019 })).not.toThrow() + expect(() => parse(themeSource, { ecmaVersion: 2019 })).not.toThrow() }) it('picks the dark floor for dark composed backgrounds', () => { - const { resolveTerminalContrastFloor } = loadThemeInjected() as { - resolveTerminalContrastFloor: (bg: unknown) => number - } + const resolveTerminalContrastFloor = loadContrastFloorResolver() for (const bg of ['#1a1b26', '#1e242a', '#282828', '#000000', 'black']) { expect(resolveTerminalContrastFloor(bg)).toBe(DARK_FLOOR) } }) it('picks the light floor for light composed backgrounds', () => { - const { resolveTerminalContrastFloor } = loadThemeInjected() as { - resolveTerminalContrastFloor: (bg: unknown) => number - } + const resolveTerminalContrastFloor = loadContrastFloorResolver() for (const bg of ['#ffffff', '#fbf1c7', 'white', 'rgb(240 240 240)']) { expect(resolveTerminalContrastFloor(bg)).toBe(LIGHT_FLOOR) } }) it('composites transparency over the dark app surface before deciding', () => { - const { resolveTerminalContrastFloor } = loadThemeInjected() as { - resolveTerminalContrastFloor: (bg: unknown) => number - } + const resolveTerminalContrastFloor = loadContrastFloorResolver() // Fully transparent → app surface (dark) → dark floor. expect(resolveTerminalContrastFloor('transparent')).toBe(DARK_FLOOR) // Faint white over the dark surface stays dark; opaque-enough white flips light. @@ -52,43 +72,28 @@ describe('mobile terminal-webview contrast floor gate', () => { }) it('defaults unparseable backgrounds to the dark floor so output never stays invisible', () => { - const { resolveTerminalContrastFloor } = loadThemeInjected() as { - resolveTerminalContrastFloor: (bg: unknown) => number - } + const resolveTerminalContrastFloor = loadContrastFloorResolver() for (const bg of [undefined, null, '', 'not-a-color', '#12', 42]) { expect(resolveTerminalContrastFloor(bg)).toBe(DARK_FLOOR) } }) it('writes the resolved floor onto a live terminal when the theme changes', () => { - const term = { options: { theme: undefined as unknown, minimumContrastRatio: 1 } } - const context = loadThemeInjected({ - term, - document: { - documentElement: { style: { background: '' } }, - body: { style: { background: '' } } - } - }) as Record & { applyTerminalTheme: (input: unknown) => void } + const term: TerminalDocumentThemeTarget = { options: { minimumContrastRatio: 1 } } + const applyTerminalTheme = loadThemeApplier(term) - context.applyTerminalTheme({ theme: { background: '#ffffff' } }) + applyTerminalTheme({ theme: { background: '#ffffff' } }) expect(term.options.minimumContrastRatio).toBe(LIGHT_FLOOR) - context.applyTerminalTheme({ theme: { background: '#1e242a' } }) + applyTerminalTheme({ theme: { background: '#1e242a' } }) expect(term.options.minimumContrastRatio).toBe(DARK_FLOOR) }) // #10754: the desktop user can lower or disable the floor. Mobile mirrors the desktop gate, so the // published value has to win here or the same session renders differently on the phone. describe('published desktop override', () => { - function applyOn(term: { options: { minimumContrastRatio: number } }, input: unknown): void { - const context = loadThemeInjected({ - term, - document: { - documentElement: { style: { background: '' } }, - body: { style: { background: '' } } - } - }) as Record & { applyTerminalTheme: (input: unknown) => void } - context.applyTerminalTheme(input) + function applyOn(term: TerminalDocumentThemeTarget, input: unknown): void { + loadThemeApplier(term)(input) } it('uses the published floor instead of the luminance gate', () => { diff --git a/mobile/src/terminal/terminal-webview-url-tap.test.ts b/mobile/src/terminal/terminal-webview-url-tap.test.ts index bd7ff4bf06b..edb6523993f 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.test.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.test.ts @@ -1,11 +1,13 @@ import { createContext, Script } from 'node:vm' import { describe, expect, it } from 'vitest' import type { TappedFilePath } from './terminal-path-tap' -import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' +import { + documentDeclaredFunction, + generatedDocumentModule +} from './document/generated-document-region.test-support' import { TERMINAL_HTTP_URL_MAX_LENGTH, TERMINAL_HTTP_URL_REGEX_SOURCE, - URL_TAP_WEBVIEW_JS, findFileUrlAtColumn, findUrlAtColumn, resolveTerminalOscFileTap, @@ -13,6 +15,13 @@ import { } from './terminal-webview-url-tap' import { XTERM_HTML } from './terminal-webview-html' +// The three modules the document carries the URL-tap group as, in its own order. +const urlTapGroupSource = ( + await Promise.all( + ['path-tap', 'url-tap', 'osc-link-tap', 'surface-tap'].map(generatedDocumentModule) + ) +).join('\n') + type FileTapResolverCase = { name: string uri: string @@ -99,19 +108,15 @@ function createInjectedFileTapResolvers(): { resolveTerminalFileUrlTap: InjectedFileTapResolver resolveTerminalOscFileTap: InjectedFileTapResolver } { - const context = createContext({ URL }) + const context: Record = createContext({ URL }) new Script( - `${TERMINAL_PATH_TAP_JS}\n${URL_TAP_WEBVIEW_JS}\n` + + `${urlTapGroupSource}\n` + 'this.__resolveTerminalFileUrlTap = resolveTerminalFileUrlTap;\n' + 'this.__resolveTerminalOscFileTap = resolveTerminalOscFileTap;' ).runInContext(context) - const injected = context as { - __resolveTerminalFileUrlTap: InjectedFileTapResolver - __resolveTerminalOscFileTap: InjectedFileTapResolver - } return { - resolveTerminalFileUrlTap: injected.__resolveTerminalFileUrlTap, - resolveTerminalOscFileTap: injected.__resolveTerminalOscFileTap + resolveTerminalFileUrlTap: documentDeclaredFunction(context, '__resolveTerminalFileUrlTap'), + resolveTerminalOscFileTap: documentDeclaredFunction(context, '__resolveTerminalOscFileTap') } } @@ -204,6 +209,6 @@ describe('findUrlAtColumn', () => { expect(XTERM_HTML).toContain('function isLocalFileUriHostname(') expect(XTERM_HTML).toContain('return parsePathLineCol(value);') expect(XTERM_HTML).toContain('function notifyTerminalSurfaceTap(') - expect(XTERM_HTML).toContain("notify({ type: 'open-url', url: tappedUrl });") + expect(XTERM_HTML).toContain('notify({ type: "open-url", url: tappedUrl });') }) }) diff --git a/mobile/src/terminal/terminal-webview-url-tap.ts b/mobile/src/terminal/terminal-webview-url-tap.ts index f5d416d5797..c63cf8d48d6 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.ts @@ -43,212 +43,3 @@ function findTerminalUrlAtColumn(lineText: string, col: number, source: string): } return null } - -export const URL_TAP_WEBVIEW_JS = ` - var URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_HTTP_URL_REGEX_SOURCE)}; - var FILE_URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_FILE_URL_REGEX_SOURCE)}; - var URL_TAP_MAX_LENGTH = ${TERMINAL_HTTP_URL_MAX_LENGTH}; - function findUrlAtColumn(lineText, col) { - return findTerminalUrlAtColumn(lineText, col, URL_TAP_RE_SOURCE); - } - function findFileUrlAtColumn(lineText, col) { - return findTerminalUrlAtColumn(lineText, col, FILE_URL_TAP_RE_SOURCE); - } - function findTerminalUrlAtColumn(lineText, col, source) { - if (typeof lineText !== 'string' || lineText.length === 0) return null; - var re = new RegExp(source, 'gi'); - var match; - while ((match = re.exec(lineText)) !== null) { - var end = match.index + match[0].length; - if (match[0].length <= URL_TAP_MAX_LENGTH && col >= match.index && col < end) return match[0]; - if (match[0].length === 0) re.lastIndex++; - } - return null; - } - function fileUrlAtViewportPoint(clientX, clientY) { - var cell = viewportToCell(clientX, clientY); - if (!cell) return null; - return findFileUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col)); - } - function urlAtViewportPoint(clientX, clientY) { - var cell = viewportToCell(clientX, clientY); - if (!cell) return null; - // Map the cell column to a string index so wide chars earlier on the line - // don't shift the match column off the tapped URL. - return findUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col)); - } - - // Why: OSC 8 links can render as labels like "#1234"; the URI lives in - // xterm's internal link service, so every access is guarded and falls through. - function oscLinkService() { - try { - var core = term && term._core; - if (!core) return null; - return core._oscLinkService - || (core._inputHandler && core._inputHandler._oscLinkService) - || null; - } catch (e) { return null; } - } - function oscLinkAtViewportPoint(clientX, clientY) { - try { - var cell = viewportToCell(clientX, clientY); - if (!cell) return null; - var line = term.buffer.active.getLine(cell.row); - if (!line) return null; - var urlId = oscLinkIdAtCell(line, cell.col); - if (!urlId) return initialOscLinkAtCell(cell.row, cell.col); - var svc = oscLinkService(); - if (!svc || !svc.getLinkData) return initialOscLinkAtCell(cell.row, cell.col); - var data = svc.getLinkData(urlId); - var uri = data && data.uri; - return terminalOscLinkTarget(uri); - } catch (e) { return null; } - } - function initialOscLinkAtCell(row, col) { - for (var i = 0; i < initialOscLinks.length; i++) { - var link = initialOscLinks[i]; - if (!link || typeof link.uri !== 'string') continue; - if (link.row < initialOscLinkRowOffset) continue; - var shiftedRow = link.row - initialOscLinkRowOffset; - if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return terminalOscLinkTarget(link.uri); - } - return null; - } - function terminalOscLinkTarget(uri) { - if (typeof uri !== 'string') return null; - if (/^https?:/i.test(uri)) return { kind: 'url', url: uri }; - var fileTap = resolveTerminalOscFileTap(uri); - return fileTap ? { kind: 'file', fileTap: fileTap } : null; - } - function resolveTerminalOscFileTap(uri) { - return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri); - } - function resolveTerminalFileUrlTap(uri) { - var parsed; - try { - parsed = new URL(uri); - } catch (e) { - return null; - } - if (parsed.protocol !== 'file:') return null; - var filePath; - try { - filePath = decodeURIComponent(parsed.pathname || ''); - } catch (e) { - return null; - } - if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) { - filePath = '//' + parsed.hostname + filePath; - } else if (/^\\/[A-Za-z]:\\//.test(filePath)) { - filePath = filePath.slice(1); - } - if (!filePath) return null; - var hashTarget = parseFileUrlLineHash(parsed.hash || ''); - if (hashTarget) { - return { pathText: filePath, line: hashTarget.line, column: hashTarget.column }; - } - if (/%3a/i.test(parsed.pathname || '')) { - return { pathText: filePath, line: null, column: null }; - } - return parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null }; - } - function isLocalFileUriHostname(hostname) { - var normalized = String(hostname).toLowerCase(); - return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]'; - } - function parseOscPathLikeTarget(value) { - if (!/^(?:~[\\\\/]|[\\\\/]|\\.{1,2}[\\\\/]|[A-Za-z]:[\\\\/]|[A-Za-z0-9._-]+[\\\\/]|(?=[A-Za-z0-9._-]*\\.[A-Za-z0-9]))/.test(value)) return null; - return parsePathLineCol(value); - } - function parseFileUrlLineHash(hash) { - var match = /^#?L(\\d+)(?:C(\\d+))?$/i.exec(hash); - if (!match) return null; - var line = parseInt(match[1], 10); - var column = match[2] ? parseInt(match[2], 10) : null; - if (line < 1 || (column !== null && column < 1)) return null; - return { line: line, column: column }; - } - function parseFilePathTrailingLineTarget(filePath) { - var match = /^(.*?)(?::(\\d+))(?::(\\d+))?$/.exec(filePath); - if (!match || !match[1] || match[1].charAt(match[1].length - 1) === '/' || match[1].charAt(match[1].length - 1) === '\\\\') return null; - var line = parseInt(match[2], 10); - var column = match[3] ? parseInt(match[3], 10) : null; - if (line < 1 || (column !== null && column < 1)) return null; - return { pathText: match[1], line: line, column: column }; - } - function captureInitialOscLinkTexts() { - if (!Array.isArray(initialOscLinks)) return; - for (var i = 0; i < initialOscLinks.length; i++) { - var link = initialOscLinks[i]; - if (!link || typeof link.text === 'string') continue; - link.text = initialOscLinkTextAtRow(link, link.row); - } - } - function initialOscLinkTextStillMatches(link, row) { - if (typeof link.text !== 'string') return false; - return link.text.length > 0 && initialOscLinkTextAtRow(link, row) === link.text; - } - function initialOscLinkTextAtRow(link, row) { - try { - var lineText = getLineText(row); - var start = cellColToStringIndex(row, link.startCol); - var end = cellColToStringIndex(row, link.endCol); - return lineText.slice(start, end); - } catch (e) { - return ''; - } - } - function oscLinkIdAtCell(line, col) { - try { - var bufCell = line.getCell(col); - return bufCell && bufCell.extended && bufCell.extended.urlId ? bufCell.extended.urlId : 0; - } catch (e) { return 0; } - } - - function notifyTerminalSurfaceTap(originX, originY, focusKeyboard) { - var tappedOscLink = oscLinkAtViewportPoint(originX, originY); - if (tappedOscLink && tappedOscLink.kind === 'file') { - notify({ - type: 'terminal-file-tap', - pathText: tappedOscLink.fileTap.pathText, - line: tappedOscLink.fileTap.line, - column: tappedOscLink.fileTap.column - }); - return; - } - var tappedFileUrl = fileUrlAtViewportPoint(originX, originY); - var tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null; - if (tappedFileUrlPath) { - notify({ - type: 'terminal-file-tap', - pathText: tappedFileUrlPath.pathText, - line: tappedFileUrlPath.line, - column: tappedFileUrlPath.column - }); - return; - } - var tappedUrl = tappedOscLink && tappedOscLink.kind === 'url' ? tappedOscLink.url : urlAtViewportPoint(originX, originY); - if (tappedUrl) { - notify({ type: 'open-url', url: tappedUrl }); - return; - } - var tappedPath = filePathAtViewportPoint(originX, originY); - if (tappedPath) { - notify({ - type: 'terminal-file-tap', - pathText: tappedPath.pathText, - line: tappedPath.line, - column: tappedPath.column - }); - return; - } - var clickInput = buildMouseClickInput(originX, originY); - if (clickInput) { - notify({ type: 'terminal-input', bytes: clickInput }); - } - // Touch still needs native input focus after the TUI consumes its mouse click. - if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) { - notify({ type: 'terminal-tap' }); - } - } -` diff --git a/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts b/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts deleted file mode 100644 index 8e4d27c2348..00000000000 --- a/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts +++ /dev/null @@ -1,62 +0,0 @@ -// WebGL loss and visibility recovery injected into the terminal WebView IIFE. -// It closes over term, terminalGeneration, theme state, and xterm's addon global. -export const TERMINAL_WEBGL_RECOVERY_JS = ` - function refreshTerminalSurface() { - if (!term) return; - try { term.refresh(0, Math.max(0, term.rows - 1)); } catch (e) {} - } - - function cancelWebglContextRecovery() { - if (!webglRecoveryTimer) return; - clearTimeout(webglRecoveryTimer); - webglRecoveryTimer = null; - } - - function attachWebglAddon(allowRecovery) { - if (!term || !window.WebglAddon || !window.WebglAddon.WebglAddon) return false; - var addon = null; - try { - addon = new window.WebglAddon.WebglAddon(); - webglAddon = addon; - if (addon.onContextLoss) addon.onContextLoss(function() { - if (webglAddon !== addon) return; - flog('webgl-context-loss', { retry: allowRecovery }); - webglAddon = null; - try { addon.dispose(); } catch (e) {} - refreshTerminalSurface(); - if (!allowRecovery) return; - // Why: one delayed retry handles transient iOS context loss without - // entering a GPU crash loop; a second loss stays on the DOM renderer. - cancelWebglContextRecovery(); - var recoveryTerm = term; - var recoveryGeneration = terminalGeneration; - webglRecoveryTimer = setTimeout(function() { - webglRecoveryTimer = null; - if (term !== recoveryTerm || terminalGeneration !== recoveryGeneration) return; - attachWebglAddon(false); - }, 100); - }); - term.loadAddon(addon); - if (!allowRecovery) { - try { if (addon.clearTextureAtlas) addon.clearTextureAtlas(); } catch (e) {} - refreshTerminalSurface(); - } - return true; - } catch (e) { - flog('webgl-attach-failed', { retry: !allowRecovery, message: String(e) }); - if (webglAddon === addon) webglAddon = null; - try { if (addon) addon.dispose(); } catch (disposeError) {} - refreshTerminalSurface(); - return false; - } - } - - document.addEventListener('visibilitychange', function() { - if (document.visibilityState !== 'visible') return; - // Why: iOS may restore the xterm model while discarding GPU pixels/theme - // paint state, so visibility must rebuild the atlas and repaint every row. - applyTerminalTheme(terminalThemeInput); - try { if (webglAddon && webglAddon.clearTextureAtlas) webglAddon.clearTextureAtlas(); } catch (e) {} - refreshTerminalSurface(); - }); -` diff --git a/mobile/src/terminal/terminal-webview-wheel-scroll-injected.ts b/mobile/src/terminal/terminal-webview-wheel-scroll-injected.ts deleted file mode 100644 index 8d2321e7c25..00000000000 --- a/mobile/src/terminal/terminal-webview-wheel-scroll-injected.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Indirect-pointer (external mouse / trackpad) scroll for the terminal surface, -// injected into XTERM_HTML. Extracted from terminal-webview-html.ts to keep that -// file within its max-lines budget. Closes over host-IIFE state/functions: -// term, getCellHeight, getTotalScale, shouldRouteScrollToTerminalInput, -// routeScrollLines, enqueueNormalBufferScrollDelta, resetSmoothScrollOffset, -// and dispatcherShouldBlockSurface. -export const TERMINAL_WHEEL_SCROLL_JS = ` - var wheelAccumDeltaY = 0; - - function wheelEventPixelDeltaY(e) { - var delta = e.deltaY; - if (typeof delta !== 'number' || !isFinite(delta) || delta === 0) return 0; - // DOM_DELTA_LINE / DOM_DELTA_PAGE: Android WebView reports line-mode deltas - // for external mouse wheels, iOS trackpads report pixels. - if (e.deltaMode === 1) return delta * getCellHeight() * getTotalScale(); - if (e.deltaMode === 2) return delta * window.innerHeight; - return delta; - } - - function attachSurfaceWheelHandler(targetSurface) { - targetSurface.addEventListener('wheel', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - // Why: xterm's own wheel handler scrolls its hidden viewport or emits - // cursor keys through onData, which the mobile query-reply gate drops. - // Claim the event so indirect pointers share the touch scroll router. - e.preventDefault(); - e.stopPropagation(); - - // Why: a trackpad pinch arrives as ctrl+wheel. Swallow it rather than - // firing cursor keys at the TUI; two-finger pinch still drives text size. - if (e.ctrlKey) return; - - var deltaY = wheelEventPixelDeltaY(e); - if (deltaY === 0) return; - - if (shouldRouteScrollToTerminalInput()) { - resetSmoothScrollOffset(); - var effectiveCellH = getCellHeight() * getTotalScale(); - if (!(effectiveCellH > 0)) return; - wheelAccumDeltaY += deltaY; - var lines = Math.trunc(wheelAccumDeltaY / effectiveCellH); - if (lines !== 0) { - wheelAccumDeltaY -= lines * effectiveCellH; - routeScrollLines(lines, e.clientX, e.clientY); - } - return; - } - wheelAccumDeltaY = 0; - enqueueNormalBufferScrollDelta(deltaY); - }, { capture: true, passive: false }); - } -`