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
This commit is contained in:
Jinwoo-H
2026-09-20 09:41:56 -04:00
parent 422940ef93
commit f500fcb075
6 changed files with 265 additions and 2 deletions
@@ -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
@@ -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
@@ -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,
+2 -2
View File
@@ -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()
}
@@ -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
}
})
})
})
@@ -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<string, unknown>) => TerminalDocumentTerminal
}
export function init(
cols: number,
rows: number,
initialData: unknown,
nextTheme: Parameters<typeof applyTerminalTheme>[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).