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
This commit is contained in:
Jinwoo-H
2026-09-20 09:37:42 -04:00
parent 5d107e9c2f
commit 2bce0883be
8 changed files with 214 additions and 20 deletions
@@ -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<string, unknown>): 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
@@ -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,
@@ -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 {
+2 -1
View File
@@ -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'
/**
+2 -1
View File
@@ -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. */
@@ -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'
@@ -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
}
})
})
})
+135
View File
@@ -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)
}