From f500fcb0752a60435be9cd3d8608e9066dfbbe40 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 20 Sep 2026 09:41:56 -0400 Subject: [PATCH] refactor(mobile): extract the terminal init-and-write slice into a module The slice opens with the already-extracted webgl-recovery group, so its text is two emissions joined. init() resets almost every field the document shares, which makes this the densest qualifier site in the script. The caret options were interpolated from the theme module, so they join `document-constants.ts` as four exports: a substitution is keyed by name, not by property path. One local the document declares and never reads keeps a line-scoped `no-unused-vars` disable. Removing it would be a different program, which is the one thing this branch does not do. Counts: qualified 83, scope declarations 0, rebindings 11, braced bodies 18, unbound catches 7, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../terminal/document/document-constants.ts | 7 + .../terminal/document/document-externals.ts | 3 + .../src/terminal/document/document-scope.ts | 22 ++ mobile/src/terminal/document/surface-swap.ts | 4 +- .../terminal/document/terminal-init.test.ts | 30 +++ mobile/src/terminal/document/terminal-init.ts | 201 ++++++++++++++++++ 6 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 mobile/src/terminal/document/terminal-init.test.ts create mode 100644 mobile/src/terminal/document/terminal-init.ts 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).