From b97a206d2723ea8a0320fbfb234d0a06d8dfcd55 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 20 Sep 2026 06:23:21 -0400 Subject: [PATCH] refactor(mobile): make the query-reply gate a module the page can import The second of the twelve groups, and the one that corrects the scope table's membership rule. `terminalDataRepliesEnabled` is written from four places, so the whole-script census counted it among the 57 variables that cannot stay free across modules. All four writes are in this group. Once the script is modules, a variable written only inside the module that declares it is that module's own state, not the document's, and it stays a `let` there. So the scope object holds what crosses a module boundary, and the 57 is an upper bound rather than the answer; the qualifier count the flip commit pins will be lower than the 641 measured over the single scope, and by how much is a function of where the boundaries fall. Two references do cross here and are qualified: the write-queue generation this group compares against, and the observer-disposal list it pushes onto. Counts pinned: two qualified references, one `var` to `let`, two one-statement `if` bodies braced, both `catch (e) {}` clauses unbound, no declaration moved. The document is untouched, so the byte pin is still green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../terminal/document/document-externals.ts | 6 ++ .../src/terminal/document/document-scope.ts | 22 +++++- .../src/terminal/document/query-reply.test.ts | 28 ++++++++ mobile/src/terminal/document/query-reply.ts | 69 +++++++++++++++++++ 4 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 mobile/src/terminal/document/query-reply.test.ts create mode 100644 mobile/src/terminal/document/query-reply.ts diff --git a/mobile/src/terminal/document/document-externals.ts b/mobile/src/terminal/document/document-externals.ts index 110ef05434f..18527705df4 100644 --- a/mobile/src/terminal/document/document-externals.ts +++ b/mobile/src/terminal/document/document-externals.ts @@ -17,3 +17,9 @@ export declare function getCellHeight(): number /** `terminal-fit-scale`: the fit scale times the user's pinch scale. */ 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 diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index 9426889e70e..0d88aec4459 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -7,8 +7,11 @@ * import — assigning an imported binding is a syntax error. So the written ones become fields here, * and the group that owns each is named beside it. * - * Only the written ones move. A `var` the script never assigns again is an ordinary local and stays - * one, which is what keeps the qualifier off most of the program. + * Two things keep a variable out of this table. One the script never assigns again is an ordinary + * local. One assigned only inside the group that declares it is that module's own state, however + * often it is written — `terminalDataRepliesEnabled` is written from four places and all four are + * in `query-reply`, so it stays a `let` there. Only what crosses a module boundary is shared + * state, which is what keeps the qualifier off most of the program. * * The table grows one group at a time as C7.1 extracts them; a field arrives with its group. */ @@ -25,8 +28,15 @@ export type TerminalDocumentScope = { /** `smooth-scroll-and-cell-geometry`: the surface's pan offset, in viewport pixels. */ panX: number panY: number + /** `terminal-init-and-write`: bumped on every re-init, so a late callback can tell it is stale. */ + terminalGeneration: number + /** `term-observers`: xterm listener handles to dispose when the terminal is replaced. */ + termObserverDisposables: TerminalDocumentDisposable[] } +/** An xterm listener handle, as the document disposes of one. */ +export type TerminalDocumentDisposable = { dispose?: () => void } + /** * The initial values, which are the ones the document's own declarations carried. * @@ -34,7 +44,13 @@ export type TerminalDocumentScope = { * starts from its own state instead of inheriting what the last one left. */ export function createTerminalDocumentScope(): TerminalDocumentScope { - return { term: null, panX: 0, panY: 0 } + return { + term: null, + panX: 0, + panY: 0, + terminalGeneration: 0, + termObserverDisposables: [] + } } /** The document's own scope. The generator emits this declaration at the top of the script. */ diff --git a/mobile/src/terminal/document/query-reply.test.ts b/mobile/src/terminal/document/query-reply.test.ts new file mode 100644 index 00000000000..42392111da8 --- /dev/null +++ b/mobile/src/terminal/document/query-reply.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_QUERY_REPLY_JS } from '../terminal-webview-query-reply-injected' +import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support' + +const modulePath = fileURLToPath(new URL('./query-reply.ts', import.meta.url)) + +describe('the query-reply module', () => { + it('emits the script the document carries, modulo the four normalisations', async () => { + const emitted = await emitTerminalDocumentModule(modulePath) + expect(compareTerminalDocumentScripts(TERMINAL_QUERY_REPLY_JS, emitted, 'scope')).toEqual({ + equivalent: true, + normalisations: { + // `terminalGeneration` and `termObserverDisposables` once each. The reply flag is not + // among them: every one of its four writes is in this module, so it stays a local. + qualifiedReferences: 2, + scopeFieldDeclarations: 0, + // That flag's own declaration, `var` to `let`. + rebindings: 1, + // The two one-statement `if` bodies. + bracedBodies: 2, + // Both `catch (e) {}` clauses, whose binding was never read. + unboundCatches: 2 + } + }) + }) +}) diff --git a/mobile/src/terminal/document/query-reply.ts b/mobile/src/terminal/document/query-reply.ts new file mode 100644 index 00000000000..81eaaf4841f --- /dev/null +++ b/mobile/src/terminal/document/query-reply.ts @@ -0,0 +1,69 @@ +import { enqueueWriteBoundary, notify } from './document-externals' +import { scope, type TerminalDocumentDisposable } from './document-scope' + +/** + * The gate deciding when xterm's parser replies may reach the native host. + * + * One unit so the tests exercise the same replay and generation gate the document runs rather than + * a re-implementation of it — which was already the reason this was one injected string. + */ +export type QueryReplyTerminal = { + attachCustomKeyEventHandler: (handler: () => boolean) => void + textarea?: { + readOnly: boolean + tabIndex: number + setAttribute: (name: string, value: string) => void + } + onData: (listener: (data: string) => void) => TerminalDocumentDisposable +} + +// Written from four places, all of them here, so it is this module's state rather than the +// document's and stays a local. +let terminalDataRepliesEnabled = false + +export function resetTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = false +} + +export function resumeTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = true +} + +export function forwardTerminalDataReply(data: string) { + if (terminalDataRepliesEnabled) { + notify({ type: 'terminal-data', bytes: data }) + } +} + +export function enqueueTerminalDataReplyBoundary(gen: number) { + enqueueWriteBoundary(function () { + if (gen === scope.terminalGeneration) { + terminalDataRepliesEnabled = true + } + }) +} + +export function attachTerminalQueryReplyBridge(term: QueryReplyTerminal, gen: number) { + // Why: parser replies require stdin enabled, but mobile input is owned by + // native controls. Keep xterm's textarea inert for touch/hardware keys. + try { + term.attachCustomKeyEventHandler(function () { + return false + }) + if (term.textarea) { + term.textarea.readOnly = true + term.textarea.tabIndex = -1 + term.textarea.setAttribute('inputmode', 'none') + } + } catch {} + try { + scope.termObserverDisposables.push( + term.onData(function (data) { + forwardTerminalDataReply(data) + }) + ) + } catch {} + // Why: live output can queue before initial replay finishes. Enable replies + // at the replay boundary so those live queries are answered, never replayed ones. + enqueueTerminalDataReplyBoundary(gen) +}