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 = ` ` diff --git a/mobile/src/terminal/terminal-webview-html/document-shell.ts b/mobile/src/terminal/terminal-webview-html/document-shell.ts index 2dc90f8a8eb..7dd869b1df9 100644 --- a/mobile/src/terminal/terminal-webview-html/document-shell.ts +++ b/mobile/src/terminal/terminal-webview-html/document-shell.ts @@ -161,5 +161,4 @@ window.onerror = function(msg) {