From 2bce0883bebe63e4c02b1a48444ef0e96520f5cd Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 20 Sep 2026 09:37:42 -0400 Subject: [PATCH] refactor(mobile): extract the terminal write-queue slice into a module The slice also carries `disposeTermObservers` and `extractMouseModeScanTail`, which belong to other concerns but sit here because emitted-document order pins them here; four names stop being externals as a result. The observer disposal keeps its guard-as-expression form behind a line-scoped disable: the rewrite the rule asks for is outside every counted class. Counts: qualified 50, scope declarations 0, rebindings 11, braced bodies 10, unbound catches 1, number properties 0, shorthand properties 0. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../terminal/document/document-externals.ts | 12 -- .../src/terminal/document/document-scope.ts | 44 ++++++ .../document/mouse-mode-decset-scan.ts | 2 +- mobile/src/terminal/document/query-reply.ts | 3 +- mobile/src/terminal/document/surface-swap.ts | 3 +- .../src/terminal/document/term-observers.ts | 7 +- .../src/terminal/document/write-queue.test.ts | 28 ++++ mobile/src/terminal/document/write-queue.ts | 135 ++++++++++++++++++ 8 files changed, 214 insertions(+), 20 deletions(-) create mode 100644 mobile/src/terminal/document/write-queue.test.ts create mode 100644 mobile/src/terminal/document/write-queue.ts diff --git a/mobile/src/terminal/document/document-externals.ts b/mobile/src/terminal/document/document-externals.ts index 4d31f07e395..e9d66918a63 100644 --- a/mobile/src/terminal/document/document-externals.ts +++ b/mobile/src/terminal/document/document-externals.ts @@ -21,9 +21,6 @@ export declare function getTotalScale(): number /** `runtime-state-and-text-scaling`: posts one message to the native host. */ export declare function notify(message: Record): void -/** `write-queue`: runs a callback when the replay queue reaches the point it was enqueued at. */ -export declare function enqueueWriteBoundary(callback: () => void): void - /** `terminal-init-and-write`: whether xterm is showing the alternate screen buffer. */ export declare function isAlternateBufferActive(): boolean @@ -42,9 +39,6 @@ export declare function shouldRouteScrollToTerminalInput(): boolean /** `mouse-report-and-scroll-routing`: sends a line scroll to the terminal at a point. */ export declare function routeScrollLines(lines: number, clientX: number, clientY: number): void -/** `term-observers-and-mode-mirroring`: disposes every xterm listener the last terminal held. */ -export declare function disposeTermObservers(): void - /** `surface-touch-gestures`: binds touch, wheel and tap handlers to a surface element. */ export declare function attachSurfaceEventHandlers(target: HTMLElement): void @@ -84,9 +78,6 @@ export declare function buildMouseClickInput(originX: number, originY: number): /** `mouse-report-and-scroll-routing`: whether a tracking mode consumes plain clicks. */ export declare function isClickMouseTrackingMode(mode: string): boolean -/** `write-queue`: the trailing bytes a DECSET scan must carry into the next chunk. */ -export declare function extractMouseModeScanTail(input: string): string - /** `host-message-router`: routes one decoded host message. */ export declare function handleMsg(msg: unknown): void @@ -101,6 +92,3 @@ export declare function clampPan(): void /** `smooth-scroll-and-cell-geometry`: writes the pan and scale onto the surface transform. */ export declare function updateTransform(): void - -/** `write-queue`: runs a callback once the write queue has drained. */ -export declare function afterWritesDrained(callback: () => void): void diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index 26c457fb68e..cc6b47e64d3 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -83,6 +83,7 @@ export type TerminalDocumentTerminal = { readonly rows: number readonly buffer: { readonly active: TerminalDocumentBuffer } readonly options: TerminalDocumentTerminalOptions + write: (data: string, callback?: () => void) => void readonly _core?: TerminalDocumentCore readonly modes?: { bracketedPasteMode?: boolean } onLineFeed?: (listener: () => void) => TerminalDocumentDisposable @@ -142,6 +143,28 @@ export type TerminalDocumentScope = { sgrMouseMode: boolean /** `runtime-state`: whether the TUI asked for SGR pixel (1016) mouse reports. */ sgrMousePixelsMode: boolean + /** `runtime-state`: Claude's record dot, which iOS WebKit would otherwise promote to emoji. */ + CLAUDE_STATUS_DOT: string + /** `runtime-state`: the variation selector that forces the text glyph. */ + TEXT_PRESENTATION_SELECTOR: string + /** `runtime-state`: the variation selector that forces the emoji glyph. */ + EMOJI_PRESENTATION_SELECTOR: string + /** `runtime-state`: the dot with any trailing selectors, as one pattern. */ + CLAUDE_STATUS_DOT_PATTERN: RegExp + /** `runtime-state`: whether a chunk ended mid-selector, so the next one starts inside it. */ + statusDotPendingSelector: boolean + /** `runtime-state`: how far a split DECSET may be carried before the scan gives up. */ + PRIVATE_MODE_SCAN_TAIL_LIMIT: number + /** `runtime-state`: chunks and boundaries waiting for xterm. */ + writeQueue: TerminalWriteQueueEntry[] + /** `runtime-state`: how far the queue has been consumed, before compaction. */ + writeQueueHead: number + /** `runtime-state`: whether a write is parsing right now. */ + writesDraining: boolean + /** `runtime-state`: callbacks waiting for the queue to empty. */ + afterDrainCallbacks: (() => void)[] + /** `runtime-state`: whether the terminal has been initialised. */ + ready: boolean /** `normal-buffer-smooth-scroll`: sub-row scroll travel not yet committed to xterm. */ smoothScrollOffsetY: number /** `normal-buffer-smooth-scroll`: scroll travel waiting for the next frame. */ @@ -223,6 +246,9 @@ export type TerminalDocumentModes = { sgrMousePixelsMode: boolean } +/** One entry of the write queue: a chunk, a boundary callback, or a consumed slot. */ +export type TerminalWriteQueueEntry = string | (() => void) | undefined + export type TerminalDocumentDisposable = { dispose?: () => void } /** xterm's WebGL addon, as the document loads, repaints and disposes of it. */ @@ -238,6 +264,10 @@ export type TerminalDocumentWebglAddon = { * A factory rather than a shared literal so a second document — a test, or a page that remounts — * starts from its own state instead of inheriting what the last one left. */ +const statusDot = String.fromCharCode(0x23fa) +const textPresentationSelector = String.fromCharCode(0xfe0e) +const emojiPresentationSelector = String.fromCharCode(0xfe0f) + export function createTerminalDocumentScope(): TerminalDocumentScope { return { term: null, @@ -268,6 +298,20 @@ export function createTerminalDocumentScope(): TerminalDocumentScope { trackedMouseTrackingMode: 'none', sgrMouseMode: false, sgrMousePixelsMode: false, + CLAUDE_STATUS_DOT: statusDot, + TEXT_PRESENTATION_SELECTOR: textPresentationSelector, + EMOJI_PRESENTATION_SELECTOR: emojiPresentationSelector, + CLAUDE_STATUS_DOT_PATTERN: new RegExp( + statusDot + '[' + textPresentationSelector + emojiPresentationSelector + ']*', + 'g' + ), + statusDotPendingSelector: false, + PRIVATE_MODE_SCAN_TAIL_LIMIT: 4096, + writeQueue: [], + writeQueueHead: 0, + writesDraining: false, + afterDrainCallbacks: [], + ready: false, smoothScrollOffsetY: 0, pendingNormalScrollDeltaY: 0, normalScrollFrameId: null, diff --git a/mobile/src/terminal/document/mouse-mode-decset-scan.ts b/mobile/src/terminal/document/mouse-mode-decset-scan.ts index 5eb03266559..f47370ab7e9 100644 --- a/mobile/src/terminal/document/mouse-mode-decset-scan.ts +++ b/mobile/src/terminal/document/mouse-mode-decset-scan.ts @@ -1,4 +1,4 @@ -import { extractMouseModeScanTail } from './document-externals' +import { extractMouseModeScanTail } from './write-queue' import { scope } from './document-scope' export function isAltScreenActive(data: unknown): data is string { diff --git a/mobile/src/terminal/document/query-reply.ts b/mobile/src/terminal/document/query-reply.ts index 81eaaf4841f..824604430ca 100644 --- a/mobile/src/terminal/document/query-reply.ts +++ b/mobile/src/terminal/document/query-reply.ts @@ -1,4 +1,5 @@ -import { enqueueWriteBoundary, notify } from './document-externals' +import { enqueueWriteBoundary } from './write-queue' +import { notify } from './document-externals' import { scope, type TerminalDocumentDisposable } from './document-scope' /** diff --git a/mobile/src/terminal/document/surface-swap.ts b/mobile/src/terminal/document/surface-swap.ts index 25c01e56bfa..a8ccc01bfe6 100644 --- a/mobile/src/terminal/document/surface-swap.ts +++ b/mobile/src/terminal/document/surface-swap.ts @@ -1,4 +1,5 @@ -import { attachSurfaceEventHandlers, disposeTermObservers } from './document-externals' +import { disposeTermObservers } from './write-queue' +import { attachSurfaceEventHandlers } from './document-externals' import { scope, type TerminalDocumentTerminal } from './document-scope' /** The surfaces and terminal a swap is replacing, handed back to whoever commits it. */ diff --git a/mobile/src/terminal/document/term-observers.ts b/mobile/src/terminal/document/term-observers.ts index f60c38faffa..fe1adf538b7 100644 --- a/mobile/src/terminal/document/term-observers.ts +++ b/mobile/src/terminal/document/term-observers.ts @@ -1,8 +1,5 @@ -import { - afterWritesDrained, - disposeTermObservers, - updateScrollIndicator -} from './document-externals' +import { afterWritesDrained, disposeTermObservers } from './write-queue' +import { updateScrollIndicator } from './document-externals' import { scope } from './document-scope' import { logFeedAndEvict } from './selection-state-and-eviction' import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' diff --git a/mobile/src/terminal/document/write-queue.test.ts b/mobile/src/terminal/document/write-queue.test.ts new file mode 100644 index 00000000000..1b88f0ca6d2 --- /dev/null +++ b/mobile/src/terminal/document/write-queue.test.ts @@ -0,0 +1,28 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs' +import { TERMINAL_HTML_WRITE_QUEUE } from '../terminal-webview-html/write-queue' +import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support' + +const modulePath = fileURLToPath(new URL('./write-queue.ts', import.meta.url)) + +describe('the write-queue module', () => { + it('emits the script the document carries, modulo the six normalisations', async () => { + const emitted = await emitTerminalDocumentModule(modulePath) + expect(compareTerminalDocumentScripts(TERMINAL_HTML_WRITE_QUEUE, emitted, 'scope')).toEqual({ + equivalent: true, + normalisations: { + // The queue and its head throughout, plus the status-dot constants, the drain flags and + // the generation the pump is running for. + qualifiedReferences: 50, + scopeFieldDeclarations: 0, + rebindings: 11, + bracedBodies: 10, + // Disposing an observer that is already gone. + unboundCatches: 1, + numberProperties: 0, + shorthandProperties: 0 + } + }) + }) +}) diff --git a/mobile/src/terminal/document/write-queue.ts b/mobile/src/terminal/document/write-queue.ts new file mode 100644 index 00000000000..310d903f69c --- /dev/null +++ b/mobile/src/terminal/document/write-queue.ts @@ -0,0 +1,135 @@ +import { scope } from './document-scope' + +export function resetWriteQueue() { + scope.writeQueue = [] + scope.writeQueueHead = 0 +} + +export function isStatusDotPresentationSelector(value: string) { + return value === scope.TEXT_PRESENTATION_SELECTOR || value === scope.EMOJI_PRESENTATION_SELECTOR +} + +export function endsWithStatusDotPresentationSequence(data: string) { + let i = data.length - 1 + while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) { + i-- + } + return i >= 0 && data.charAt(i) === scope.CLAUDE_STATUS_DOT +} + +// Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph. +export function normalizeStatusDotPresentation(data: string) { + if (typeof data !== 'string' || data.length === 0) { + return data + } + if (scope.statusDotPendingSelector) { + scope.statusDotPendingSelector = false + let strippedPendingSelectors = false + while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) { + data = data.slice(1) + } + strippedPendingSelectors = data.length === 0 + if (strippedPendingSelectors) { + scope.statusDotPendingSelector = true + return '' + } + } + const normalized = data.replace( + scope.CLAUDE_STATUS_DOT_PATTERN, + scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR + ) + scope.statusDotPendingSelector = endsWithStatusDotPresentationSequence(data) + return normalized +} + +export function enqueueWrite(data: string) { + scope.writeQueue.push(normalizeStatusDotPresentation(data)) +} + +export function enqueueWriteBoundary(callback: () => void) { + scope.writeQueue.push(callback) +} + +export function nextQueuedWrite() { + if (scope.writeQueueHead >= scope.writeQueue.length) { + resetWriteQueue() + return undefined + } + const next = scope.writeQueue[scope.writeQueueHead] + scope.writeQueue[scope.writeQueueHead] = undefined + scope.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 (scope.writeQueueHead > 128 && scope.writeQueueHead * 2 > scope.writeQueue.length) { + scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead) + scope.writeQueueHead = 0 + } + return next +} + +export function disposeTermObservers() { + const disposables = scope.termObserverDisposables + scope.termObserverDisposables = [] + for (let i = 0; i < disposables.length; i++) { + try { + // oxlint-disable-next-line no-unused-expressions, typescript/no-non-null-assertion -- SAFETY: the guard is the call's own condition, and it proves the method is there. + disposables[i] && disposables[i].dispose && disposables[i].dispose!() + } catch {} + } +} + +export function extractMouseModeScanTail(input: string) { + const start = Math.max(input.lastIndexOf(scope.ESC), input.lastIndexOf(scope.C1_CSI)) + if (start === -1) { + return '' + } + const 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 > scope.PRIVATE_MODE_SCAN_TAIL_LIMIT) { + return '' + } + if (tail === scope.ESC || tail === scope.ESC + '[' || tail === scope.C1_CSI) { + return tail + } + if (tail.indexOf(scope.ESC + '[?') === 0) { + return /^[0-9;]*$/.test(tail.slice(3)) ? tail : '' + } + if (tail.indexOf(scope.C1_CSI + '?') === 0) { + return /^[0-9;]*$/.test(tail.slice(2)) ? tail : '' + } + return '' +} + +export function pumpWrites(gen: number): void { + if (!scope.ready || !scope.term || scope.writesDraining || gen !== scope.terminalGeneration) { + return + } + const next = nextQueuedWrite() + if (typeof next !== 'string') { + if (typeof next === 'function') { + return (next(), pumpWrites(gen)) + } + const callbacks = scope.afterDrainCallbacks + scope.afterDrainCallbacks = [] + for (let i = 0; i < callbacks.length; i++) { + callbacks[i]() + } + return + } + scope.writesDraining = true + // Why: xterm.write() parses asynchronously. Row adjustment/resizing must + // wait until replayed SGR attributes have landed in the buffer. + scope.term.write(next, function () { + if (gen !== scope.terminalGeneration) { + return + } + scope.writesDraining = false + pumpWrites(gen) + }) +} + +export function afterWritesDrained(callback: () => void) { + scope.afterDrainCallbacks.push(callback) + pumpWrites(scope.terminalGeneration) +}