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-script.mjs b/mobile/scripts/build-terminal-document-script.mjs
index 093a6e88870..6f82a967864 100644
--- a/mobile/scripts/build-terminal-document-script.mjs
+++ b/mobile/scripts/build-terminal-document-script.mjs
@@ -1,7 +1,11 @@
-import { readFile } from 'node:fs/promises'
+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
@@ -131,3 +135,43 @@ export async function emitTerminalDocumentModule(modulePath) {
.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/terminal-document-module-order.mjs b/mobile/scripts/terminal-document-module-order.mjs
index 4f6d7539df0..7fa99fe5962 100644
--- a/mobile/scripts/terminal-document-module-order.mjs
+++ b/mobile/scripts/terminal-document-module-order.mjs
@@ -5,6 +5,9 @@
*
* 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',
diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts
index 60f96f3f5bb..0fd30f7f809 100644
--- a/mobile/src/terminal/document/document-scope.ts
+++ b/mobile/src/terminal/document/document-scope.ts
@@ -1,5 +1,4 @@
-import { terminalTextScalePresets } from './document-constants'
-import { DEFAULT_TERMINAL_THEME } from '../terminal-webview-html/theme'
+import { terminalDefaultTheme, terminalTextScalePresets } from './document-constants'
import type { TerminalDocumentThemeMessage } from './terminal-theme'
/**
* The state the in-WebView terminal document shares across its parts.
@@ -327,8 +326,8 @@ export function createTerminalDocumentScope(): TerminalDocumentScope {
webglAddon: null,
webglRecoveryTimer: null,
terminalThemeInput: null,
- defaultTheme: DEFAULT_TERMINAL_THEME,
- terminalTheme: DEFAULT_TERMINAL_THEME,
+ defaultTheme: terminalDefaultTheme,
+ terminalTheme: terminalDefaultTheme,
terminalMinimumContrastRatio: 3,
initialOscLinks: [],
initialOscLinkRowOffset: 0,
diff --git a/mobile/src/terminal/document/terminal-document-flip.test.ts b/mobile/src/terminal/document/terminal-document-flip.test.ts
index b4532254a1b..0b7a38e53d9 100644
--- a/mobile/src/terminal/document/terminal-document-flip.test.ts
+++ b/mobile/src/terminal/document/terminal-document-flip.test.ts
@@ -1,22 +1,30 @@
-import { fileURLToPath } from 'node:url'
+import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
-import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs'
-import { TERMINAL_DOCUMENT_MODULE_ORDER } from '../../../scripts/terminal-document-module-order.mjs'
-import { XTERM_ENGINE_JS } from '../terminal-webview-engine.generated'
-import { XTERM_HTML } from '../terminal-webview-html'
+import { fileURLToPath } from 'node:url'
import {
- compareTerminalDocumentScripts,
- readTerminalDocumentScript
-} from './terminal-document-equivalence.test-support'
+ buildTerminalDocumentScript,
+ emitTerminalDocumentModule
+} from '../../../scripts/build-terminal-document-script.mjs'
+import { TERMINAL_DOCUMENT_MODULE_ORDER } from '../../../scripts/terminal-document-module-order.mjs'
+import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support'
/**
* The review of the move, as one number per difference class.
*
- * Every line of the document's script is now a module, and this says the two are the same program
+ * `terminal-document-pre-flip-script.txt` is the hand-written script exactly as it stood before any
+ * of this, taken from the byte fixture that pinned it. This says the modules emit the same program
* modulo the qualifier and the repository's own rules rewriting an ES5 document the moment its
* source is a linted module. Anything outside those classes refuses with the token index and both
* sides, so a reordered statement, a changed literal or a renamed local cannot pass here.
+ *
+ * The scope object is the one thing the emitted script has that the document did not, so it is
+ * pinned on its own below rather than folded into a count.
*/
+const preFlipScript = readFileSync(
+ new URL('../terminal-document-pre-flip-script.txt', import.meta.url),
+ 'utf8'
+)
+
describe('the whole terminal document script', () => {
it('is what the modules emit, modulo the seven normalisations', async () => {
const emitted = await Promise.all(
@@ -25,8 +33,7 @@ describe('the whole terminal document script', () => {
)
)
const candidate = `(function() {\n${emitted.join('\n')}\n})();`
- const baseline = readTerminalDocumentScript(XTERM_HTML, XTERM_ENGINE_JS)
- expect(compareTerminalDocumentScripts(baseline, candidate, 'scope')).toEqual({
+ expect(compareTerminalDocumentScripts(preFlipScript, candidate, 'scope')).toEqual({
equivalent: true,
normalisations: {
// The qualifier, partitioned: 609 reads and writes of a name whose declaration stayed put,
@@ -48,4 +55,21 @@ describe('the whole terminal document script', () => {
}
})
})
+
+ it('adds the scope object and nothing else', async () => {
+ const script = await buildTerminalDocumentScript()
+ const emitted = await Promise.all(
+ TERMINAL_DOCUMENT_MODULE_ORDER.map((name) =>
+ emitTerminalDocumentModule(fileURLToPath(new URL(`./${name}.ts`, import.meta.url)))
+ )
+ )
+ const body = emitted.join('\n')
+ const at = script.indexOf(body)
+ expect(at).toBeGreaterThan(-1)
+ const preamble = script.slice('(function() {\n'.length, at)
+ expect(script.slice(at + body.length)).toBe('\n})();')
+ expect(preamble).toContain('function createTerminalDocumentScope()')
+ expect(preamble).toContain('const scope = createTerminalDocumentScope();')
+ expect(preamble.split('createTerminalDocumentScope').length - 1).toBe(2)
+ })
})
diff --git a/mobile/src/terminal/terminal-document-golden.txt b/mobile/src/terminal/terminal-document-golden.txt
index 12a6d3e1fe0..36059ee8cc5 100644
--- a/mobile/src/terminal/terminal-document-golden.txt
+++ b/mobile/src/terminal/terminal-document-golden.txt
@@ -159,139 +159,229 @@ window.onerror = function(msg) {
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-webview.test.ts b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts
index 203db63bc65..4fdaf8716b9 100644
--- a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts
+++ b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts
@@ -1,16 +1,11 @@
-import { readFileSync } from 'node:fs'
import { Script } from 'node:vm'
import { Terminal } from '@xterm/xterm'
import { describe, expect, it, vi } from 'vitest'
import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected'
import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract'
-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
type Cell = { isBgDefault: () => boolean; isInverse: () => number }
type MetricsNotification = {
@@ -193,17 +188,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-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts
index ac415922851..3258d19710e 100644
--- a/mobile/src/terminal/terminal-webview-engine.test.ts
+++ b/mobile/src/terminal/terminal-webview-engine.test.ts
@@ -3,21 +3,26 @@ import { parse } from 'acorn'
import { describe, expect, it, vi } from 'vitest'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
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
+
+/** The scope object the document opens with; the extracted block reads its state through it. */
+function documentScopePreamble(): string {
+ const start = terminalHtmlSource.indexOf('(function() {\n')
+ const end = terminalHtmlSource.indexOf(' scope.surface = document.getElementById', start)
+ if (start === -1 || end <= start) {
+ throw new Error('the document does not open with the scope object')
+ }
+ return terminalHtmlSource.slice(start + '(function() {\n'.length, end)
+}
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 +79,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 +162,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 +178,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 +243,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
index 19a9cfc07ba..342937fa116 100644
--- a/mobile/src/terminal/terminal-webview-html-source.test-support.ts
+++ b/mobile/src/terminal/terminal-webview-html-source.test-support.ts
@@ -1,31 +1,32 @@
-import { readFileSync } from 'node:fs'
+import { readdirSync, 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
+const DOCUMENT_DIRECTORY = './document/'
+/** The parts of the document that are still markup rather than program. */
+const SHELL_FILES = [
+ './terminal-webview-html/document-shell.ts',
+ './terminal-webview-html/document-close.ts',
+ './terminal-webview-html/theme.ts'
+]
function readSource(relativePath: string): string {
return readFileSync(new URL(relativePath, import.meta.url), 'utf8')
}
/**
- * Reads the TypeScript source that assembles the in-WebView document.
+ * Reads the TypeScript source the in-WebView document is built from.
*
- * 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.
+ * Why a directory and not a list: the document's script is generated from every module under
+ * `document/`, so a new one cannot join the emitted document while staying invisible to the tests
+ * that search this source.
*/
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')
+ const modules = readdirSync(new URL(DOCUMENT_DIRECTORY, import.meta.url))
+ .filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts'))
+ .sort()
+ .map((name) => readSource(DOCUMENT_DIRECTORY + name))
+ if (modules.length < 30) {
+ throw new Error(`expected the document's modules, found ${modules.length}`)
}
- 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')
+ return [readSource(COMPOSER_FILE), ...SHELL_FILES.map(readSource), ...modules].join('\n')
}
diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts
index 8aefefbc021..54769545deb 100644
--- a/mobile/src/terminal/terminal-webview-html.ts
+++ b/mobile/src/terminal/terminal-webview-html.ts
@@ -1,41 +1,15 @@
-import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell'
-import { TERMINAL_HTML_RUNTIME_CONSTANTS } from './terminal-webview-html/runtime-constants'
-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 } from './terminal-webview-html/message-bridge'
+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'
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_CONSTANTS,
- 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,
+ TERMINAL_DOCUMENT_SCRIPT,
TERMINAL_HTML_DOCUMENT_CLOSE
].join('')
diff --git a/mobile/src/terminal/terminal-webview-html/document-close.ts b/mobile/src/terminal/terminal-webview-html/document-close.ts
index 1180b2d921c..693edf2201e 100644
--- a/mobile/src/terminal/terminal-webview-html/document-close.ts
+++ b/mobile/src/terminal/terminal-webview-html/document-close.ts
@@ -1,5 +1,5 @@
-// Closes the IIFE the whole script runs in, then the document itself.
-export const TERMINAL_HTML_DOCUMENT_CLOSE = `})();
+// Closes the document after the generated script.
+export const TERMINAL_HTML_DOCUMENT_CLOSE = `