diff --git a/mobile/src/terminal/document/document-constants.ts b/mobile/src/terminal/document/document-constants.ts index 9eb11e1f487..9a686cbca51 100644 --- a/mobile/src/terminal/document/document-constants.ts +++ b/mobile/src/terminal/document/document-constants.ts @@ -1,4 +1,5 @@ import { colors } from '../../theme/mobile-theme' +import { MOBILE_TERMINAL_CARET_OPTIONS } from '../terminal-webview-html/theme' import { TERMINAL_FILE_URL_REGEX_SOURCE, TERMINAL_HTTP_URL_MAX_LENGTH, @@ -27,3 +28,9 @@ export const terminalFileUrlRegexSource = TERMINAL_FILE_URL_REGEX_SOURCE /** The longest candidate a tap will open, matching desktop. */ export const terminalHttpUrlMaxLength = TERMINAL_HTTP_URL_MAX_LENGTH + +/** The caret options, one export each because a substitution is keyed by name. */ +export const terminalCursorBlink = MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink +export const terminalCursorStyle = MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle +export const terminalShowCursorImmediately = MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately +export const terminalCursorInactiveStyle = MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle diff --git a/mobile/src/terminal/document/document-externals.ts b/mobile/src/terminal/document/document-externals.ts index 728b258e975..26c4c844668 100644 --- a/mobile/src/terminal/document/document-externals.ts +++ b/mobile/src/terminal/document/document-externals.ts @@ -83,3 +83,6 @@ export declare function updateTransform(): void /** `runtime-state-and-text-scaling`: the scale that fits the grid to the viewport. */ export declare function computeFitScale(): number + +/** `runtime-state-and-text-scaling`: the font size in pixels for a text-scale preset. */ +export declare function fontPxForScale(scale: number): number diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index 9c20f83c026..e5faf9bba66 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -85,6 +85,16 @@ export type TerminalDocumentTerminal = { readonly buffer: { readonly active: TerminalDocumentBuffer } readonly options: TerminalDocumentTerminalOptions write: (data: string, callback?: () => void) => void + open: (element: HTMLElement) => void + scrollToLine: (line: number) => void + readonly unicode: { activeVersion: string } + attachCustomKeyEventHandler: (handler: () => boolean) => void + onData: (listener: (data: string) => void) => TerminalDocumentDisposable + readonly textarea?: { + readOnly: boolean + tabIndex: number + setAttribute: (name: string, value: string) => void + } readonly element?: HTMLElement readonly _core?: TerminalDocumentCore readonly modes?: { bracketedPasteMode?: boolean } @@ -145,6 +155,14 @@ export type TerminalDocumentScope = { sgrMouseMode: boolean /** `runtime-state`: whether the TUI asked for SGR pixel (1016) mouse reports. */ sgrMousePixelsMode: boolean + /** `runtime-state`: the text scale the user picked, as a preset index. */ + currentTextScale: number + /** `runtime-state`: the font stack xterm renders with. */ + terminalFontFamily: string + /** `terminal-init-and-write`: whether the first live chunk since init is still pending. */ + firstDataPending: boolean + /** `terminal-init-and-write`: whether the replayed snapshot was an alternate screen. */ + activeAltScreenSnapshot: boolean /** `terminal-fit-scale`: the fit scale the document committed. */ currentScale: number /** `runtime-state`: the pinch zoom the user applied on top of the fit scale. */ @@ -304,6 +322,10 @@ export function createTerminalDocumentScope(): TerminalDocumentScope { trackedMouseTrackingMode: 'none', sgrMouseMode: false, sgrMousePixelsMode: false, + currentTextScale: 1, + terminalFontFamily: '', + firstDataPending: true, + activeAltScreenSnapshot: false, currentScale: 1, userScale: 1, CLAUDE_STATUS_DOT: statusDot, diff --git a/mobile/src/terminal/document/surface-swap.ts b/mobile/src/terminal/document/surface-swap.ts index a8ccc01bfe6..e222976e4e1 100644 --- a/mobile/src/terminal/document/surface-swap.ts +++ b/mobile/src/terminal/document/surface-swap.ts @@ -5,7 +5,7 @@ import { scope, type TerminalDocumentTerminal } from './document-scope' /** The surfaces and terminal a swap is replacing, handed back to whoever commits it. */ export type TerminalSurfaceSwap = { oldTerm: TerminalDocumentTerminal | null - oldSurface: HTMLElement + oldSurface: HTMLElement | null nextSurface: HTMLElement } @@ -58,7 +58,7 @@ export function commitTerminalSurfaceSwap( swap.nextSurface.style.position = '' swap.nextSurface.style.left = '' swap.nextSurface.style.top = '' - swap.oldSurface.remove() + swap.oldSurface!.remove() if (swap.oldTerm) { swap.oldTerm.dispose() } diff --git a/mobile/src/terminal/document/terminal-init.test.ts b/mobile/src/terminal/document/terminal-init.test.ts new file mode 100644 index 00000000000..b7de5d42dbf --- /dev/null +++ b/mobile/src/terminal/document/terminal-init.test.ts @@ -0,0 +1,30 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs' +import { TERMINAL_HTML_INIT_AND_WRITE } from '../terminal-webview-html/terminal-init-and-write' +import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support' + +/** The slice opens with the already-extracted webgl-recovery group. */ +const modulePaths = ['./webgl-recovery.ts', './terminal-init.ts'].map((relative) => + fileURLToPath(new URL(relative, import.meta.url)) +) + +describe('the terminal init-and-write slice', () => { + it('emits the script the document carries, modulo the six normalisations', async () => { + const emitted = (await Promise.all(modulePaths.map(emitTerminalDocumentModule))).join('\n') + expect(compareTerminalDocumentScripts(TERMINAL_HTML_INIT_AND_WRITE, emitted, 'scope')).toEqual({ + equivalent: true, + normalisations: { + // init() resets almost every field the document shares, so this is the densest + // qualifier site in the script. + qualifiedReferences: 83, + scopeFieldDeclarations: 0, + rebindings: 11, + bracedBodies: 18, + unboundCatches: 7, + numberProperties: 0, + shorthandProperties: 0 + } + }) + }) +}) diff --git a/mobile/src/terminal/document/terminal-init.ts b/mobile/src/terminal/document/terminal-init.ts new file mode 100644 index 00000000000..d0f44f8e29b --- /dev/null +++ b/mobile/src/terminal/document/terminal-init.ts @@ -0,0 +1,201 @@ +import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' +import { + terminalCursorBlink, + terminalCursorInactiveStyle, + terminalCursorStyle, + terminalShowCursorImmediately +} from './document-constants' +import { fontPxForScale, notify } from './document-externals' +import { + scope, + type TerminalDocumentTerminal, + type TerminalDocumentWebglAddon +} from './document-scope' +import { applyFitScale } from './fit-scale' +import { + isAltScreenActive, + normalizeInitialData, + updateMouseModeFromData +} from './mouse-mode-decset-scan' +import { captureInitialOscLinkTexts } from './osc-link-tap' +import { attachTerminalQueryReplyBridge, resetTerminalDataReplyAuthority } from './query-reply' +import { cancelSelect } from './document-externals' +import { resetEvictionCounter } from './selection-state-and-eviction' +import { beginTerminalSurfaceSwap, commitTerminalSurfaceSwap } from './surface-swap' +import { attachTermObservers } from './term-observers' +import { applyTerminalTheme } from './terminal-theme' +import { attachWebglAddon, cancelWebglContextRecovery } from './webgl-recovery' +import { afterWritesDrained, enqueueWrite, pumpWrites, resetWriteQueue } from './write-queue' + +declare global { + interface Window { + Unicode11Addon?: { Unicode11Addon: new () => TerminalDocumentWebglAddon } + } + const Terminal: new (options: Record) => TerminalDocumentTerminal +} + +export function init( + cols: number, + rows: number, + initialData: unknown, + nextTheme: Parameters[0], + nextFontScale: unknown, + preserveScroll: boolean, + nextOscLinks: unknown +) { + if (typeof nextFontScale === 'number' && nextFontScale > 0) { + scope.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. + const prevB = + preserveScroll && scope.term && scope.term.buffer && scope.term.buffer.active + ? scope.term.buffer.active + : null + const scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1 + scope.terminalGeneration++ + const gen = scope.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() + scope.webglAddon = null + scope.ready = false + resetWriteQueue() + scope.statusDotPendingSelector = false + scope.writesDraining = false + scope.afterDrainCallbacks = [] + scope.initRows = rows || 24 + scope.firstDataPending = true + scope.smoothScrollOffsetY = 0 + scope.wheelAccumDeltaY = 0 + scope.mouseModeScanTail = '' + scope.trackedMouseTrackingMode = 'none' + scope.sgrMouseMode = false + scope.sgrMousePixelsMode = false + scope.lastEmittedModes = { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false + } + const 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) + scope.activeAltScreenSnapshot = isAltScreenActive(replayData) + scope.initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : [] + scope.initialOscLinkRowOffset = 0 + scope.initialOscLinkEvictionReady = false + const surfaceSwap = beginTerminalSurfaceSwap() + // oxlint-disable-next-line no-unused-vars -- the document declares it here; removing it is a different program. + const nextSurface = surfaceSwap.nextSurface + + applyTerminalTheme(nextTheme) + scope.term = new Terminal({ + cols: cols || 80, + rows: rows || 24, + theme: scope.terminalTheme, + minimumContrastRatio: scope.terminalMinimumContrastRatio, + fontFamily: scope.terminalFontFamily, + fontSize: fontPxForScale(scope.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: terminalCursorBlink, + cursorStyle: terminalCursorStyle, + // Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret. + showCursorImmediately: terminalShowCursorImmediately, + // A full inactive cell remains visible under the terminal's phone-fit scale. + cursorInactiveStyle: terminalCursorInactiveStyle, + convertEol: false, + allowProposedApi: true + }) + const nextTerm = scope.term + scope.pendingTerm = nextTerm + // oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: the surface swap just mounted one. + scope.term.open(scope.surface!) + attachWebglAddon(true) + if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) { + try { + scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon()) + scope.term.unicode.activeVersion = '11' + } catch {} + } + if (typeof replayData === 'string' && replayData.length > 0) { + // Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output. + enqueueWrite(scope.ESC + '[0m' + replayData) + } + + // Why: reset eviction tracking + attach observers for the new term. + resetEvictionCounter() + cancelSelect() + attachTermObservers() + attachTerminalQueryReplyBridge(scope.term, gen) + + requestAnimationFrame(function () { + if (gen !== scope.terminalGeneration) { + return + } + scope.ready = true + scope.everReady = true + afterWritesDrained(function () { + if (gen !== scope.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 && scope.term && scope.term.buffer && scope.term.buffer.active) { + try { + scope.term.scrollToLine( + Math.max(0, (scope.term.buffer.active.baseY || 0) - scrollAnchorRows) + ) + } catch {} + } + captureInitialOscLinkTexts() + scope.initialOscLinkRowOffset = 0 + scope.initialOscLinkEvictionReady = true + applyFitScale('init-replay') + notify({ type: 'ready', cols: cols, rows: rows }) + }) + }) +} + +export function write(data: string) { + updateMouseModeFromData(data) + enqueueWrite(data) + pumpWrites(scope.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 (scope.firstDataPending) { + scope.firstDataPending = false + const gen = scope.terminalGeneration + afterWritesDrained(function () { + if (gen !== scope.terminalGeneration) { + return + } + applyFitScale('first-data') + }) + } +} + +export function resize(cols: number, rows: number) { + if (!scope.term) { + return + } + scope.initRows = rows || scope.initRows + scope.term.resize(cols || scope.term.cols, rows || scope.term.rows) + emitKeyboardAvoidanceMetrics() + applyFitScale('resize-msg') + notify({ type: 'ready', cols: cols, rows: rows }) +} + +// reflow(): see terminal-webview-reflow-injected.ts (extracted for max-lines).