* refactor(mobile): split the rich editor document's stylesheet and markup apart
The body constant carried the tail of a `:root` block, every CSS rule and the
editable surface's markup in one string, which only the HTML builder could
splice. A page mounting the document needs the stylesheet and the markup
separately, so they become a function over the theme and a constant.
Byte-for-byte inert: `mobile-rich-markdown-editor-document.test.ts`'s digest of
the shipped document is unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): give the keyboard-inset normaliser its own module
It is the host's half of the inset, read by the controller, and it sat in the
module holding the document's in-page script. The script is about to become
ordinary TypeScript under `rich-markdown/`, where a native-side normaliser does
not belong.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): the rich editor's document becomes scope-threaded modules and a factory
The editor's ~600-line program lived in seven string constants a concatenator
glued into one `<script>`: unreadable, untypeable, and unreachable from a page,
which is where the OTA shell has to run it (ruling 26).
It is now ordinary TypeScript under `src/components/rich-markdown/`. Every
function that touches editor state takes `scope: RichMarkdownEditorScope` first,
`createRichMarkdownEditorDocument(host)` builds the scope, runs the start
sequence and returns `{ send, stop }`, and the six window reads the script did
are host seams with those reads as their defaults: `postToHost`, `promptForUrl`,
`keyboardInsetSource`, `clearTimer`, `getSelection`, `getDocument`.
`runCommand` is async because a host that answers the URL prompt with a modal
cannot answer synchronously; the thirteen commands that never wait stay one
synchronous act.
No module holds a `let` and none does work at parse time (rulings 20, 21), so a
second mount starts from its own state and `stop` takes back both the surface's
four listeners and the viewport's two.
The native document is an esbuild IIFE bundle of `native-document-entry.ts`,
written beside the terminal document's artifact by a fifth postinstall
generator. Nothing ships it yet: the HTML builder still splices the old strings,
which the next commit changes.
Red-first: `rich-markdown-document-parse-time.test.ts` and
`rich-markdown-host-seams.test.ts`. Their readers are the terminal census's,
extracted to `src/test-support/webview-document-census.ts` and pointed at both
documents rather than copied.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): ship the bundled document and retire the editor's script strings
`buildMobileRichMarkdownEditorHtml` splices the esbuild bundle of
`src/components/rich-markdown/`, and the seven string constants and their
concatenator go. `escapeInjectedJavaScriptString` stays: it is the escape for
`injectJavaScript`, which is still how the native host reaches the document.
Equivalence, since a byte golden over the script cannot survive a bundler:
- `rich-markdown/native-document-bundle.test.ts` evaluates the shipped artifact
exactly as the WebView does — its markup, its bridge, its `execCommand`, its
`prompt`, its `visualViewport` — and drives it through the injected handle:
`keyboardInset` then `ready`, all five members, a markdown round trip through
the real escape, an edit under the host's generation, every toolbar command's
engine verb, the `javascript:` refusal, a tapped link, and the module list.
- `mobile-rich-markdown-editor-document.test.ts` keeps a byte pin, now over the
page around the document. Measured on main's own document with its script
region removed and on this one: 5,621 bytes, both
`5054e1d5c87e4ce1805d4856ddc8bf36804e697675e6013d84da453d3e81af25`. The
whole-document digest it replaces was `1ef29c88…`, 29,852 bytes.
Every assertion `mobile-rich-markdown-editor-html.test.ts` made by extracting
functions out of the emitted text is kept, aimed at the modules:
- nested/ordered/task list rendering and serialization, entities, explicit
numbering, the parent-start fallback, read-only checkboxes →
`markdown-round-trip.test.ts`, over real elements rather than shaped objects.
- the emitChange/setEditable guards and the generation carried through a
replacement → `editor-content.test.ts`, behaviourally.
- dismissKeyboard, the tapped caret, the label tap, the restored caret, the
end-of-document fallback, the detached caret → `editor-selection.test.ts`,
with a blur that drops the ranges the way WebKit does.
- parseable script and the injection escape stay in the HTML test.
New with the factory: `document-lifecycle.test.ts` — stop takes the four surface
listeners and the viewport observer off, a second mount is its own document, two
documents do not share `editable`, and a start that throws unwinds.
`use-mobile-rich-markdown-editor-controller`, `MobileRichMarkdownEditor` and the
web fallback tests are untouched and green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the document's mutable bindings from the tree, not the line start
The census matched `/^(let|var) /gm`, so `export let`, a declaration indented
inside a top-level block and a `for (let …)` head were all invisible — three
shapes of the one binding two documents would share — and its single
precondition proved only the shape it could already see.
`moduleLevelMutableBindings` walks the program instead and stops at every
function body, because a binding one call owns is not module state. Its
preconditions are one per shape, with the kind each reports, and a negative case
over a `const` and a function-local `let`/`var` so the empty list is a
measurement rather than a reader that refuses everything.
Red-first: `export let pendingReport = 0` planted in `keyboard-inset.ts` reds it
with `keyboard-inset: let pendingReport`, which the old matcher passed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): pin both WebView document bundles to the mobile root
esbuild writes each module's path into a bundle as a comment relative to the
working directory, and neither generator set `absWorkingDir`. So the artifact's
bytes followed the cwd of whatever postinstall run wrote it: measured from the
repo root, `mobile/`, and `mobile/src`, three digests — and from outside the
repo the comments carried `/Users/<name>/…`, a machine path in the one file
every bundle test compares against a build it makes itself.
Both generators now pin the mobile root, so the four cwds measured agree, and
both bundle tests carry the pin: a digest built in a child process from the OS
temp directory equals the committed artifact's, and no comment in either
artifact is an absolute path or climbs out with `../`.
`build-terminal-document-script.mjs` had the defect verbatim on main; C1 copied
its shape, so both are fixed here rather than leaving the original to be found
again. Neither artifact's bytes move: both were generated from `mobile/`, which
is what `absWorkingDir` now names.
Red-first: deleting the `absWorkingDir` line from either generator reds that
generator's case.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): cover the getSelection seam's override, not just its default
Five of the six seams had both halves and this one had only its window default,
which is the half that cannot fail on the page: there the caret has to come from
the object the host hands over, because a document mounted inside a screen
shares `window` with every other field on it.
The case gives the document a selection of its own, blurs the surface the way
WebKit does — dropping the ranges, which is the whole reason a caret is saved —
and reads the restored caret back out of the host's object. The window's own
selection stays empty throughout, which is what says the default was never
consulted.
Red-first: `rememberSelection` reading `window.getSelection()` instead of the
field reds it; every other case in the file stays green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): make the editor document's stop cancel its pending timer
`stop` took the surface's four listeners and the viewport's observer off and
left the input timer, while the scope kept the handle and the `clearTimer` seam
kept the means to cancel it. A listener comes off with the element it was on; a
scheduled callback holds the scope and fires into a document the host has
already unmounted, posting a change under the generation of content it has
replaced.
`stopEditorContent` cancels it through the seam and clears the field, and the
sequence runs it last — after the listeners that could have scheduled another
one are gone.
Nothing schedules the handle today. The cancel is here because the seam and the
field exist for the day something does, and that is not the moment to discover
`stop` never reached it. The case plants the pending change rather than waiting
for a debounce, and carries its own control: the same timer posts while the
document is running, and posts nothing once it is stopped.
Red-first: dropping `stopEditorContent` from the sequence reds both that case
and the parse-time census's start/stop set comparison.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct the postinstall generator count in both censuses
Two comments said four generators and six generated files. There are five
generators writing six files, and the six are not the six either comment
described: `census-source-files.ts` still named the page's copy of the terminal
document, which ruling 25 retired and #21962 stopped ignoring, while C7.10 C1
added the rich Markdown editor's.
Both now name the lists of record — `mobile/package.json`'s postinstall for the
generators, `mobile/.gitignore` for the files — and say the count is a reading
that grows rather than a fence, which is what made the old numbers wrong twice
over.
Verified against both lists: 5 and 6.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): say which digest is the document and which is the page around it
The docstring put main's whole-document digest and byte count in the sentence
introducing the shell pin, so it read as if `1ef29c88…` and 29,852 bytes were
what the constant below asserts. They are not: that digest is of main's whole
document, script included, and nothing in the file reproduces it. The constant
is of the document with its `<script>` region emptied, taken on main's document
and on this one.
Both are now named and separated, with what each covers and why the shell one
was read twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name the parse-time fixture by its role
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): drop an editor command whose dialog answered after the host moved on
C7.10 C1 made `runCommand` async so a host can answer the URL prompt with a
modal. Inside the WebView that changes nothing — `window.prompt` resolves within
a microtask, and the host reaches the document through `injectJavaScript`, which
is a later task — but on the page the modal is a real task boundary, and while
it is open the host can replace the content, make the editor read-only or
unmount it entirely. The continuation ran anyway: `createLink` against markdown
nobody chose, and a change posted under the new generation carrying an edit made
against the old one.
`acceptsCommands` is the question both halves ask: not stopped, still editable,
still the same generation, still contenteditable. `insertUrl` asks it before
`execCommand` and `runCommand` asks it again before emitting, each against the
generation read before its own wait.
The scope gains `stopped`, which `stopRichMarkdownEditorDocument` sets.
Inert on native, where no state can change across a microtask, so the answer to
both questions is the one the old code assumed.
Red-first: with either check removed, the new case reports
`[ 'createLink', 'createLink' ]` against `[ 'createLink' ]`. The case carries its
own control — an answer that arrives while nothing has moved is still applied
and still reported.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): make the editor's block reader always consume a line
`markdownToHtml` looped forever on `# `, `- ` and `1. `. `isBlockStart` admits a
marker followed by a space, and the list test admits the same, but the heading
reader requires text after the hashes and `parseListLine` requires text after
the marker — so on those lines the list branch consumed nothing and returned the
index it was given, and the paragraph loop gathered nothing and pushed an empty
paragraph without advancing. A one-line file the host handed to `setMarkdown`
froze the WebView.
Two guards, both by the same rule: a branch may only commit if it moved the
index. The list branch falls through when its run is empty, and the paragraph
falls back to the line itself when it gathered none.
Present on main verbatim, so this is inherited rather than introduced — but the
fix is observationally inert, because the only inputs it changes are the ones
that previously never returned. Every input that produced output produces the
same output.
Evidence, from a probe that bounds the loop from the inside rather than waiting
on it: before, `# ` and `- ` both UNBOUNDED; after, twenty marker and fence
shapes all return. The pinned cases carry their own control, `# ok` and `- ok`,
so the fallback is not swallowing the readers it falls back from.
A red-first case is not possible here: without the fix the case does not fail,
it hangs the worker. The probe above is the measurement.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): see every declaration that runs as a document module is evaluated
The parse-time reader inspected only variable declarations, while
`DECLARATION_KINDS` admits classes and default exports. So
`class A { static value = install() }`, a static block, and
`export default install()` all passed a census whose whole job is to refuse
exactly that — and a static field reading `document` passed too, which is the
remount defect the rule exists for, wearing a different shape.
Three shapes now, each reported by what it does rather than what it looks like:
a variable initialiser, a class's static members, and a default export that is
an expression. `DECLARES_WITHOUT_RUNNING` keeps the last one from walking into
the body of `export default function () {}`, whose calls run when something
calls it.
The preconditions are one per shape, with a negative case beside them: an
instance field runs per `new` and nothing in a document is ever constructed, and
a default-exported function declares a body rather than running one.
Inherited from the terminal's census, which had the same reader; both use this
one, and both are green.
Red-first: removing the class branch reds the new precondition case.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read lifecycle exports from the tree, not from one exact spelling
The reader was a regular expression needing `export function`, one line, the
scope parameter and no return type. `export async function startX(`, a return
type, or a parameter list the formatter wrapped made a real lifecycle export
vanish — and the comparison it feeds is a set against the names the sequence
calls, so a function missing from *both* lists makes them agree. A start nobody
runs would have read as a start nobody needs.
It now qualifies a function by what it is: exported, named for its lifecycle,
and taking the document's scope as its only parameter. That last clause is
ruling 20's own wording — a start takes nothing the scope does not already carry
— and the regex was enforcing it by accident, through the single parameter its
pattern happened to allow.
Surfaced by the change: the terminal's `startEdgeScroll(scope, dir)`, which the
regex never matched and the sequence never calls. It takes a direction, so it is
the overlay's act for a drag rather than a module's lifecycle, and the one-
parameter rule refuses it for the stated reason instead of by accident. Both
censuses are green.
Red-first: restoring the regex reds the new precondition case, which covers
`async`, a return type and wrapped parameters, with refusals beside them for a
two-parameter start, another document's scope type, and an unexported function.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the terminal WebView document byte for byte
The document is already pinned as a digest, which says whether the emitted
bytes moved and nothing about where. C7.1 moves the hand-written script inside
it into modules the web page can import and rebuilds the document from them,
and the claim that has to hold through every one of those commits is that the
native screen kept the document it had. A digest cannot be the instrument for
that: it fails as two hexadecimal strings.
So the document is also committed as itself. The fixture is generated by
`scripts/build-terminal-document-fixture.mjs`, never pasted, and the test
rebuilds the comparison through that script's own substitution rather than
restating it, so a fixture written by one rule and read by another cannot agree
with itself.
The generated xterm engine is stored as two placeholders. It is already covered
by the digest test, postinstall regenerates it from whatever xterm the lockfile
holds, and inlining it would put 612 KiB of vendored bytes into the file whose
job is to isolate hand-written changes. Two further cases keep that from
becoming a hole: the placeholders must each appear exactly once and the engine
must not appear at all, and the restored document must equal the real one.
Regenerating the fixture is a review event. It is only correct when the emitted
document was meant to change, and the diff in that commit is the evidence.
Red-first: flipping one character inside a comment in `write-queue.ts` fails
both identity cases with a one-line diff naming the comment, where the digest
test reports a hash.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare two terminal documents as programs, not as bytes
The C7.1 flip commit moves the document's 57 reassigned variables onto a scope
object, because a variable assigned across ES modules is a syntax error, and
every read and write of them gains a qualifier. The ruling asks that the review
of that commit be a test rather than a 515-line read. This is that test's
instrument.
It cannot be a byte comparison. Once the script's source is modules, `oxfmt`
owns its style, and the repository's style has no semicolons where the
hand-written document has one on nearly every line. A byte diff would therefore
be dominated by changes that are not the refactor, which is the opposite of
what the reviewer needs.
So the comparison is over tokens: semicolons are excluded for the same reason
they moved, comments never reach the stream, and one difference is allowed —
`name` becoming `<qualifier>.name`, three tokens for one — which it counts and
reports. It is stricter than "it still runs": a reordered statement, a changed
literal, a dropped operator, a renamed local and a qualifier under the wrong
object name all diverge, each reported with the token index and both sides.
Acorn carries `value` on its tokens but does not declare it, so the field is
read through a narrowing check rather than asserted onto the declared type.
Red-first, by mutation: dropping the qualifier-name check fails the case that
names it; removing the leftover-token check fails the dropped- and
added-statement cases; treating semicolons as significant fails the three cases
that depend on ignoring them. The acceptance case runs on the real 2,758-line
script rather than on a fixture, so the instrument is known to survive
everything the document actually contains.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): count each normalisation the move makes, separately
Measured while extracting the first group: the document's ES5 style is not a
style this repository's own rules permit. `curly` braces 279 brace-less
if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446
`var` declarators become `const`, `let` or a scope field. Those rewrites land
before the qualifier is considered at all, so "the qualifier and nothing else"
was never reachable once the source is a linted module.
The comparison now allows exactly four classes and counts each on its own: a
reference that gained the qualifier, a declaration that moved onto the scope
object, a `var` that only changed keyword, a body that gained braces, and a
catch clause that lost its binding. Separate counters rather than a total,
because the flip commit pins each number and a total would let one class absorb
another — which is the drift the pin exists to catch. The two `var` classes
partition the 446, and the qualifier's 641 sites partition into references that
kept their declaration and declarations that moved.
Two ordering facts the cases pin. The catch rule is tried before the brace rule,
or the inserted-brace rule eats the `{` that follows `catch` and the streams
never resynchronise. A body braced at the very end leaves its closing brace
after the baseline has run out, so trailing closes are absorbed after the walk
rather than reported as a length difference.
Everything outside the four classes still refuses with the token index and both
sides: a changed literal, a dropped operator, a reordered pair, a renamed local,
a qualifier under another object's name, a brace opened and never closed, and a
brace closed where none was opened.
Red-first, by mutation: disabling the catch rule, disabling the trailing-brace
absorption, folding scope-field declarations into plain references, and not
counting brace insertions each fail exactly the case that covers them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the mouse-report cell a module the page can import
The first of the twelve groups the document already names. `*-injected.ts` has
been splicing JS strings into the document for a while, and tests evaluate
those strings, so the one-source-two-consumers shape is already there; what is
missing is that a string cannot be imported by the web page, typechecked, or
linted. This turns one of them into a module and adds the generator that puts
it back into the document.
The generator is a transform, not a bundle: a bundler orders its output by the
dependency graph, and the document's order is part of what the equivalence test
holds fixed. Imports are dropped rather than resolved, because inside the
document every name is already in scope — that is what the single IIFE means —
and `document-externals.ts` declares the names whose groups have not moved yet
and emits nothing at all. esbuild prints an ESM module's exports as a trailing
block, so that block is dropped whole rather than by its keyword; leaving the
keyword behind would put a bare block statement in the document.
Both sides of the comparison now go through that same printer before being
read. Otherwise every choice the printer makes — semicolons, property
shorthand, quote style — reads as a difference in the program when it is a
difference in who typed it, and each would need its own rule. A script that
does not parse is reported as a refusal naming its side, not thrown.
`let` is contextual outside strict mode, so acorn reports it as a name and not
as a keyword; without that the var-to-let rewrite the linter performs would be
refused on every reassigned local.
The group's counts are pinned exactly: nine references gained the qualifier
(`term` seven times, `panX` and `panY` once each), nine locals became `const`
or `let`, thirteen one-statement `if` bodies gained braces, no declaration
moved onto the scope object and no catch clause lost a binding.
The document is untouched, so the byte pin from 3006d8dfdf is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* 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
* refactor(mobile): make reflow a module, and give the generator its own tests
The third group, and the defect it found: esbuild wraps a long import list
across lines, and the generator was skipping only the first of them, which left
the remaining names loose in the emitted script. The document did not parse, and
the equivalence check said so by name rather than throwing — which is what that
refusal path was added for. Both lists, import and export, are now skipped to
their closer instead of by their first line.
The generator's own tests cover what the per-group comparisons cannot say on
their own: an export is unmarked and indented into the document scope, a
one-line import is dropped, a wrapped import is dropped whole, the trailing
export block esbuild prints is dropped rather than left as a bare block
statement, and types are erased without touching the program.
Reflow's counts: eleven qualified references — the terminal ten times and the
settled row count once — six locals that became `const`, and the two early
returns braced. The row count is written from three groups, so unlike the
query-reply flag it is the document's state rather than one module's.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the keyboard-avoidance metrics a module
The fourth group, and the first that needed a non-null assertion.
`lineHasVisibleContent` reads the terminal's column count with no guard of its
own; the guard is in `computeContentBottomRow`, which is its only caller. Adding
a guard would change the program, and optional chaining would change what
happens when there is no terminal — the document throws there today. TypeScript
erases a non-null assertion, so the emitted script is unchanged and the
invariant is written down where the reader needs it.
Reflow now imports the metrics call from this module rather than declaring it an
external, which is the shape every group takes as its neighbours arrive.
Counts: fourteen qualified references, nine locals rebound, ten one-statement
bodies braced, and the two `catch (e) {}` clauses — the row scan and the
alternate-screen probe — unbound.
The scope table's rule is stated more precisely with it: a variable is this
module's own only when the group both declares and assigns it. While the rest of
the document is still strings, one the main slice declares stays shared even if
every use is in one group, because emitting a second declaration beside the one
the slice still carries would not be the same program.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make WebGL loss recovery a module
The fifth group, and the first carrying a top-level statement rather than only
declarations: the visibility listener it registers. In the document that runs
when the IIFE reaches it; as a module it runs on import, which is the same
single registration.
The context-loss listener disposes the addon it is registered on, so it cannot
run before that addon exists, but the assignment is to a `let` a closure
captures and TypeScript will not carry the narrowing across it. A non-null
assertion, erased by the compiler, keeps the emitted script identical and puts
the invariant where the reader is.
Counts: twenty-three qualified references across the terminal, the addon, its
retry timer and the theme the host last sent; three locals rebound; twelve
one-statement bodies braced; five of the six catch clauses unbound, the sixth
keeping its binding because the attach failure reads the error into its
diagnostic.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make indirect-pointer scroll a module, and count a fifth class
The sixth group found a rule the four classes do not cover, so I measured the
whole script rather than meeting them one at a time: linting all 2,757 lines as
a module trips `curly` 279 times and `no-unused-vars` 38, both already counted,
and then five further rules at 23 sites — `prefer-number-properties` 17,
`prefer-includes` 2, `no-useless-escape` 2, `prefer-exponentiation-operator` 1
and `no-unused-expressions` 1.
Seventeen of those 23 are one rewrite: a global numeric function moved onto
`Number`. It has the same token shape as the qualifier, so it is counted as its
own class rather than folded into anything, and only the four numeric globals
are admitted — anything else appearing under `Number` is refused, which a case
pins. Every site is already behind a `typeof … === 'number'` check or is parsing
a string, so the two forms are the same test.
The remaining six sites are each a different shape and too few to be worth
matching; they will surface as refusals in whichever group carries them, and I
will report each rather than widen this.
The scroll accumulator is the first declaration to move onto the scope: it is
declared in this group but a touch scroll in another slice resets it, so the
`var` becomes an assignment to the shared field and the class that exists for
exactly that counts one.
Counts: five qualified references, one declaration moved, four locals rebound,
eight bodies braced, one `Number` rewrite.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal surface-swap group into a module
The seventh named group. `surface` and the uncommitted terminal are read by
other slices, so both move onto the scope; the two committed handles and the
pending surface are declared and assigned only here and stay module locals.
Counts: qualified 7, scope declarations 1, rebindings 4, braced bodies 2,
unbound catches 2, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): substitute build-time constants into the emitted document
The document's script text is not all hand-written: parts of it are template
literals interpolating real values, starting with the theme background. A
module cannot interpolate and still be the same program, so the generator now
derives an esbuild `define` from `document-constants.ts` and substitutes after
the import lines are dropped, when the names are free again. The page imports
the very same bindings, so there is one source either way.
The fixture script's TypeScript loader moves beside it rather than being
written twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal theme group into a module
The eighth named group, and the first parameterised one: its background
fallback comes from the mobile theme through `document-constants.ts`.
Two sites carry a line-scoped lint disable rather than the rewrite the rule
asks for: `indexOf(',') >= 0` and `Math.pow`. Both rewrites are outside every
normalisation class the equivalence instrument counts, so taking them would
change the program the native document carries, which is the one thing this
branch holds fixed. The reason is on the disable line.
Counts: qualified 12, scope declarations 0, rebindings 28, braced bodies 13,
unbound catches 0, number properties 9.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal path-tap group into a module
The ninth named group, and a pure query: it reads no shared state, so it has
no qualifier sites at all.
Two things this group forced. The generator now drops lint directive lines
before the transform, because a directive inside an expression makes esbuild
parenthesise that expression to keep the comment where it was, and those
parentheses are tokens the document does not have. And the two regexes keep
their `no-useless-escape` escapes behind a line-scoped disable, for the same
reason the theme group keeps `Math.pow`.
One name the document declares twice in one function stays `var`. Two
block-scoped declarations would be two bindings where the document has one,
and esbuild renames the inner one to say so.
Counts: qualified 0, scope declarations 0, rebindings 31, braced bodies 20,
unbound catches 0, number properties 2.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal tap-dispatch group into a module
The tenth named group, and the heaviest reader of shared state: the selection,
its elements, its thresholds and both press origins are all declared by the
overlay slice, which is still document text, so all of them move onto the
scope with their declarations left where they are.
Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11,
unbound catches 0, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal mouse-click-drag group into a module
The eleventh named group. The escape byte and both SGR mouse modes join the
scope from the runtime slice; the gesture itself is declared here and never
read outside, so it stays a module local.
Counts: qualified 17, scope declarations 0, rebindings 22, braced bodies 27,
unbound catches 1, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal url-tap group into three modules
The twelfth and last named group, and the second parameterised one: both
candidate patterns and the length bound come through `document-constants.ts`.
Three modules rather than one. At 303 lines it was over the file cap, and the
document's own order interleaves the OSC 8 lookup with the file-URL parsing,
so the split follows that order and the group's text is the three emissions
joined. The test does the joining.
Note for a later lane: `terminal-webview-url-tap.ts` and
`terminal-file-url-tap.ts` already hold TypeScript twins of some of this,
written for the React Native side and not identical to what the document
carries. Collapsing the two is a behaviour change and does not belong in a
branch whose whole claim is that the document did not move.
Counts: qualified 10, scope declarations 0, rebindings 41, braced bodies 25,
unbound catches 6, number properties 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the mouse-mode DECSET scan slice into a module
The first of the thirteen inline slices. Both control-sequence introducers,
the straddling scan tail and all three mode fields are declared by the
runtime-state slice, which is still document text, so they move onto the scope
with their declarations left where they are.
Counts: qualified 20, scope declarations 0, rebindings 10, braced bodies 9,
unbound catches 0, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal message-bridge slice into a module
The script and the document end in the same slice, so the slice splits in two
at the point where the IIFE closes: the script half becomes a module, the
document half stays text. The byte pin proves the join is unchanged.
The second catch keeps its binding: it names the error and reports it.
Counts: qualified 1, scope declarations 0, rebindings 1, braced bodies 0,
unbound catches 1, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): give the document close its own slice file
The previous commit put two exports in one slice file, which the slice-count
guard reads as a mismatch: it derives the slice list from the composer's
imports and cross-checks it against the composed entries, one per file. Five
suites failed to load.
Splitting the file rather than the constant is the better shape anyway. The
file was called `message-bridge-and-document-close` because it carried two
concerns; now each has its own.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal term-observers slice into modules
This slice interpolates the already-extracted keyboard-avoidance group between
its own two halves, so its text is three emissions joined in that order and
the test does the joining.
A sixth normalisation class, measured here rather than assumed: the printer
writes `{ name: name }` back as shorthand, and qualifying the value makes the
property name unavoidable again, so one baseline token faces four. It is
counted on its own like the others, with its own acceptance case in the
instrument's test, and every existing group's pin now carries a zero for it.
Counts: qualified 36, scope declarations 1, rebindings 12, braced bodies 12,
unbound catches 6, number properties 0, shorthand properties 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the selection-state-and-eviction slice into a module
The slice that declares most of the shared selection state: every threshold,
every overlay element and the selection itself, twenty-two scope declarations
in one place. The eviction counter is declared and assigned only here, so it
stays a module local.
Counts: qualified 12, scope declarations 22, rebindings 2, braced bodies 3,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the smooth-scroll and cell-geometry slice
Two modules, not one: the slice carries the normal-buffer smooth scroll and
then the cell-to-pixel geometry, and the split follows that order so the
group's text is the two emissions joined. Four names stop being externals and
become real imports.
Counts: qualified 39, scope declarations 0, rebindings 15, braced bodies 16,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* 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
* refactor(mobile): extract the terminal fit-scale slice into a module
The slice opens with the already-extracted theme group, so its text is two
emissions joined. Four more names stop being externals.
Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20,
unbound catches 0, number properties 9, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* 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
* refactor(mobile): extract the runtime-state and text-scaling slice
The document's declaration block, where almost everything it shares is
declared, with the query-reply and surface-swap groups interpolated inside it.
Three modules: the two declarations that come before the groups, the text
scaling, and the viewport transform with the scroll indicator. Seven more
names stop being externals.
Two things this slice forced.
The scope-declaration rule now counts each declarator of one `var`, because
`var panX = 0, panY = 0` becomes two assignments onto the scope. It has its
own acceptance case in the instrument's test.
The two halves are compared against their own text rather than as one joined
program. The declaration the slice opens with is shadowed by a parameter
inside one of the interpolated groups, and printing the baseline as one
program renames that parameter; qualifying the outer name removes the shadow,
so the rename has nothing to correspond to. Splitting the slice on the group
constants compares like with like, and those groups have their own tests.
Build-time constants are now substituted textually rather than through an
esbuild `define`: a `define` whose value is an object or an array is injected
as a helper binding instead of being inlined.
Counts, head: scope declarations 2. Tail: qualified 31, scope declarations 38,
rebindings 25, braced bodies 13, unbound catches 1.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): format the two test files the last commit left unformatted
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the mouse-report and scroll-routing slice
Two modules around the already-extracted mouse-report-cell group: the viewport
cell lookup that precedes it, and the mouse input encoding and scroll routing
that follow. Eight more names stop being externals, which leaves ten.
Counts: qualified 49, scope declarations 0, rebindings 49, braced bodies 42,
unbound catches 3, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the host-message-router slice into modules
Two modules after the already-extracted reflow group: the postMessage bridge
with the engine error reporting that rides on it, and the router itself.
`notify`, `handleMsg` and `reportEngineError` stop being externals, which
leaves seven.
The catch binding handed to the error reporter keeps a cast: a catch variable
is `unknown` under strict mode, and the reporter reads only `message` before
falling back to `String()`. The reason is on the line.
Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12,
unbound catches 2, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the selection-overlay slice into modules
Two modules after the already-extracted path-tap and url-tap groups: the
selection range with the xterm mirror, and the overlay positioning with the
edge scroll. Six more names stop being externals, which leaves one.
Counts: qualified 77, scope declarations 0, rebindings 96, braced bodies 63,
unbound catches 9, number properties 6, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the surface-touch-gestures slice into modules
The last of the thirteen slices. Two modules after the three already-extracted
groups: the selection menu's buttons, and the touch gestures with the pinch
and the momentum scroll. `attachSurfaceEventHandlers` was the last external,
so `document-externals.ts` is gone: every name the document uses now resolves
to a module.
The instrument reads both sides strict. A loose script has to defend Annex B's
block-scoped function declarations, and the printer does that by hoisting a
`var` and renaming the function, so one side carried a rename the other could
not. Neither name escapes its block, so the two readings agree on behaviour
and only the strict one can be compared. It has its own acceptance case.
Counts: qualified 104, scope declarations 1, rebindings 69, braced bodies 57,
unbound catches 2, number properties 2, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the document's opening declarations into a module
The document shell carried the IIFE opener and the eight declarations inside
it, so it splits the way the message-bridge slice did: the shell keeps the
HTML and the opener, a new slice file holds the declarations, and the byte pin
proves the join is unchanged.
With this every line of the document's script has a module behind it.
Counts: qualified 3, scope declarations 8, rebindings 0, braced bodies 0,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the whole document script against the modules
Every line of the script now has a module behind it, so the whole thing can be
compared at once. This is the review of the move, as one number per class:
qualifier 609 references + 73 declarations = 682 sites
var rebindings 373, the document's 446 declarators less those 73
curly braces 279, the number measured before any of this started
unbound catches 36 of 38; two name their error and report it
Number properties 17, also measured up front
shorthand properties 4, two SGR flags written twice each
unshadowed names 7
A seventh class was needed and is counted like the others: a binding that
shadowed a document variable stops being a shadow once that variable moves
onto the scope, so the printer stops disambiguating it. It has its own
acceptance case.
The module order lives in one file that both this test and the generator read,
so neither can drift from the other.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): keep only the lint directives that do something
Seventeen of the disables were inert: `typescript/no-non-null-assertion` is
not enabled here, and a directive naming two rules on one line is not parsed
at all, so the one rule that did apply was being ignored too. The changed-code
quality gate reports an inert directive as a finding.
The two that matter are back, one rule per line: the guard-as-expression in
the observer disposal, and the local the document declares and never reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): generate the terminal document from its modules
The WebView document is no longer a hand-written IIFE pasted into a template
string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts`
and the 36 modules under `src/terminal/document/` in document order, strips
their imports, exports and line-scoped lint directives, substitutes the
`document-constants.ts` exports textually, reprints each with esbuild and wraps
the result in one IIFE. `terminal-webview-html.ts` composes the shell, that
generated script and the close fragment. The artifact is gitignored and built by
postinstall, like the two engine artifacts.
The emitted document is token-equivalent to the old one under eight counted
normalisation classes, each pinned as an exact number in
`document/terminal-document-flip.test.ts` against the pre-flip text:
qualifiedReferences 609
scopeFieldDeclarations 73
rebindings 373
bracedBodies 279
unboundCatches 36
numberProperties 17
shorthandProperties 4
unshadowedNames 7
Any other difference fails with the token index and both sides. The second case
pins that the new document adds the scope object and nothing else.
Ruling 17: the behavioural tests now grep the generated document through
`XTERM_HTML`, never a module source, so every assertion still speaks about what
the WebView runs. Every assertion stays and the `expect` count per file is
unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22,
keyboard-avoidance 18, query-reply 14. One control per file was run by deleting
the module line the updated pattern guards; all seven red, and the tree restores
green.
Pattern changes, old -> new.
terminal-webview-scroll-routing.test.ts
var deltaY = ts.lastY - y; -> const deltaY = ts.lastY - y;
smoothScrollOffsetY -= deltaY; -> scope.smoothScrollOffsetY -= deltaY;
var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
-> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);
'touchmove' single-quoted, one line -> "touchmove" double-quoted, printer line break
}, { capture: true, passive: false }); -> { capture: true, passive: false }
function momentumStep() -> let momentumStep = function()
pendingNormalScrollDeltaY += deltaY; -> scope.pendingNormalScrollDeltaY += deltaY;
if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) {
normalScrollFrameId = requestAnimationFrame( -> scope.normalScrollFrameId = requestAnimationFrame(
pendingNormalScrollDeltaY = 0; -> scope.pendingNormalScrollDeltaY = 0;
cancelAnimationFrame(normalScrollFrameId); -> cancelAnimationFrame(scope.normalScrollFrameId);
var writeQueueHead = 0; -> scope.writeQueueHead = 0;
writeQueueHead++; -> scope.writeQueueHead++;
writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);
surface.style.transform = 'translate(' + panX -> scope.surface.style.transform = "translate(" + scope.panX
getVisualPanY() + 'px) scale(' -> getVisualPanY() + "px) scale("
var FRICTION = 0.972; -> const FRICTION = 0.972;
var MIN_VEL = 0.012; -> const MIN_VEL = 0.012;
edgeScrollDir = dir; -> scope.edgeScrollDir = dir;
term.scrollLines(edgeScrollDir); -> scope.term.scrollLines(scope.edgeScrollDir);
// Latching document-level touch dispatcher -> function attachSurfaceEventHandlers(
edgeScrollClientX = clientX; -> scope.edgeScrollClientX = clientX;
edgeScrollClientY = clientY; -> scope.edgeScrollClientY = clientY;
return mode !== 'none'; -> return mode !== "none";
var pixelX = cell.x; -> const pixelX = cell.x;
var pixelY = cell.y; -> const pixelY = cell.y;
...isSafeSgrMouseCoordinate(cell.y)) return -> ...isSafeSgrMouseCoordinate(cell.y)) {
...isSafeSgrMouseCoordinate(sgrRow)) return -> ...isSafeSgrMouseCoordinate(sgrRow)) {
if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress;
if (mouseTrackingMode === 'x10') return sgrPress; -> if (mouseTrackingMode === "x10") { return sgrPress;
if (mouseTrackingMode === 'x10') return press; -> if (mouseTrackingMode === "x10") { return press;
if (col > 126 || row > 126) return ''; -> if (col > 126 || row > 126) { return "";
document.addEventListener('touchend' -> document.addEventListener( "touchend"
}, { capture: true, passive: true }); -> { capture: true, passive: true }
notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...)
document.addEventListener('touchstart' -> document.addEventListener( "touchstart"
var clickInput = buildMouseClickInput -> const clickInput = buildMouseClickInput
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput });
terminal-webview-text-zoom.test.ts
var CLAUDE_STATUS_DOT = -> scope.CLAUDE_STATUS_DOT =
var PRIVATE_MODE_SCAN_TAIL_LIMIT -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT
\n\n function enqueueWrite -> \n function enqueueWrite
var terminalFontFamily = -> scope.terminalFontFamily =
output = terminalFontFamily; -> output = scope.terminalFontFamily;
String.fromCharCode(0x23fa) -> String.fromCharCode(9210)
TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)
EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)
data.replace(CLAUDE_STATUS_DOT_PATTERN, ...) -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR )
writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data))
var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData)
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
statusDotPendingSelector = false -> scope.statusDotPendingSelector = false (x2)
term.open(surface) -> scope.term.open(scope.surface)
term.unicode.activeVersion = '11' -> scope.term.unicode.activeVersion = "11"
enqueueWrite(ESC + '[0m' + replayData) -> enqueueWrite(scope.ESC + "[0m" + replayData)
fontFamily: terminalFontFamily -> fontFamily: scope.terminalFontFamily
fontWeight: '300' -> fontWeight: "300"
fontWeightBold: '500' -> fontWeightBold: "500"
terminal-webview-engine.test.ts
var webglAddon = null; .. var webglRecoveryTimer = null;
-> the refreshTerminalSurface()..init( block, with the scope preamble
window.addEventListener('resize' -> window.addEventListener("resize"
'terminal init failed' -> "terminal init failed"
'terminal message failed' -> "terminal message failed"
var everReady = false; -> scope.everReady = false;
everReady = true; -> scope.everReady = true;
fatal === undefined ? !everReady : !!fatal -> fatal === void 0 ? !scope.everReady : !!fatal
msg.type === 'init' && !everReady -> msg.type === "init" && !scope.everReady
/fatal === undefined \? !ready\b/ -> /fatal === void 0 \? !scope\.ready\b/
if (msg.type === 'ping') -> if (msg.type === "ping")
notify({ type: 'pong', pingId: msg.id }) -> notify({ type: "pong", pingId: msg.id })
terminal-webview-reflow.test.ts
} else if (msg.type === 'reflow') { -> } else if (msg.type === "reflow") { (x2)
var MIN_FIT_COLS = 20; -> scope.MIN_FIT_COLS = 20;
if (cols < MIN_FIT_COLS) return; -> if (cols < scope.MIN_FIT_COLS) {
flog('measure-skip-small-width' -> flog("measure-skip-small-width"
notify({ type: 'measure-result', ... }) -> notify({ type: "measure-result", ... })
var dispatch = { mode: 'idle' -> const dispatch = { mode: "idle"
window.addEventListener('message' -> window.addEventListener("message"
terminal-keyboard-avoidance-webview.test.ts
\n // reflow() -> \n function reflow(
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
\n var panX -> \n scope.panX
TERMINAL_REFLOW_JS fragment import -> the reflow(cols, rows)..notify( slice of the document
terminal-webview-query-reply.test.ts
attachTerminalQueryReplyBridge(term, gen) -> attachTerminalQueryReplyBridge(scope.term, gen) (x2)
term.attachCustomKeyEventHandler(function() { return false; })
-> term.attachCustomKeyEventHandler(function() { \n return false; \n });
term.textarea.readOnly = true -> term.textarea.readOnly = true;
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
terminal-webview-url-tap.test.ts
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the
generated document's digest, 730472 -> 723480 bytes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): delete the slice constants and injected fragments
The document is generated from its modules now, so the strings it used to be
pasted together from are dead. Deleted: the fourteen slice constants under
`terminal-webview-html/` (host-message-router, message-bridge,
mouse-mode-decset-scan, mouse-report-and-scroll-routing, runtime-constants,
runtime-state-and-text-scaling, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-touch-gestures,
term-observers-and-mode-mirroring, terminal-fit-scale, terminal-init-and-write,
write-queue) and the eleven `*-injected.ts` files. `document-shell.ts`,
`document-close.ts` and `theme.ts` stay: the shell and close are still the
document's HTML, and `theme.ts` is where `document-constants.ts` reads the
palette from.
Ruling 17, second commit. Tests that asserted the extraction mechanism itself
went with it: they compared one module's emission against the slice text it was
extracted from, and the flip test now pins the whole document against the whole
pre-flip script with the same eight classes. Deleted, all under `document/`:
fit-scale, host-message-router, keyboard-avoidance-metrics, message-bridge,
mouse-click-drag, mouse-mode-decset-scan, mouse-report-and-scroll-routing,
mouse-report-cell, path-tap, query-reply, reflow, runtime-constants,
runtime-state, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-swap, surface-touch-gestures,
tap-dispatch, term-observers, terminal-init, terminal-theme, webgl-recovery,
wheel-scroll. `document/url-tap.test.ts` stays: it pins against
`URL_TAP_WEBVIEW_JS`, which is neither a slice constant nor an injected file and
still has a consumer.
Tests that asserted behaviour through a deleted string now read the generated
document. `document/generated-document-region.test-support.ts` is the one way in:
`documentScopePreamble()` returns the scope object the document opens with, and
`generatedDocumentModule(name)` re-emits a module and refuses unless the document
carries that text verbatim, so an evaluated block is the WebView's own bytes. The
two local copies of the preamble in the engine and text-zoom tests were folded
into it.
Moved, with every assertion kept and the `expect` count per file unchanged:
terminal-webview-html/write-queue.test.ts -> document/write-queue.test.ts 34
terminal-webview-theme-injected.test.ts -> terminal-webview-theme.test.ts 14
terminal-webview-query-reply.test.ts 14
terminal-path-tap.test.ts 25
terminal-webview-url-tap.test.ts 33
terminal-keyboard-avoidance-webview.test.ts 18
terminal-webview-reflow.test.ts 22
terminal-webview-text-zoom.test.ts 59
terminal-webview-engine.test.ts 49
Pattern changes, old -> new.
terminal-webview-reflow.test.ts
if (!term || isAlternateBufferActive()) return;
-> if (!scope.term || isAlternateBufferActive()) {
term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows);
var wasAtBottom = buffer.viewportY >= buffer.baseY;
-> const wasAtBottom = buffer.viewportY >= buffer.baseY;
term.scrollToBottom(); -> scope.term.scrollToBottom();
if (nextCols === term.cols && nextRows === term.rows) return;
-> if (nextCols === scope.term.cols && nextRows === scope.term.rows) {
The other eight files kept their patterns; only the text they read changed, from
a deleted constant to the document block. The harnesses that evaluate a block now
build the document's scope object instead of declaring the vars it replaced, and
hand the terminal in as `scope.term`.
Controls, one per file: the module line an updated pattern guards was removed,
the document rebuilt, and the test run. All red, and the tree restores green.
query-reply terminalDataRepliesEnabled = true -> query-reply test, 2 failed
path-tap const parsed = parsePathLineCol(...) -> path-tap test, red
keyboard-avoidance-metrics contentBottomRow -> keyboard-avoidance test, 4 failed
reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
webgl-recovery new window.WebglAddon.WebglAddon() -> engine and text-zoom tests, 4 failed
osc-link-tap return parsePathLineCol(value) -> url-tap test, 1 failed
terminal-theme scope.term.options.minimumContrastRatio = ...
-> theme test, 4 failed
write-queue scope.writeQueue[scope.writeQueueHead] = undefined
-> write-queue test, 4 failed
`document-scope.ts` docstrings named the slice each field belonged to; they name
the owning module now. Three module comments pointed at deleted injected files
and point at the modules instead. Neither changes the document: esbuild drops
comments, and the byte pin is unmoved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name the right number of counted classes
The flip test's title still said seven; the table it asserts has eight.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): name the shape applyTerminalTheme writes through
The anti-slop gate refused `loadThemeApplier(term: object)` in the theme test.
`applyTerminalTheme` touches exactly two slots on the terminal it is handed, so
`terminal-theme.ts` now exports that shape as `TerminalDocumentThemeTarget` and
the test's parameter and both fixtures use it. The theme is optional on the way
in because `applyTerminalTheme` is what writes it.
No cast. The type is erased by the generator's transform, so the document is
unchanged and the flip test's class table and the byte pin both still hold.
Control: restoring the `object` parameter reproduces the finding at
terminal-webview-theme.test.ts:35:33 and the gate exits 1; with the named type
it exits 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): retire the flip pin, leaving the byte golden as the document's fence
`terminal-document-flip.test.ts` compared the emitted modules against
`terminal-document-pre-flip-script.txt`, the hand-written script as it stood before
C7.1, and held exactly while no module changed. That is the proof of the flip, not a
standing fence: the first lane that must change a module has to retire it or restate
its counted classes for a reason that has nothing to do with the move.
C7.5 is that lane — the document's host seams become scope fields so the page can set
them — so both go here, while the test is still green. The flip proof lives at
51ae7b1b03 ("test(mobile): name the right number of counted classes"), which is where
anyone reviewing the move should read it.
From here the standing pin is the whole-document byte golden,
`terminal-document-golden.txt`, checked by `terminal-document-identity.test.ts` and by
the payload-hash digest beside it. Regenerating it is a review event: the emitted diff
is listed old to new in the commit message and in the PR body, and a golden that moves
without a listed diff is a blocking finding.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): give the terminal document's host seams a field on its scope
Ruling 19: on the page `window.ReactNativeWebView` is the *shell's* bridge, so a
terminal `notify` through it would post raw terminal JSON into the bridge's channel,
and there is no engine IIFE hanging `Terminal` and the two addons off `window` because
the page imports xterm. Four reads had to become seams:
host-notify.ts notify() -> scope.postToHost
viewport-transform flog() -> scope.postToHost
terminal-init.ts new Terminal(...) -> scope.createTerminal
terminal-init.ts window.Unicode11Addon-> scope.createUnicode11Addon
webgl-recovery.ts window.WebglAddon -> scope.createWebglAddon
Each default is the window read the site already did, still performed at call time and
not captured when the scope is built, so inside the WebView the program is the one it
was. `document-host-seams.ts` holds the four and is emitted ahead of the scope object,
because the scope's defaults are those functions and the factory runs as the script is
parsed. `document-terminal-shape.ts` takes the xterm-shape types out of the scope's
file, which the four fields pushed over the 300-line cap; document-scope re-exports
them, so no importer moves. The page's side of the seam lands in C7.5's later commits.
Two shapes kept faithful rather than tidied. The unicode11 addon is still built inside
the `try` it was built in, so a constructor that throws is still swallowed; and no
WebGL addon still returns false from `attachWebglAddon` without reaching the `catch`,
which is the DOM-renderer fallback rather than a failure.
Golden regenerated: terminal-document-golden.txt 105,446 -> 105,968 bytes, document
723,480 -> 724,002. 20 lines out, 36 in, all at the five sites above and nowhere else:
+ (new, top of the IIFE) function postToReactNativeWebView(message) { if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(message)); } }
+ (new) function createEngineTerminal(options) { return new Terminal(options); }
+ (new) function createEngineUnicode11Addon() { return window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon ? new window.Unicode11Addon.Unicode11Addon() : null; }
+ (new) function createEngineWebglAddon() { return window.WebglAddon && window.WebglAddon.WebglAddon ? new window.WebglAddon.WebglAddon() : null; }
- " pendingTerm: null"
+ " pendingTerm: null," and four fields: postToHost: postToReactNativeWebView, createTerminal: createEngineTerminal, createUnicode11Addon: createEngineUnicode11Addon, createWebglAddon: createEngineWebglAddon
- flog's nine lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify({ type: "log", tag: "[fit]" + tag, payload })); }"
+ flog's five lines "scope.postToHost({ type: "log", tag: "[fit]" + tag, payload });"
- " if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) {"
+ " if (!scope.term) {"
- " addon = new window.WebglAddon.WebglAddon();"
+ " addon = scope.createWebglAddon();" then " if (!addon) {" / " return false;" / " }"
- " scope.term = new Terminal({"
+ " scope.term = scope.createTerminal({"
- " if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {" / " try {" / " scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon());" / " } catch {"
+ " try {" / " const unicodeAddon = scope.createUnicode11Addon();" / " if (unicodeAddon) {" / " scope.term.loadAddon(unicodeAddon);" / " } catch {"
- notify's three lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(msg)); }"
+ " scope.postToHost(msg);"
Nothing else in the document moved: the emitted indentation, statement order and every
other literal are byte for byte what they were.
Two pinned readers follow the move. `terminal-webview-payload-hash.test.ts` takes the
new length and digest. `terminal-webview-text-zoom.test.ts` kept both WebGL assertions
and aimed them where the text now is: `window.WebglAddon.WebglAddon` and
`new window.WebglAddon.WebglAddon()` are asserted on the scope preamble rather than on
the recovery module, and the recovery module is asserted to call
`scope.createWebglAddon()`. `host-seams.test.ts` is the new pin: it builds a scope
before the globals exist to show the defaults read the window when they post, shows
each addon factory answering null when the engine has none, and drives a host message
in and a notify out with all four fields set, asserting the bridge is never touched.
Red before this commit at 6 of 7 cases.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* build(mobile): write the xterm stylesheet as its own generated artifact
The page mounts xterm itself, so it needs the engine's stylesheet and must never
resolve the engine string: 612 KiB of minified IIFE built to be injected as text into
a WebView document, unusable under the shell's `script-src 'self'` with no nested
frame to load one into, and the largest single module the session route's closure
would carry. Both lived in `terminal-webview-engine.generated.ts`, so one import of
the CSS pulled the string in behind it.
`build-terminal-webview-engine.mjs` now writes `terminal-webview-engine-css.generated.ts`
beside it from the same read of `@xterm/xterm/css/xterm.css`, with the same comment
strip and the same `http%3A//` scrub the no-external-URL gate wants. Gitignored beside
its neighbour and written by the same postinstall step, so a fresh tree gets both or
neither. `document-shell.ts` takes the CSS from the new module and the engine string
from the old one; `build-terminal-document-fixture.mjs` and the two tests that hold
both constants read them from their new homes.
The document did not move: `terminal-document-golden.txt` is byte for byte what the
last commit left, 105,968 bytes, and the payload digest is unchanged.
The fence is `config/scripts/mobile-web-terminal-engine-closure.test.mjs`. It walks
every module under `src/terminal/document/` as an entry point — the document is one
script whose modules reach each other by side effect, so no single one of them roots
a graph holding the rest — and asserts the engine string is in none of their closures,
with two modules named as the precondition that the walk resolved anything at all. The
native document's own closure is asserted to still hold both generated modules, so the
first case cannot pass by the CSS having gone missing. And the third case plants a
document module that imports the engine string in a scratch tree and shows the walk
reports it, which is what makes the absence above a measurement.
`mobileWebAppRouteClosure` is now a caller of `mobileWebAppEntryClosure`, which takes
the entry points and an optional working directory; the route closure's own two entry
points and its extensionless-specifier reason are unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the dead URL-tap constant and two stale reflow guards
Round 1 fixes, all three folded here.
1. `URL_TAP_WEBVIEW_JS` is gone from terminal-webview-url-tap.ts, with
`document/url-tap.test.ts` deleted alongside it. The document is generated
from its modules now, so that constant was a second copy of the URL-tap group
with no consumer but its own tests. terminal-webview-url-tap.test.ts's
resolver harness reads the document's own text instead, the path-tap,
url-tap, osc-link-tap and surface-tap modules in document order through
`generatedDocumentModule`, which refuses unless the document carries each
verbatim. Its 33 expects all stay. One mechanism-only assertion went with the
file: `document/url-tap.test.ts`'s single `compareTerminalDocumentScripts`
pin of the three emissions against the constant, which the flip test's
whole-document pin already covers. The file's other exports stay.
The deletion surfaced a third reader. terminal-webview-scroll-routing.test.ts
concatenated terminal-webview-url-tap.ts into its `source`, and its
`notify({ type: 'terminal-tap' });` assertion was matching the constant's
single-quoted text, not the document. The read is dropped, since nothing else
in that file needed it, and the assertion is the document's form:
notify({ type: 'terminal-tap' }); -> notify({ type: "terminal-tap" });
Its 95 expects stay. Leaving the read in place would let a document assertion
pass against a module source, which is the hazard this lane exists to remove.
2. terminal-webview-reflow.test.ts guarded a template placeholder that no longer
exists, so it could not fail:
expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}')
-> expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1)
Same intent against the generated document: the reflow module's emitted text
is in the document exactly once. The case is renamed to say so and the
comment above it describes the generator, not the deleted template.
3. Same file, the routine assertion still passed as a substring of the qualified
call; qualified as line 30 already was:
term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows);
Its 22 expects stay.
Controls, each verified to have changed the file first, all red, tree green
after restore:
osc-link-tap return parsePathLineCol(value) -> url-tap test, 3 failed
surface-tap notify({ type: 'terminal-tap' }) -> scroll-routing, 1 failed
reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
module order 'reflow' listed twice -> reflow test, expected 2 to be 1
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): mount the terminal document in the page instead of a WebView
`react-native-webview` has no web build that renders anything: measured, it paints the
line "React Native WebView does not support this platform" where the terminal was. So
the page mounts the document itself — xterm imported from `@xterm/xterm` with the
unicode11 and webgl addons, and the document's own modules imported in the order the
generator emits them — behind the identical `TerminalWebViewProps` and
`TerminalWebViewHandle`.
Written as one implementation, not two. `use-terminal-webview-controller.ts` is
everything `TerminalWebView.tsx` did that was not about `react-native-webview`: the
readiness handshake, the pending queue, the write coalescer, the notify dispatch and
the whole imperative handle. Its two arguments are the difference between the hosts —
a sink that takes one `TerminalWebViewCommand`, and whether a foreground return has to
re-prove the document with a ping. The native component posts across the bridge and
answers yes on iOS; the web component calls `handleMsg` and answers no, because its
document is the page's own modules and there is no second content process to lose. A
second copy of that file is the fork the series exists to avoid, since the handle is
the contract every consumer holds.
`terminal-webview-ready-promises.ts` carries the two promises the handle hands out,
`awaitReady` and `measureFitDimensions`, which the controller's length made a module.
`document-style.ts` and `document-markup.ts` carry the stylesheet and the elements out
of the document shell; the shell composes them and the golden is byte for byte
unchanged, 105,968 bytes. `terminal-webview-html.web.ts` answers those two and the
caret options and nothing else, so the page resolves no document string and no engine
string.
`terminal-web-document-mount.ts` is what the WebView's HTML used to be: it plants the
stylesheet and the markup, sets the four scope seams, and reaches the modules by one
dynamic import — they read their elements as they are parsed, so a static import would
hoist above the planting and leave every one of them holding null.
`page-document-modules.ts` is the order, `message-bridge` excluded per ruling 19
because on the page those `message` frames belong to the shell; its one non-bridge
duty, the window-resize refit, is re-armed by the mount.
`page-document-module-order.test.ts` holds that list against the generator's own,
so a sorted import list or a module added on one side cannot pass.
Two page-side degradations, both bounded and both stated. The document assigns
`window.onerror` as it is parsed, so while a terminal is mounted page errors reach its
reporter; the mount restores the previous handler on dispose. And a browser that
refuses a WebGL context gets the DOM renderer, which is the fallback `webgl-recovery`
already has for a context loss, with a `[fit]webgl-unavailable` notify saying so
rather than a silent halving of the drain rate.
`terminal-webview-consumer-census.test.ts` is the pin the substitution rests on: it
scans `src/session` and the terminal directory for an import of the component file by
name, of `terminal-webview-html`, of either generated engine module or of anything
under `document/`, finds none outside the component and its mount, and shows on
planted text that it would report each. `mobile-web-terminal-engine-closure.test.mjs`
gains the component's own closure: `TerminalWebView.web.tsx` and
`terminal-webview-html.web.ts` are in it, the engine string, the native HTML module
and `message-bridge` are not.
Four source greps follow the code into its new home, every assertion kept:
`terminal-write-coalescer-boundaries` reads the coalescer's four boundaries in the
controller, and reads the two lifecycle clears once in `resetReadiness` plus both
WebView callers in the component; `terminal-webview-reflow` and
`terminal-webview-scroll-routing` read the handle in the controller and the two timers
in the promises module (`measureResolveRef.current === finish` -> `measureResolve ===
finish`, `void p.finally` -> `void pending.finally`).
One behaviour was nearly lost and is pinned by an existing case: the native
foreground-recovery ping reads `Platform.OS` at the moment of recovery, not at render,
so the transport asks a predicate rather than carrying a boolean.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): render the page's terminal in a browser under the shell's policy
Everything below the contract is new on the page: xterm is an import rather than a
612 KiB string in a WebView document, the document's modules run in the page's own
realm, and the elements they read by id are planted by the component. No module test
settles whether that opens at all under `script-src 'self'` with neither
`unsafe-inline` nor `unsafe-eval`, or whether a real terminal byte stream reaches the
buffer intact.
Three cases in the C6 render harness, against the bundle built by the real builder and
served under the policy parsed out of the shell's own Kotlin constant.
The stream is built for the grid rather than committed: an SGR colour change per cell,
an erase-to-end and an absolute cursor position per row, run out past the host's own
48 KiB chunk. 49,302 bytes applied through `handle.write`. It is read back through the
document's own path — select all, then the Copy button the overlay carries — so the
oracle is the component's `onSelectionCopy` prop and not a private reach into xterm:
6,133 characters, both edge markers present, and no escape byte or SGR text left in
them, which is what says the parser consumed the stream instead of printing it.
The second case takes a fit through the handle, which on the page is a command in and
a notify back with no bridge between, and carries design §8's cheap half of the IME
question. It first pins something that changes where that probe can even point:
xterm's own textarea is inert by the document's design — `query-reply.ts` makes it
read-only, untabbable and `inputmode=none` so touch and hardware keys go to the
screen's input — so text entering a terminal on the page arrives at a `TextInput`, and
that is what is typed into. Chrome reports `insertText` with `isComposing` false for
each character, logged as `[c7.5][beforeinput]`. A composing IME on a real soft
keyboard is the device step and this does not claim to answer it.
CSP violations are counted with a `securitypolicyviolation` listener installed before
anything else runs, which is stricter than the console-error filter the other render
checks use — and the first thing it found was not the terminal's. The page entry
carries Zod, whose `new Function` probe is swallowed by its own catch, so
`script-src: eval` is refused once on any page route with no page error and no console
line. The first case is the control that names it, on a route that mounts a marker and
no terminal; the two terminal cases subtract it and report zero of their own. Zero
page errors and zero console errors besides.
No route serves this screen until C7.7, so the component is bundled through a scratch
route tree, naming it extensionlessly so the bundler resolves `TerminalWebView.web.tsx`
exactly as a real route would. That step retires when the session route is registered.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): retire the last module concatenator and guard the order list
Round 2 fixes, all five folded here.
1. Deleted terminal-webview-html-source.test-support.ts.
`readTerminalWebViewHtmlSource()` had no consumers left once the behavioural
tests moved to the generated document, and it was the last thing that built a
document-shaped string by concatenating module sources — its filter admitted
`.test-support.ts` files too, so it could have grown one. Confirmed by grep
that the only occurrence of either name in the repository was its own
declaration.
2. New document-module-order.test.ts asserts both directions: the non-test,
non-test-support `.ts` files under `document/` are exactly
`{document-scope} + TERMINAL_DOCUMENT_MODULE_ORDER + {document-constants}`,
and no name is listed twice. `document-constants` is the one exception
because it is never emitted: its exports are substituted into the modules
that import them as literals, so the document carries its values without
carrying the module. A module added here and forgotten there would be dead
code that reads as live; a name left after its file goes makes the generator
throw at build time rather than at review time.
3. terminal-document-flip.test.ts's docstring now carries the retirement policy
from ruling 18: the test is the proof of the flip and holds only while no
module changes, the first lane that must change one retires it together with
`terminal-document-pre-flip-script.txt`, and the standing pin from then on is
`terminal-document-identity.test.ts`, whose fixture regeneration is a review
event. Comment only.
4. terminal-document-equivalence.test-support.ts said 57 reassigned variables
and "Four classes and no others". It now says 73 declaration sites and eight
classes, with each class's measured figure named. Two doc comments sat above
the wrong declaration and were moved onto what they describe: the
`NUMBER_GLOBALS` one down to that constant, and the printing one down to
`significantTokens`, with `STRICT_DIRECTIVE` given its own line.
5. build-terminal-document-script.mjs substituted constants with
`replaceAll(regexp, literal)`, where `$&`, `` $` ``, `$'` and `$n` in a
constant's value are read as replacement patterns. The substitution is now
`substituteDocumentConstants`, exported so it can be tested directly, and
replaces with a function.
Controls, each verified to have changed its input first, all red, tree green
after restore:
plant document/zz-planted-module.ts -> order guard, "+ zz-planted-module"
drop 'wheel-scroll' from the order -> order guard, "+ wheel-scroll"
revert to the string replacer -> 4 failed, "a $& b" became "a marker b"
The `$n` case is deliberately absent from that table: the pattern has no capture
group, so `$1` is already literal under either form and a case for it could not
tell them apart.
The document did not move. The byte golden, the digest and the flip test's class
table are all unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): measure what the page terminal costs the session route's closure
The session route is not served on the page until C7.7, but the closure the bundler
would walk is the same one and the terminal is the largest thing in it. Measured
against this branch's base, `ota-c7-1-terminal-document` at 51ae7b1b03:
modules 4316 -> 4363 (+47)
local modules 927 -> 971 (+44)
minified bytes 3,930,787 -> 3,883,532 (-47,255)
The route gets smaller. It sheds six modules — the native component, the 612 KiB
engine string, the 105 KiB generated document script, the HTML module and the shell
and close around it — all string literals of a program the page cannot run, and gains
fifty: the component, its mount, the stylesheet and markup modules, the two the
controller split made, and the document's own thirty-nine, with xterm and the two
addons behind them at 607,945 bytes minified ESM on their own. `document-terminal-shape.ts`
is not among them: it declares types and esbuild emits nothing for it.
The census pins the trade in both directions, because "the engine string is absent"
passes just as well on a closure that resolved nothing: the six shed modules are
asserted gone, the eight gained ones and the three xterm packages asserted present,
and the document asserted whole except `message-bridge`, which ruling 19 keeps off the
page. It also holds the 16 px seam where C7.2 found it — nine offenders, no unresolved
styles — since the terminal's modules joining this closure is exactly the change that
could add a tenth unread.
The page-closure families were run before and after on the full corpus, never a
filtered scenarios file. Both sides: 7 files, 879 tests, exit 0 — and those 879
include the four page-closure pins, which assert the verdict of every golden C1, C2,
C3 and C5 record, so an unchanged run is an unchanged verdict table rather than an
unmeasured one. Per family with `vitest -t "session.terminal"`, both sides 19 passed
and 773 skipped. No family moved, which is what an inert lane should show: this
branch changes no RPC, no opcode, no grant and nothing the recorder reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): clear the changed-code gate findings this lane introduced
Eleven findings from `check-changed-code-quality.mjs` against the base, all in code
this lane added, none of them a behaviour change.
Two type assertions lost their directive to the formatter. The xterm `Terminal` cast
sits on the second line of a wrapped arrow body, so a directive above the assignment
aims at the wrong line; it moves onto the line the assertion is on. The WebGL addon
cast had no directive at all. Both keep the same `SAFETY:` rationale on one line,
which is the only shape oxlint reads.
Two more assertions in `host-seams.test.ts` are gone rather than annotated. The
terminal double's `element` is a getter over a local the double's own `open` writes,
and `withSeams` reads each field it is about to overwrite through
`getOwnPropertyDescriptor` instead of indexing the scope with a cast.
Then three `eslint-disable no-console` directives that disabled nothing, an
`oxlint-disable` for `react-hooks/exhaustive-deps` that the rule never fired on — the
reason it carried stays as a comment, since the dependency list is still deliberate —
and one duplicated `node:fs/promises` import.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(config): name the closure helper what main already named it
A trial merge against `origin/main` conflicts on this function: main grew the same
generalisation independently, as `mobileWebAppModuleClosure(entryModules)` with
`mobileWebAppRouteClosure` delegating to it and three callers in the page-closure
families census. This branch is based on `ota-c7-1-terminal-document` and so cannot
merge main, but it can stop being a second spelling of the same thing.
Taken over wholesale: main's name, its parameter, its extension stripping and its
comment, with `mobileWebAppRouteClosure` reduced to the one-line delegation main
already has. The only addition is an options bag carrying `absWorkingDir`, which the
engine-closure census needs to plant a module in a tree of its own and show the walk
would report it; the real measurements never pass it. What was a whole-function
conflict is now that one hunk.
The census case that measured the native document had named
`terminal-webview-html.ts` with its extension, which main's stripping does not allow.
It names `terminal-webview-html/document-shell` instead — the module that actually
reads both generated ones — which is the better probe anyway and needs no extension
to resolve, since it has no `.web` sibling.
`web-overrides.json` also conflicts and is left alone: both sides append entries to
one list and the resolution is mechanical.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(config): put the two closure helpers in main's order
The previous commit took main's name and signature but left the route closure below
the module closure, where this branch had written it. Git merged both orderings and
produced two copies of `mobileWebAppRouteClosure` on the merged tree, which oxlint
reports as a duplicated export — a red the trial merge found and neither side's own
lint could.
Same order as main now: the route closure and its docstring first, the module closure
under it. The trial merge is down to one hunk, the `absWorkingDir` parameter, and the
merged tree lints clean.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): make the flip comparator refuse what it was accepting
Round 2 items 6 and 7, both in the equivalence instrument.
6. `isPrinterDisambiguation` accepted any `name2` facing `name` without proving
the two were the same binding, so an unrelated rename ending in a digit would
have been counted rather than refused. It is replaced by `UNSHADOWED_RENAMES`,
an explicit list of pre-flip name, generated name and declaring module. The
whole script has one entry: `term2` -> `term` in `query-reply`, which is the
`term` parameter of `attachTerminalQueryReplyBridge` and its six uses, seven
sites in all. That is stated in the docstring rather than encoded as a second
pin, since the flip test already pins the total.
7. Brace absorption treated every unexpected `{` as a linter-added body and
absorbed any later `}` while one was outstanding, so a bare block anywhere
would have been swallowed. `isBraceableHeadBody` now requires the open to be
the body of `if`, `for`, `while`, `else` or `do` — walking a `)` back to its
`(` and reading the keyword before it — and `matchingCloseIndex` records the
index the close must appear at, so the absorbed `}` is that body's own.
That check had to move ahead of the equality check. Wherever a braced body
ends a block, the baseline's next token is a `}` as well, so pairing them
would consume the wrong one and leave the counts right for the wrong reason.
Both refusals are tested over snippets:
function f() { return value2; } vs return value;
-> token 6: expected name value2, generated name value
let value = 1; use(value); vs { let value = 1; } use(value);
-> token 0: expected name let, generated {
and the braceable heads are tested one by one, `if`, `for`, `while`,
`if`/`else` and `do`, so the new rule is shown to accept every shape the `curly`
rule produces and not only the one the document happens to exercise.
Controls: restoring the shape rule fails the first refusal case and nothing
else; restoring the accept-any-brace rule fails the second and nothing else.
The eight counts did not move: 609, 73, 373, 279, 36, 17, 4, 7.
Splitting out `terminal-document-tokens.test-support.ts` is not cosmetic. The
tightened rules put the file over the 300-line cap, and a `max-lines` disable is
forbidden, so the token reader moved to its own module: that side answers what a
script says, and says nothing about which differences between two of them are
allowed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(config): take main's docstrings for the two closure helpers
The order matched but the prose did not, so the trial merge still conflicted on the
whole block. Both docstrings are now main's own text, with one sentence trimmed: main
names `MobileBrowserPane` as the first component with a pin of its own, which is C6's
fact and not one this branch can assert.
What remains between this branch and main in this file is the `absWorkingDir`
parameter, which is what the engine-closure census plants a module with.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): write the page terminal's notify sink in an effect, not during render
React Doctor's one error on this branch, and a real one: `receiveRef.current = receive`
ran during render. React may replay or discard render work, so a mutation made there
can leak from UI that never commits — and this ref is read from a callback the mounted
document keeps, which outlives the render that installed it.
Moved into its own effect, declared above the mount effect so the first read already
sees a sink. `check-react-doctor-changed.mjs` goes from exit 1 to exit 0.
Found late because the first run of that gate was read through `| tail`, which reports
the pipeline's last command rather than the gate's own exit code.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): teach C7.1's order guard the three modules this lane added
The guard C7.1 landed says the document directory and the order list name the same
modules. On this branch three files are in that directory and not in that list, so it
was red on the merge — which is the guard working, and the fix is to name each of them
with its reason rather than to loosen the scan.
document-host-seams emitted, but ahead of the scope rather than inside the order
list, because the scope's defaults are its four functions and
the factory runs as the script is parsed
document-terminal-shape types only; esbuild emits nothing and an empty emission
would add a blank line to the document
page-document-modules the page's entry, not the WebView's, holding the same order
for a host that has no generator to splice them
Named one by one, not filtered by a pattern, so a fourth cannot join them by looking
similar. A third case asserts the seams module is neither in the order list nor the
scope module, which is the ordering the first two cannot see.
Red before this commit: C7.1's version of the file on this tree reports
`document-host-seams` and the other two as directory modules the list does not name.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): re-measure the session closure against the merged C7.1 base
Same module counts — 4316 -> 4363 and 927 -> 971 local — but the minified figure moved
from -47,255 to -55,561, and the 8,306-byte difference is C7.1's rather than this
lane's. Its round-1 fold deleted `URL_TAP_WEBVIEW_JS` from `terminal-webview-url-tap.ts`,
a module that enters this closure only once the page's component reaches it, so the
saving shows on the after side and cannot show on the base. Both readings are recorded
with the commit each was taken against, because a number with one base named and
another used is the kind of thing a reviewer cannot check.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): retire the flip comparator with the pin it was built for
The token comparator had exactly two consumers and neither survives. `document/url-tap.test.ts`
went in C7.1's own round-1 fold at 8da7680c9b, and `terminal-document-flip.test.ts`
went in this lane's first commit under ruling 18, because the flip pin holds only
while no module changes and C7.5 is the lane that changes them. What was left was a
tool, its token reader and a test of the tool, answering to nothing.
So `terminal-document-equivalence.test-support.ts`, the
`terminal-document-tokens.test-support.ts` C7.1 split out of it, and
`terminal-document-equivalence.test.ts` all go. That closes round 3's two LOW notes on
the comparator — bounding an absorbed body to one statement, and refusing a bare block
as `use();` against `{ use(); }` — since there is no comparator left to tighten. The
standing pin on the document is the whole-document byte golden, which is a stronger
claim than token equivalence ever was: it admits no normalisation at all.
`document-module-order.test.ts` gains the case its exception list was asserting in
prose. `document-terminal-shape` is not in the order list because esbuild erases a
module of type declarations to the empty string, and emitting it would put a blank
line in the document rather than a program; that emission is now measured and pinned
as `''`. If the module ever declares a value the case goes red and the module belongs
in the order list with its own line in the golden diff.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): make the document's error reporter the sixth host seam
Ruling 19 reaches `window.onerror`. The document assigned it as it was parsed, which
inside the WebView is taking nothing from anyone — that document owns its page — and
on the page is a guest displacing whatever the host installed. Restoring it on dispose
was a patch over the takeover, not an answer to it: while a terminal was mounted, every
page error still went to the terminal's reporter.
So `scope.installErrorReporter` joins the five, with today's assignment as its default.
`host-notify` hands it the same handler it always installed, and the WebView's document
is the program it was.
The page supplies its own: an `error` listener that adapts the event to the reporter's
arguments, added on mount and removed on dispose, and `window.onerror` is never
written. This one seam is *called* as the modules are parsed rather than later, so the
mount now reaches `document-scope` on its own first and sets every field before a
single document module runs — which is also the safer order for the other five.
Golden regenerated: 105,968 -> 106,116 bytes, document 724,002 -> 724,150. Three lines
out, seven in, and nowhere else:
+ (new, beside the other defaults) function installWindowErrorReporter(report) { window.onerror = report; }
- " createWebglAddon: createEngineWebglAddon"
+ " createWebglAddon: createEngineWebglAddon," and " installErrorReporter: installWindowErrorReporter"
- " window.onerror = function(msg, source, line, column, err) {"
+ " scope.installErrorReporter(function(msg, source, line, column, err) {"
- " };"
+ " });"
`terminal-webview-payload-hash.test.ts` takes the new length and digest.
Pinned on both sides. `host-seams.test.ts` gains the default taking `window.onerror`
and a host that installs its reporter elsewhere leaving it null. The render check adds
a browser case: `window.onerror` is null before the mount, null after it, and null
after the component unmounts — with a real uncaught error thrown in between and
asserted to reach `onEngineError`, so the first reading cannot pass on a terminal that
had simply stopped reporting, and a second error after dispose asserted to reach
nothing. Red with the mount's override removed: `expected undefined to be null`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): empty the session closure's react-native-webview list
C7.6's census on main names the terminal as the last consumer and says whose work it
is: "The terminal is the third and is C7.5's, which drops the engine string and mounts
xterm in the document". This is that lane, so the list it left is now empty and the
session closure reaches `react-native-webview` from nothing at all.
Emptying a list weakens the case that reads it, because an empty result is also what a
scan that read no file reports, so two things change with it. The main case gains its
preconditions: the walk read a closure of more than 500 local modules, and it read the
three web siblings whose native halves are exactly the modules that would have
imported the package. And the control stops walking the list — with the list empty that
compared nothing against nothing — and walks the three native files instead, which do
import it, alongside the three web siblings, which do not.
`TerminalWebView.web.tsx` joins the answered list, so the case that the builder
resolves a web sibling rather than its native file now covers all three.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): pin the onerror seam against a handler the page actually owns
The case read `null` before the mount, while mounted and after dispose. That is true
but weak: a terminal that assigned `null` over a real handler would pass it, which is
exactly the takeover ruling 19 forbids.
So the page now installs a handler of its own in an init script, before the bundle
loads, and the assertion is identity — `window.onerror === globalThis.__orcaSentinel`,
compared inside the page because a function does not survive `evaluate` — at all three
points. Between them an uncaught error is thrown and both reporters are asserted to
see it: the page keeps the handler it installed, and the terminal's own listener still
works, so the readings cannot pass on a terminal that had simply stopped reporting.
After dispose a second error reaches the page's handler and not the terminal's, which
is what taking the listener off has to mean.
The `null` reading stays as its own case, because the other half matters too: on a page
that installed nothing the terminal must not leave a handler behind for the next
consumer to find.
Both go red with the mount's `installErrorReporter` override removed — `expected false
to be true` and `expected undefined to be null`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): start the terminal document per mount (ruling 20)
Round 1's blocking finding: ES module bodies run once per page, so the page's
second mount re-imported nothing and inherited the first mount's elements,
listeners and error reporter. Measured after a remount: zero .xterm nodes in
the live DOM, no selection overlay, nothing reaching onEngineError, and
onWebReady still firing.
Ruling 20: no emitted module does work as it is parsed. Every top-level effect
moved into an exported per-module start function — 86 statements across 14
modules, plus three parse-time captures whose declarations became typed lets.
The generator emits one call sequence in module order at the foot of the
document, so the native script still runs them once at parse; the page runs the
same sequence per mount and dispose undoes the three that outlive the host
element (tap-dispatch, webgl-recovery, host-notify).
installErrorReporter now hands back its own undo, so it stays five seams at six
document sites rather than growing a sixth.
M2: a failed document chunk was an unhandled rejection with no engine error.
It now goes down the document's own reporting path, so the overlay names the
cause instead of the 15s readiness watchdog. Pinned by refusing that chunk at
the wire in the render check.
L3: the seam count now reads five fields / six sites / three files everywhere.
L4: three unrelated web-overrides entries keep main's escaping.
Golden: 106116 -> 108134 bytes; payload 724150 -> 726168, sha256
2d089b8d9ab9491eed79cf7fe353dde6444799a3d297269ab660aee63ba56c82.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the parse-time census tree without assertions
The changed-code gate refuses type assertions. The walker reached node fields
through `as Record<string, unknown>`; it now reads them with Object.entries,
which is checked and says the same thing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): move the document's state onto the scope (ruling 21)
Round 2's blocking finding, and ruling 20's second half: moving parse-time
effects out of the module bodies left the state behind. Nine module-level
bindings survived a mount, so the second terminal inherited a spent non-fatal
error budget (reporting nothing however it failed), the first terminal as its
committed surface (disposing it twice), and the first mount's momentum loop.
Every mutable binding now lives on the scope, and the scope carries one reset
the start sequence calls first: native once at parse, the page once per mount.
Moved, by module: query-reply 1, surface-swap 3, text-scaling 2, fit-scale 1,
host-notify 2, selection-state-and-eviction 1, mouse-click-drag 1,
tap-dispatch 1, surface-touch-gestures 1 — thirteen fields, two of them the
objects tap-dispatch and surface-touch-gestures used to own outright.
Because the reset is now the one initialiser, the start functions keep only
what it cannot do: element reads, listener installs and the reporter install.
Four start functions emptied and went; terminal-handle held nothing else and
is deleted from the order list. The scope type splits into state and host
seams, because a reset must restore the first and never the second.
Every stop function cancels what its module scheduled. Timers go back through
the handles the scope already held; frames go through the scope's own
scheduleDocumentFrame, so dispose can take back the ones no module tracks by
id. terminalGeneration and fitRetryToken carry forward across a reset, because
a stale callback tests itself against them and a reset to zero would make the
old number match again.
L2: the seams-before-scope case asserts the order in the emitted document, not
just non-membership. L3: the style docstring says what is true — one scope per
page, so mount refuses a second live document and gives the page back when a
mount fails.
Golden: 108134 -> 108047 bytes; payload 726168 -> 726081, sha256
6a5a3216aab7b99daeb26bcdcfe6e325c415e5ef60c16405eea329ca141405fe.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse frames from a stopped document
The frame case went red under full-suite load: tearing the terminal down runs
the engine's own disposal, which calls back into these modules, and a frame
asked for on the way out was owed by nobody because the cancel had already run.
A stopped document now asks for no frames at all, so the ordering inside
dispose stops mattering.
The render case is also rewritten around the work that survives a loaded
machine. It gives the terminal a scrollback and sends one wheel, which reveals
the scroll indicator and arms the 550 ms timer to hide it again, and the
boundary between the two mounts is drawn when the first terminal leaves the
page rather than when the component is told to go — React unmounts on its own
schedule, and a callback that runs while the first terminal is still up is not
a leak. The precondition counts what the document scheduled under the first
mount, so an empty leak list cannot mean the wheel reached nothing.
Verified both ways at this head: red with stopViewportTransform and
cancelDocumentFrames removed, green with them, and green in the whole
config/scripts suite.
Payload 726081 -> 726195, sha256
67a7b82bcd87b811214d02ca0e2f29bb634da47607e50f701bf153b9bf7323ef.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): style only what the page mount owns
CodeRabbit on document-style.ts:16. The mount appended the document's whole
stylesheet to the page head, so its `*`, `html` and `body` rules restyled every
screen the shell can show and went on doing it after unmount. Ruling 19's
shape: the native document owns its page and keeps the sheet as it is; the page
mount may style only what it owns.
The sheet splits into TERMINAL_DOCUMENT_ROOT_STYLE and
TERMINAL_DOCUMENT_ELEMENT_STYLE, composed in the same order, so the emitted
document does not move for the split - verified byte-identical before the seam
below. The page injects the element half only, with every selector held under
the host's own class, and xterm's sheet goes through the same rewrite. The
rewrite refuses an at-rule rather than passing its inner selectors through
unscoped.
A second leak of the same kind was in the same measurement: applyTerminalTheme
wrote the terminal background straight onto `html` and `body`. That is a sixth
seam - six fields at seven document sites now. Its default does exactly the two
writes it did; the page paints the host element instead. Emitted lines, old to
new: `paintWindowDocumentBackground` added beside the other defaults (3 lines);
`paintDocumentBackground: paintWindowDocumentBackground` added to the seam
factory (1 line); in applyTerminalTheme, the two `document...style.background`
writes become one `scope.paintDocumentBackground(background)`.
Leaving the sheet in the head after unmount is kept, and is now defensible: the
host drops the class on dispose, so every rule in it matches nothing until the
next mount.
The render check gains a case comparing `body` and `html` computed styles,
while mounted and after dispose, against a page of the same application with no
terminal on it, and asserting no rule of the injected sheet matches an element
outside the host. Verified red both ways at this head: unscoped sheet moves
`background-color` and `box-sizing`, and the inline theme write moves
`background-color`.
Payload 726195 -> 726363, sha256
9950f1770cd85ad2f80c69e074111869f6c66a724c87b66ba81f1ff10318a0ce.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): give the page mount's rules and frames their own oracles
Round 3 blocks on evidence, not on shipped behaviour. Each item:
H1. The scoping had no positive oracle: dropping the host class, or injecting
an empty xterm sheet, left the render check green, because every assertion was
about rules not escaping. The containment case now also reads four things off
the live elements under the host — xterm's own `position: relative`, the
viewport's `overflow-y: hidden`, that the viewport reserves no scrollbar width,
and the overlay's `position: fixed`. Red both ways: no host class reds all
four, an empty engine sheet reds the first.
H3. `cancelDocumentFrames` had no witness: the only leak the timer case could
see was the 550 ms hide timer, which its own module's stop cancels. There is
now a case whose witness is a frame taken through `scheduleDocumentFrame` —
the fit retry loop, with the surface hidden so the fit never commits and one
frame is always owed at dispose — and it reds when only `cancelDocumentFrames`
is removed. A unit covers the registry itself: a frame is held until it runs,
a cancel takes back every pending one and then refuses to schedule, and a reset
re-enables it.
The two scheduling cases now assert on their own witness kind, so neither can
stand in for the other, and the recorder judges a leak by whether the
`#terminal-container` that was on the page at schedule time is still in the
document — React unmounts on its own schedule, and a callback that runs while
the first terminal is still up is not a leak. The timer witness moved from the
scroll-indicator timer to the long-press timer, because the first needed a
drained scrollback and raced the engine under load; its precondition caught
that rather than passing.
L1. The two seam docstrings each sit on their own function.
L2. The parse-time census plants an element-read initialiser, which the
statement filter cannot see, and an inert object literal, which a reader that
flagged every initialiser would wrongly report.
L3. Dispose disposes `scope.committedTerm` as well as `scope.term`: a swap that
never committed leaves two terminals and only one was reached. Deduplicated,
because they are the same object whenever no swap is open, and pinned both ways.
L5. `document-style-scoping.ts` joins GAINED_OUTSIDE_THE_DOCUMENT.
Golden unchanged at 108,329 bytes; payload and its hash unchanged. Render
check: 12 cases.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): make the page document's dispose idempotent and owner-checked
CodeRabbit on terminal-web-document-mount.ts:180. Dispose was neither. A
handle outlives what it built - the component keeps one in a ref and React can
run a cleanup after a later mount has started - and everything dispose touches
is shared: the scope, the module sequences, window.__engineErrors. So a second
call, or a call from a handle whose document had already been replaced, tore
down the terminal that was on the screen and handed the page away while it was
still in use.
Each mount now carries a token, and dispose acts only when that token is still
the live one. A token rather than the host element or its class: two mounts can
be handed the same element, because the page remounts into a host React has
reused, so an element is not an identity and the class says only that some
document is using the host. The failed-mount path releases the page under the
same check.
Pinned both ways, red with the check removed: disposing twice leaves a terminal
put back after the first teardown alone, and a stale handle disposed after a
second document mounted changes nothing - the live markup stays, its terminal
is not disposed, and the page is still refused to a third mount.
Golden unchanged at 108,329 bytes; payload and hash unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): let a pending page mount be disposed before its import lands
Round 4 on #21809.
H1. The mount claimed the page before its dynamic import and handed back a
promise, so a component cleanup that ran while the chunk was still in flight had
nothing to dispose: the claim outlived the mount it was made for, and Reload —
the recovery ruling 20 names — was refused as a second document. The claim, the
markup and the handle are now made synchronously, `ready` settles on its own,
and a mount disposed while its import was in flight releases without starting
anything. Pinned in the render check by holding the document chunk 20 s past the
15 s readiness watchdog, clicking Reload and waiting for the second mount to
become live; red at that wait before the change.
M1. The frame case's precondition asserted that a frame had been asked for while
the document owned the page, not that one was owed when it was disposed. The fit
retry commits on its first attempt whenever the grid still measures, so a dispose
between two refits owed nothing and agreed with an empty leak list for exactly
the reason under test — one run in five. The refit and the unmount now share one
discrete click, which React flushes before the event returns, and a mutation
observer reads the registry at the instant the host is emptied. Five red runs
without `cancelDocumentFrames`, all on the leak and none on the precondition,
and five green with it.
M2. Two mounts handed the same element, which is what the token is for: the
other six cases use a different element each, so a host comparison passes all of
them.
L1. A throw inside the start sequence released the token but ran no stop, leaving
the host-notify error listener installed until the next reset nulled its undo.
The sequence now unwinds the starts that completed, in reverse, before it
rethrows.
L2. A render case comparing the window and document listeners the page holds
with no terminal on it, before and after a mount, so a stop that forgets one is
a failure rather than a second copy per terminal ever shown.
L4. Separated the stacked docstrings in the parse-time-effects census.
The render check's bundle, server, browser and page helpers move to their own
fixture module: the cases are what is under review and the scratch route tree is
not, and the file was 16 code lines under its cap.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): count the page document's leaked frames from dispose, not from detach
CI's addendum to round 4's M1: the frame case failed with the fix present,
`expected [ Array(1) ] to deeply equal []`, on a slower runner.
What scheduled it: `applyFitScale`, through `scheduleDocumentFrame` like every
other frame the document asks for — the document has no other rAF call site. It
is not an escape from the registry, so the registry is not what changes here.
Why it was counted: React unmounts in two steps. The mutation phase detaches the
host, and the passive cleanup that calls `dispose` runs after it — about 1 ms
later here, 20 to 35 ms later with the CPU throttled 20x, which is the runner
shape this failed on. A frame served in that gap runs with a detached container
while the document is still live and has not been asked to stop, and nothing
could have taken it back: `cancelDocumentFrames` had not been called yet. The
oracle judged by the captured container's connectedness, so it read the gap as a
leak. It now counts only what runs after the last statement of `dispose`, which
is the class coming off the host, observed on the element because React may have
detached it already.
The same reading fixes the other direction. The precondition is read at that
same moment, and the witness is a refit re-armed from a frame of the test's own,
so the document is owed a frame at the end of every frame the browser serves and
a dispose cannot land where nothing is owed. The single refit the case used
before bought one frame, and the retry loop commits on its first attempt
whenever the grid still measures.
Evidence: with the boundary removed the case reproduces CI's `Array(1)` in two
runs of three unthrottled, and in five of five with the CPU throttled 20x, where
the detach-to-dispose gap measures 20 to 35 ms; with it, five green runs; with
`cancelDocumentFrames` removed, five red runs, all on the leak read and none on
the precondition.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop a page mount that lost its claim before it writes the scope
Round 5 on #21809.
F1 (blocking). `buildTerminalWebDocument` had no token, so after its `await
import(...)` the whole body ran whatever had happened in the meantime: it
overwrote the six seams, called `startPageDocumentModules` and added the resize
listener, and only then did the caller's `.then` read the claim and throw the
result away. Everything after that await is shared — the seams are fields on a
module-singleton scope, and the start sequence resets that scope and installs
the document's listeners — so a mount disposed while its chunk was in flight was
writing over a mount that owns the page. The claim is now re-read the instant
the import lands, before any of it, and the build returns null.
`ready` for such a mount resolves rather than rejecting. Nothing failed: the
caller asked for the terminal and then asked for it to go away, and the chunk
arriving afterwards is not something for the error overlay to name. Before this
it rejected with a TypeError from `startSelectionMenuButtons` reaching for an
emptied host.
F2. The rejection handler called `release()` unconditionally, emptying a host the
mount may no longer own. It now releases only when the page is still its own.
Pins, both red first. In happy-dom: mount, dispose, then await ready — no
listener, timer or frame added while it resolves, the six seams unchanged,
`terminalGeneration` unmoved because the start sequence never ran, and the page
free for the next mount. Without the fix that case rejects with the
`startSelectionMenuButtons` TypeError. In the browser, the Reload-while-in-flight
case now reads the page's listeners with no terminal on it and compares them
against a page that mounted once and disposed once; without the fix the
abandoned mount leaves `window error` and `window resize` behind, because the
second mount's scope reset nulls the first mount's reporter undo.
The listener snapshot helper is shared with the mount-and-dispose case rather
than written twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(config): give the render fixture's server and scratch tree back when it cannot start
CodeRabbit on the render fixture, plus its note on `release`.
The fixture. `chromium.launch` is the last step of the setup and the one that
fails in practice — no Chromium on the machine, an
`ORCA_MOBILE_WEB_RENDER_BROWSER` pointing nowhere — and by then the bundle
server is listening and the scratch tree is on disk. Rejecting there left the
caller without a handle, so `afterAll` had nothing to close and both stayed
allocated; the listening socket is the one that bites, because an open server
handle keeps the vitest worker alive after its last test has reported. The setup
after `mkdtemp` is now wrapped, gives back whatever it managed to take, and
rethrows the original error rather than anything the cleanup raised. The normal
close path awaits the server-close callback instead of firing it.
`release` in the page mount. The ownership check covered the claim but not the
two lines that make the terminal disappear, so a release that skipped the claim
would still empty the host and drop its class. The check now guards the whole
function, and round 5's caller-side check is gone as a duplicate of it: one rule,
inside the thing it governs. Both existing callers are unchanged in behaviour —
the synchronous planting catch always owns the page, and the rejection handler
was already guarded.
Pinned red first. The new case points the launch at an executable that is not
there, then asks the port the fixture actually served on for a connection and
reads the scratch directories in the temp dir. Without the rollback the port
still accepts and the scratch tree is still there; with it, neither. The port is
recorded by wrapping the real `createBundleServer` rather than standing a double
in front of it, and the case asserts a server was created at all, or the refusal
would mean nothing.
Two oracles were discarded on the way. `rejects.toThrow()` with no argument
passes for a build that broke for its own reason, so the rejection is matched by
message. `process.getActiveResourcesInfo()` reports `TCPServerWrap`, not
`TCPSERVERWRAP`, so a count filtered on the upper-case spelling was zero in both
arms and agreed with everything; it also still lists the handle at the moment
the close callback runs.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): read the render fixture's rollback in a temp root of its own
Two defects in the case I committed in cb1833e675, both found by running it.
The anti-slop gate refuses module mocking, and it is right to: the case recorded
the served port by mocking the harness module around the real
`createBundleServer`. Gone, with no disable.
Its replacement read the shared temp directory for the fixture's scratch prefix,
which the render check next door writes to from a worker of its own. So the case
watched that tree appear and be swept up mid-run and called it a change: one red
in four alone, and red in the full suite, where the two run together. `TMPDIR`
now points at a directory this worker made, so the fixture's scratch tree lands
somewhere nothing else writes and what is left in there afterwards was left by
the setup under test. The failed launch also leaves Playwright artifacts and a
browser profile in there, which are Playwright's to clean, so the reading is
filtered to the name the fixture gives its own trees.
The listening-socket half is unchanged and was right: spelled `TCPServerWrap` as
Node spells it, and read a tick after the close callback, because the handle is
still listed while that callback runs.
Both halves now fail on their own without the thing they measure: with no
rollback at all the socket count is one above its baseline, twice out of twice;
with the rollback but no `rm`, the scratch tree is still there. Three green runs
with both.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hand the started document to the mount in the turn that started it
Round 6's two LOW items, and the pins for the owner-checked release.
LOW 1. `started` was assigned in the `.then` after the build, a microtask later
than the start sequence and the resize listener it installs. A dispose in that
window found nothing started, skipped the teardown and released the page with the
document still running on it. The build now takes an `adopt` callback and calls it
as its last statement, inside the guarded region, so whoever has to undo the
start is holding it before that turn ends. Pinned by queuing the dispose behind
the document import the build awaits, which lands in exactly that window: without
the change the started document's resize listener survives the dispose, five red
runs out of five.
The owner-checked release, which landed in 8b37221b57 without a pin of its own.
The one path that reaches a mount's cleanup holding someone else's page is a
rejected import: everywhere else the build re-reads the claim after its await and
stops, but a rejection never gets that far. So the pin drives that — the chunk
fails for the first mount only, the mount is disposed while pending, a second one
is built into the same element as Reload does, and then the first rejection
arrives. Without the guard inside `release` it empties the live mount's host:
three red runs out of three, on the markup. It also disposes the abandoned handle
a second time afterwards and asserts nothing moves, which is LOW 2's missing pin
for round 5's F2.
That case is its own file because the import has to fail before the mount module
loads, and the mocking the failure needs is only permitted in `.test.ts` — the
anti-slop override does not cover `.test.mjs`, which is what refused the port
recording in the render fixture's case. It fails once, so the mount that replaces
it gets real modules and is a live document worth protecting; its own resize
listener is the witness that it started.
Two oracles were dropped. Vitest reports its own message when a mock factory
throws, not the one thrown, so which import failed is read from the factory's
counter instead. And a counter of successful factory calls read zero even though
the second mount got a working document, which measures vitest's caching rather
than this code; the live mount's listener replaced it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the listener wrappers the mount pins install
The mobile tests-typecheck ratchet was red on 63eb8a40ae: six TS7006 implicit
`any` parameters in each of the two mount pins, from arrow functions assigned
over `window.addEventListener` and `window.removeEventListener`. An overloaded
method gives an assigned arrow no contextual parameter types, so each wrapper's
`type`, `listener` and `options` were implicitly `any` under
`tsconfig.test.json`, which the product typecheck does not read.
Both wrappers now take their parameters from the bound original as
`Parameters<typeof realAdd>` and spread them through, so the signature is the
real one rather than three widened parameters. No casts and no `any`.
Re-verified that the change did not quietly disarm either pin, because a recorder
that counted nothing would also go green: with `release` unguarded the rejection
case still fails on the live mount's markup, and with the adopt deferred by a
microtask the single-mount case still fails on the started document's resize
listener surviving its dispose.
The ratchet itself is the finding worth keeping. It is not part of the mobile
`tsc` the rest of my gate set runs, and it had dropped out of that set when these
folds began, so three reports listed the other ratchets and not this one. It is
back in, and stays in.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): drop what a disposed page mount adopted, and close the fixture's three resources apart
Round 7's five items.
1. The queued-dispose case's precondition was vacuous. It read the host for a
missing container, which dispose empties on every path, so a build that returned
straight after its ownership check satisfied it. The wrapper now counts resize
adds and the case asserts exactly one, which is the document having started. Red
under that mutation, on the count.
2. The render fixture's rollback awaited its cleanup unguarded, so a cleanup that
also refused replaced the error the caller needs — the reason the setup failed.
The rollback is best-effort now and the original error is what comes back.
3. That cleanup stopped at the first throw, so a browser refusing to close took
the socket and the scratch tree with it, which is the leak the rollback exists to
prevent. Each of the three is asked independently and the first failure is
rethrown after all three have been tried.
4. The rejection case restores its `window` patch in a `finally`, as its sibling
does, so a failure part way through no longer leaves the patched functions behind
for everything that runs after it.
5. `dispose` left `started` set. `send` reads it, and what it holds names the
page's one set of document modules, so a stale handle could route a host command
into whichever document is live next. Nulled, and pinned: the stale handle pings,
and with the old code the *live* mount's `receive` answers `pong`, because the
scope's seam belongs to it by then. The precondition is the live handle's own ping
being answered, so the silence is the stale handle declining rather than the
command doing nothing.
Items 2 and 3 have no pin of their own. Both are failure paths of the cleanup
itself, reachable only by making a browser or a socket refuse to close, and
standing something in front of Playwright to do it is what the anti-slop gate
refuses in this file's suffix. The rollback's own pin still covers the path that
matters, and both changes are read by it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): emit the terminal document as a factory
Ruling 22, commit 1 of C7.5b. The generator's concatenation already gave the 38
modules one function scope with one local `scope`; naming that scope a function is
what makes it the shape both hosts run, and what will let the page have its own
state per mount instead of a module singleton with a reset between them.
`createTerminalDocument(host)` is emitted around the same module bodies, in the
same order, followed by the same start sequence. It then declares `stop`, which
calls every module's stop in reverse order and takes back the frames the document
is still owed, and returns `{ send: handleMsg, stop }`. The native document is
that function plus one call with no argument, which is what the WebView has always
run: no argument means every seam is the window read it already did.
`createTerminalDocumentScope` takes the host and spreads the hooks it names over
the window defaults, filtering undefined so absent and present-but-undefined mean
the same thing. The emitted scope declaration is the one line the host reaches, so
the generator rewrites it and refuses if the line it expects is not there — a
rename would otherwise leave every call on the defaults with nothing to say so.
The golden moves by the wrapper and that one line, and by nothing else. 108,329 to
108,831 bytes, the whole diff:
-(function() {
+function createTerminalDocument(host) {
- function createTerminalDocumentScope() {
- return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams() };
+ function createTerminalDocumentScope(host = {}) {
+ const named = Object.fromEntries(Object.entries(host).filter(([, hook]) => hook !== void 0));
+ return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams(), ...named };
- const scope = createTerminalDocumentScope();
+ const scope = createTerminalDocumentScope(host);
-})();
+ function stop() {
+ stopSurfaceTouchGestures();
+ stopTapDispatch();
+ stopSelectionOverlay();
+ stopNormalBufferSmoothScroll();
+ stopHostNotify();
+ stopTerminalInit();
+ stopWebglRecovery();
+ stopFitScale();
+ stopViewportTransform();
+ cancelDocumentFrames();
+ }
+ return { send: handleMsg, stop: stop };
+}
+createTerminalDocument();
The byte golden and the payload hash are re-pinned once: 726,363 to 726,865 bytes,
sha256 9950f177 to c7bbcb0b.
Four test files sliced the document with their own copy of the IIFE bounds, which
ruling 17 allows moving. They now share one reader in the test-support module
beside the one that locates a single module, and that reader names the factory and
its call. Every assertion is unchanged. The module-order guard and the region
reader compare against the text the document carries rather than a raw emit, since
the scope module is the one the generator rewrites; both go through one exported
function so neither can describe the rewrite differently from the generator.
`TerminalDocumentHostSeams` and the new `TerminalDocumentHost` moved to
`document-host-seams.ts`, which owns the six functions they type. Types emit
nothing, so the golden is unchanged by the move; it keeps `document-scope.ts`
inside its 300-line cap with no disable and no bump.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* build(mobile): emit the page's terminal document factory beside the WebView's
Ruling 23, and the first half of C7.5b commit 2: the artifact the page will
import. The page cannot run the native script, because building a function from a
string needs `eval` and the page's policy refuses it, and it cannot run the
modules either, because they are one singleton while the whole point of the
factory is a scope per call. So one emitted body gets two wrappers.
`buildTerminalDocumentFactoryBody` is now the shared half: the modules in order,
the start sequence, the stop handle and the return. The native script wraps it in
the declaration and the trailing call, exactly as before. The new
`terminal-webview-document-factory.generated.ts` wraps the same lines in a
`@ts-nocheck` module whose only other content is the type import and the
annotated signature. One generator run writes both, so the page's factory cannot
be a build behind the WebView's.
`@ts-nocheck` covers this one generated file. Every line of its body is esbuild
output from a module that was type-checked at its source, with `declare global`
blocks and type re-exports already erased and constants already substituted; the
one line a caller reads is the signature, and the generator writes it with its
types. `TerminalDocument` joins `TerminalDocumentHost` in `document-host-seams.ts`
as the shape the factory returns.
The pin is byte equality. `document-factory-artifacts.test.ts` strips each
wrapper and holds the remaining text equal, so the byte golden pins the page's
artifact by construction rather than by a second golden; it also reads the file on
disk against what the generator would write now, since that file is gitignored and
built by postinstall, and it refuses a trailing call in the page's copy, which
would start a document as the module was imported.
The path joins `.gitignore` and the oxlint ignore list beside the engine artifact.
The consumer census gains the generated file by name: it is the document, and its
one import is the host contract its signature is written against.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): call the document instead of starting its modules
The page's half of ruling 23, and ruling 24. The mount plants the markup and calls
the factory; the handle is `send` and a `dispose` that stops it. The document is a
function, so the page holds an object per call and nothing else.
Deleted with the singleton it was written for: `page-document-modules.ts`, the
claim token and `liveDocument`, `release`, the owner-checked `dispose`, the
second-mount refusal, `resetTerminalDocumentScope`, the `adopt` callback, `ready`
and every pending-import path. All of it existed because two mounts shared one
module-level scope and because the handle had to come back before its import did.
A call is a document now, so a second mount cannot reach the first one's state and
a caller's cleanup cannot arrive before there is something to clean up. The
second-mount refusal is not replaced by a one-line guard: with a scope per call
there is no shared state left to refuse for, and a host element with two
documents planted in it is the caller's own doing, visible on the screen.
Ruling 24 splits `message-bridge` by what it is, which is what made the page able
to run this text at all. Two more seams, eight now: `installHostTransport`, whose
window default installs the `message` listeners on window and document and hands
back their removal, and `hasEngine`, whose default is the `window.Terminal` the
engine bundle installs. The page answers a transport that installs nothing,
because its transport is the handle, and an engine that is always there, because
the engine is the import above. So the page no longer takes the shell's frames or
reports a missing engine on every mount, and `stopMessageBridge` takes the
listeners off — the WebView never removed them, which ruling 21 asks for.
The refit the bridge happened to own moves to `fit-scale`, which is whose work it
is; both hosts start it, and the mount's hand-copied five calls are gone. The
engine's disposal moves into `stopTerminalInit` for the same reason: the mount
cannot reach the scope any more, and a stopped document's terminal is a WebGL
context nothing will read again.
The start sequence the generator emits is now inside the document's own undo: a
start that throws runs `stop` and rethrows, so neither host can be left holding a
listener from a build that failed. That replaces the deleted entry module's
unwind, and it covers every start rather than the four that had one.
Readiness arrives the same way on both hosts. The document posts `web-ready`
through `postToHost`, which the controller already handles, so the mount-side
`confirmWebReady` is gone. That flush is also the one caller that reaches `post`
before the effect has a handle, which is why the component's queue stays and now
says so.
The golden and the payload hash move, 102 diff lines: the two seam defaults and
their state fields, the reset gone, `startFitScale`, the disposal, the bridge over
its seams, and the start sequence inside its try.
Tests: the seam tests and the unwind test move to the factory and the derived
start sequence; the frame registry builds a fresh scope instead of resetting one;
the two mount test files and the page entry's order test go with their subjects.
The render check keeps every behavioural case and loses two whose subject the
static import removed — a Reload while the chunk is in flight, and a chunk that
will not load, which is now the route's chunk rather than the document's.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): stop censusing state a call of the document already isolates
Ruling 22 answers what ruling 21's state half was for. A module's top level is
emitted inside the factory, so a `let` there is one binding per call — which is
exactly what moving it onto the scope was achieving. The census that refused it,
and the planted-module precondition beside it, go.
The effect half stays, and the distinction is what a stop can reach. An effect in a
module body runs at the position its module is emitted rather than in the start
sequence, so no stop function undoes it and each call leaks another one. A binding
leaks nothing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): stop the derived start sequence warning on every suite run
The helper reaches its neighbours through a variable specifier, which the bundler
answers by rewriting as a glob — and it refuses to glob the directory the import is
written in, so every suite that loads this file printed the refusal twice.
`@vite-ignore` leaves the specifier alone and the module runner resolves it, which
is what was already happening. An extension does not help: with one the refusal
becomes the own-directory rule, and the path alias is not resolved for a runtime
specifier at all.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): separate the two commits inside the closure reading
The factory arriving is not the whole -1,890. Making the document a factory put the
`host` argument on `createTerminalDocumentScope`, which is this lane's only edit to
a module the closure already carried, and that alone is +80. Both numbers are in
the note now, so neither commit's cost is read as the other's.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): read a document's elements from the host it was planted in
The last thing two documents on one page shared. Ruling 22 gave each call its own
scope, but the element reads were `document.getElementById` and the ids are in the
markup every host plants, so the second document's start sequence took the first
host's surface, overlay, handles and menu — two documents driving one terminal,
with the second host left empty.
Reachable, not theoretical: expo-router keeps the outgoing screen mounted for the
length of a stack transition, so two routes that both hold a terminal have two live
documents on the page while the animation runs.
`root` joins the host argument and `elementInRoot` is the one reader; the ten reads
in runtime-constants, surface-swap, selection-state-and-eviction and text-scaling go
through it. No id is renamed and nothing is refused: two documents on one page are
two terminals.
Two deviations from the ruling, both about *when* the default is read. `root` is
`ParentNode | null` with null meaning "the page I am in", rather than defaulting to
`document`: a data default is evaluated whenever a scope is built, which put a DOM
read into every slice evaluation and took eight keyboard-avoidance cases down with a
`ReferenceError` in their `vm` context. Null defers it to the read, which is the rule
the eight seams above it already follow. And the reader lives in
`document-host-seams.ts`, which declares the type, taking the root as an argument:
in `document-scope.ts` it was four lines over the file's 300 (no bump, no disable).
Red first, and the red was the second document: with a page-wide read the second
engine opens on an element outside its own host. `document-host-root.test.ts` plants
two hosts, starts a document in each, and reads which surface each engine was opened
on through the `createTerminal` seam, because the scope is not reachable from
outside. Falsified again after the fix by pointing the emitted reader back at
`document`: red, one case.
Two neighbours checked while here. The document-level touch listeners are already
host-scoped, because every handler tests its target against the scope's own surface,
overlay and handles, which are now this document's. `window.__engineErrors` is the
one page global left, and it is now kept rather than replaced per mount: a capped
diagnostic buffer, where a second mount was costing the first its captured lines.
Golden 27 diff lines, hash and length repinned: the reader, the `root: null`
default, and the ten reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the two-host engine double as the shape the seam returns
The double was reaching `createTerminal`'s return type through
`as unknown as Parameters<typeof queueMicrotask>[0] & never`, which the type-aware
gate reads correctly as an intersection with `never` and which was a cast standing in
for naming the type.
`TerminalDocumentTerminal` names it. Every member the type declares is present — the
ones `init` and the start sequence reach do something, the rest answer in the shape
their caller reads — and the shape needed no narrowing to accept a double. Two things
the type does not declare moved off it: where `open` was called is handed back beside
the terminal rather than exposed as a second getter, so the literal carries nothing
excess, and the buffer gained the `getLine` the type requires.
No cast, so nothing to write a SAFETY line about. Still red without the fix, checked
again after the retype by pointing the emitted reader back at `document`: one case.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): filter a document's page-wide touch listeners to its own host (OTA phase C, C7.5b round 1)
Round 1 H1. The dispatcher's four listeners are on `document`, so with two
documents on one page (legitimate since `712daa80e6`) each is handed the other's
touches, and the two-finger branch acts before any target filtering: a pinch in
host B posted `mobile-clip-cancel-by-pinch` from document A and dropped A's
selection. Fixed at the source, one predicate beside `elementInRoot`, asked once
at the top of each handler rather than inside a branch. `root === null` is the
WebView, where the document is the page, so it answers yes to everything and the
native document is unchanged.
`e.target` is the element the finger went down on for the life of the touch, so a
select-drag travelling outside the host still answers yes on move, end and cancel.
Census of every global listener install under `src/terminal/document/` (non-test):
- `tap-dispatch.ts:241-244`, four capture-phase `document` touch listeners
(touchstart, touchmove, touchend, touchcancel): MUST be root-filtered; this fix.
- `document-host-seams.ts:165-166`, `window`+`document` `message` in
`installWindowHostTransport`: WebView-only. It is that host's transport seam
default and the page installs nothing (ruling 24), so no page carries two.
- `fit-scale.ts:163`, `window` `resize`: page-wide by nature. A viewport change
concerns every document on the page and the event has no target in either host;
both must refit.
- `webgl-recovery.ts:104`, `document` `visibilitychange`: page-wide by nature.
Backgrounding concerns every document on the page; its target is the document.
- No document-level mouse, wheel, keyboard or selection listener exists: those
are all on `targetSurface` or the menu buttons, read through `elementInRoot`,
so they are already inside their own host.
Red-first, the reviewer's own repro in `document-host-root.test.ts`: A and B both
in select mode, a two-finger touchstart in B's surface. Before: 2 failed
(A posted the pinch cancel too, and the control in A's own host cancelled B).
After: 3 passed. The control keeps the assertion honest — the same touch inside
the document's own host still cancels its selection.
Golden and payload hash move (regen is a review event): six hunks, +22/-1.
`eventTargetInRoot` emitted after `elementInRoot`; `touchIsThisDocuments` after
the CAPTURE constants; the three-line guard at the top of each of the four
handlers; `onDocumentTouchCancel()` becomes `onDocumentTouchCancel(e)`.
Document 728,119 -> 728,589 bytes, sha256 5b65315b... -> 1556f532...
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): unit-test the page mount's three paths no happy path reaches (OTA phase C, C7.5b round 1)
Round 1 M2. `terminal-web-document-mount.ts` had no unit test: the only reading
of it was the render check, which drives the whole page bundle in a browser —
right for behaviour, too coarse for three lines that only a failure reaches.
The body's "five test files whose subjects no longer exist" is wrong for three of
them. What ruling 22 deleted was the machinery (the claim token, `liveDocument`,
the owner-checked dispose, the second-mount refusal); these three subjects
survived it and lost their only cover:
- both engines disposed when a swap never committed (`terminal-init.ts:203-213`);
- the host given back when a start throws (`startDocumentOrGiveTheHostBack`);
- the component naming that throw's cause (`TerminalWebView.web.tsx:83`).
`terminal-web-document-mount.test.ts` (happy-dom) covers all three against the
real generated factory. Only the factory's *arrival* is mocked, delegating to the
real `createTerminalDocument` except for the one case that makes a start throw, so
no stub stands in for the program under test.
Six cases, each red against a deliberately broken line:
- two distinct terminals both disposed. Broken `new Set([scope.term,
scope.committedTerm])` -> `new Set([scope.term])`: expected [1,1], got [0,1].
- the same terminal disposed once. Broken the set -> a plain array: expected 1,
got 2. The pair is the dedup's own oracle; either half alone passes for the
wrong reason.
- the host emptied and the class dropped on a throw. Broken by deleting the two
lines in the mount's catch: host still carried `#terminal-container`.
- control: a live document keeps the markup and the class, so the two assertions
above cannot pass for a mount that planted nothing.
- `onEngineError` gets `terminal document failed to start - engine missing`.
Broken by deleting the component's `receive` in its catch: expected one
message, got none.
- control: nothing reported when the document starts.
The engine double moves to `document-terminal-double.test-support.ts` and both
readers of the seam share it; a second hand-written copy of thirty members would
drift as the shape grows. It now counts disposals beside reporting `open`.
Terminal suite 67 files / 625 tests -> 68 / 631. No product line changed, so the
golden and the payload hash do not move.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin both generated wrappers, and say what a shadow root would break now (OTA phase C, C7.5b round 1)
Round 1 M1 and L2.
M1, the mount's stylesheet comment was one version behind: it said the document
reads its elements with `document.getElementById`, which `712daa80e6` replaced
with `elementInRoot`, and drew its shadow-root conclusion from that read. Both
halves re-derived rather than reworded. A shadow root no longer breaks the reads
(`elementInRoot` is a `querySelector` under the host, which a shadow root
answers); it breaks this sheet, because a rule in the document's head does not
cross a shadow boundary, so it would have to move inside each root and be parsed
once per host instead of once per page.
L2, `document-factory-artifacts.test.ts` anchored the page body at `):
TerminalDocument {` and nothing else, so the header, the `@ts-nocheck` line, the
`import type` and the parameter's own line could all drift with the test green —
and that signature is the one line a caller of the page's artifact reads. Both
wrappers are now literal lines: nine for the page (header, directive, import,
blank, the three-line signature) and one plus two for the native script
(declaration, closing brace, trailing call). Literal rather than the generator's
own constants, which would only agree with whatever it emits.
Red controls, each with the generator changed and then restored:
- the page's `import type` reordered to `{ TerminalDocumentHost, TerminalDocument }`:
2 failed ("the page module opens with its wrapper", and the on-disk reading).
- the native trailing call changed to `createTerminalDocument({});`: 1 failed
("the native script closes with its wrapper"). The old anchor caught neither.
No emitted line moved: golden and payload hash unchanged, terminal suite 68 files
/ 631 tests.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): thread the document scope and bundle the page's script
Ruling 25. The terminal document stops being a string the build machinery assembles
and becomes ordinary TypeScript: every module function that reads document state takes
`scope: TerminalDocumentScope` as its first parameter, `document-scope.ts` exports the
types and `createTerminalDocumentScope(host)` and nothing else, and `tsc` is the oracle
that the threading is whole (a missed parameter is a type error).
`create-terminal-document.ts` is hand-written, not emitted: it builds the scope, runs the
eleven starts inside a try that unwinds on a throw, stops the ten in reverse with
`cancelDocumentFrames` last, and returns `{ send, stop }`. `native-document-entry.ts` is
one statement. The concatenator becomes an esbuild IIFE bundle of that entry at the
Chrome 74 floor, written as a string the same way the engine artifact is.
The byte pin cannot survive that and does not try to: esbuild merges module scopes and
renames the threaded parameter (`scope` -> `scope2` where two modules collide), so the
document's text is no longer a stable artifact and the behavioural suites are the proof.
`native-document-bundle.test.ts` evaluates the real bundle and reads three behaviours the
phone depends on: it announces `web-ready`, an init message opens the engine inside
`#terminal-surface`, a ping is answered, and a missing engine global reports fatally
instead of starting.
Rewritten tests, old oracle -> new oracle:
- host-seams, write-queue, document-frame-registry, document-parse-time-effects:
evaluated a region of the generated text -> import the module and pass a scope the case
builds.
- terminal-webview-engine (WebGL recovery), terminal-webview-theme,
terminal-webview-text-zoom, terminal-webview-query-reply,
terminal-keyboard-avoidance-webview: a `vm` evaluation with injected globals -> the
imported functions over a scope whose seams are the case's own doubles.
- terminal-webview-url-tap: a function extracted out of the document's text and evaluated
-> the document's `osc-link-tap` exports imported directly.
- terminal-webview-reflow, terminal-webview-scroll-routing, terminal-path-tap,
terminal-webview-tap-routing, terminal-webview-wheel-scroll: assertions over the
assembled document text -> the same assertions over the module's own source, read
through `document-module-source.test-support.ts` (TypeScript, so no semicolons).
- document-host-root, document-start-unwind: imported the generated factory -> import
`create-terminal-document`.
- terminal-webview-consumer-census: the generated factory was a census exception -> it no
longer exists.
- config/scripts closure tests: re-measured for the bundled document.
Deleted with the machinery they served: the concatenator's emit/substitute/order code,
`terminal-document-module-order.mjs`, the document fixture builder,
`generated-document-region.test-support.ts`, the byte golden and its identity test, the
payload-hash pin, `document-factory-emit`, `document-factory-artifacts`,
`document-module-order`, and the generated factory artifact with its gitignore and
lint-ignore lines.
Two assertions the tests no longer need: the published contrast floor arrives unvalidated
from a host of unknown version, so `TerminalDocumentThemeMessage` types it the way the
router types its other wire fields and `normalizeTerminalContrastOverride` remains what
decides it is a number; the recovery harness holds its timer callback in a wrapper rather
than asserting a narrowing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): dissolve the document's constants module into its owners
`document-constants.ts` existed because the document was a string: a string cannot
import, so the generator substituted JSON literals into the text and the web page imported
the same bindings to keep one source. The document imports now, so the indirection is a
re-export shim over four real owners and each site reaches the owner instead.
- `terminal-theme.ts` takes the background fallback from the theme's own `colors`.
- `url-tap.ts` imports the two patterns and the length cap from
`terminal-webview-url-tap.ts`, which is where the page's copy reads them, and the three
local aliases go with the substitution they were shaped for.
- `text-scaling.ts` and `document-scope.ts` take the presets from `storage/preferences`.
- `terminal-init.ts` and `document-scope.ts` take the caret options and the built-in theme
from `terminal-webview-html/theme.ts`.
One test oracle moves with it: the URL tap's "both copies spell the pattern identically"
case pinned the substituted assignment line, and now reads the document module's import of
the page's own constant. The resolver cases that compare the two behaviours are unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): count only the fingers inside this document's own host (OTA phase C, C7.5b round 2)
Round 2's residual of `9824145e1f`'s class, one level in. `eventTargetInRoot`
settles whose event it is; every branch then counts `e.touches`, which is every
finger on the screen. A finger resting in host A is therefore B's second finger:
a one-finger touch in B's own surface reads `length === 2`, latches a pinch and
drops B's selection, and on touchend `length === 0` is never true so B's surface
tap never fires.
`touchesInRoot(root, touches)` beside `eventTargetInRoot` returns this document's
own fingers, and every count and index reads through it. A list rather than a
count, because `touches[0]` and `touches[1]` are page-wide in exactly the same
way as `touches.length` — the first finger on the screen may be the other
terminal's. `root === null` is the WebView, whose fingers are all its own: the
list is returned untouched, so nothing is allocated on a path that runs at frame
rate.
Census of every `touches` / `changedTouches` / `targetTouches` read under
`src/terminal/document/` (non-test). There are no `changedTouches` or
`targetTouches` reads at all; every read is `e.touches`:
- `tap-dispatch.ts`, 15 reads across the three handlers that take an event
(`[0]`, `[1]`, `.length`, and the list handed to `touchById`): MUST be filtered.
The document listens on `document`, so the event and its list are both page-wide.
- `surface-touch-gestures.ts`, 18 reads across its touchstart, touchmove and
touchend handlers: MUST be filtered. These listeners are on the document's own
surface, so the event is always this document's — but the list inside it is
still every finger on the screen, which is the whole defect.
- `tap-dispatch.ts:21-24`, `touchById(touches, id)`: no filtering of its own. It
reads whatever list it is given, and all three callers now hand it a filtered
one; its parameter widens from `TouchList` to `ArrayLike<Touch>`.
Red-first in `document-host-root.test.ts`, the reviewer's two repros, with the
three product files at `aba99c3e4f` and the artifacts rebuilt: 2 failed / 3
passed (pinch cancel posted with one finger on B's overlay; `terminal-tap` never
posted). With the fix: 5 passed. The pinch-inside-own-host control stays, and the
first repro lands on B's menu pill rather than its surface, because a single
finger on the surface dismisses a selection by design — on the pill, keeping the
selection is the whole assertion.
`document-host-seams.ts` also rewritten in the present tense where it read as
history.
Golden re-pinned: 19 hunks, +51/-33. `touchesInRoot` emitted after
`eventTargetInRoot`; one `const touches = touchesInRoot(scope.root, e.touches)`
at the top of each of the six touch handlers, and every `e.touches` read inside
them now reads `touches`. Document 728,589 -> 729,152 bytes, sha256 1556f532...
-> 02633389...
Correction to `9824145e1f`'s message: it cites `tap-dispatch.ts:241-244` for the
four installs, which at that commit are `261-264` (the line numbers are the
pre-fold ones from the review).
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): fold the document's never-written constants out of the scope
Seventeen scope fields were never assigned after the factory built them. They were fields
because the generator substituted them into one function scope and a module cannot import
into a string; each is now a `const` in the module that owns it.
- `escape-introducers.ts` holds `ESC` and `C1_CSI`, as the bytes rather than as
`String.fromCharCode` calls a JSON substitution needed.
- `write-queue.ts` owns the status dot, its two presentation selectors, the pattern and the
DECSET tail limit. The pattern is a regex literal: a `new RegExp` at a module's top level
is parse-time work, which ruling 20 refuses and the census measures.
- `fit-scale.ts` owns `MIN_FIT_COLS`, `text-scaling.ts` the ends of the preset range,
`tap-dispatch.ts` the press and tap thresholds, `selection-range.ts` the word pattern and
`selection-overlay.ts` the edge-scroll distance and tick.
Four functions stop taking a scope they no longer read: `isStatusDotPresentationSelector`,
`endsWithStatusDotPresentationSequence`, `extractMouseModeScanTail` and, with
`normalizeInitialData`, `isAltScreenActive`.
`runtime-constants.ts` held no constants once they moved out, only the surface element read.
That read is the first line of `startSurfaceSwap` now, which is the module the field is
documented as belonging to, and the start sequence is ten calls rather than eleven.
The engine error buffer becomes a seam, `capturedEngineErrors`. The WebView's `<head>` keeps
its own: it opens before the engine script tag, so an engine that throws while loading is
captured by something no document has started yet, and the first report quotes it. That is
why the head declaration stays where the design said it would go — the page's mount answers
the seam with a buffer per mount instead of assigning a window global, which is what the
document no longer touches.
Two of the page's three casts are gone, and tsc is what says so: xterm's cell attribute
getters answer numbers, and `getLine(...).getCell` answers `undefined` rather than null, so
the shape now describes the engine it was written against. The third stays with a narrower
reason: `getCell` takes back the cell xterm allocated, and describing that parameter means
naming xterm's whole cell type where the document declares the six members it reads.
Comments that narrated the extraction — the scope table's `var` census, "the flip", "the
main slice", "C7.1 extracts" — say what the code does instead.
Oracles that moved with the constants: the reflow floor and the status dot now read the
owning module's `const` rather than a scope-factory line; the write-queue harness resolves
the module's one value import; the parse-time census asserts the stronger fact that no
module does parse-time work, with the element reader aimed at every module and its presence
proved by the planted case.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): re-measure the session route's closure for the threaded document
The reading in the closure test's note is against `origin/main` at ec82173130, measured the
same way on both sides: the route and its layout built as their own entries, the route's
output taken minified.
modules 4320 -> 4321 (+1)
local modules 970 -> 971 (+1)
minified bytes 3,768,122 -> 3,764,932 (-3,190)
The +1 is three modules in and two out, which the note names. The bytes fall for two
reasons the lane can point at: a threaded parameter minifies to one character where a
shared object could not, and a constant folded into the module that owns it is inlined
where `scope.X` was a property access the minifier had to keep.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep AsyncStorage out of the WebView document, and prove it
The 13 KB the bundle grew is not esbuild's lazy `__esm` wrappers and not a cycle: there
are none of either. It is a dependency that rode in. `text-scaling` reached
`storage/preferences` for the text-scale presets, and that module imports AsyncStorage, so
the phone's document carried AsyncStorage, `merge-options` and `is-plain-obj` — 11.6 KB of
storage library inside a string with nothing to store, wrapped in esbuild's CommonJS
interop. The old generator hid this: it substituted the presets as a JSON literal, so the
import never reached the emitted text.
The presets move to `terminal/terminal-text-scales.ts`, a leaf with no imports of its own,
which `storage/preferences` imports and re-exports for the settings screen. The bundle:
113,442 characters, 3,465 lines, 47 inputs, none from node_modules
was 120,217 characters with 6 node_modules inputs and three `__commonJS` wrappers
the golden it replaces was 110,085 bytes
`minify: false` stays, for the reason given: the overlay reports the line and column
`window.onerror` hands it.
Three cases join the bundle evaluation, and each was made to fail before it was kept:
- A `set-theme` and a `write` before `init`, which is what a byte golden covered by
accident. Read from the router rather than assumed: the theme applies to the scope and
paints through the seam, the chunk normalises and queues, the pump returns because there
is no terminal, and `init` then resets the queue and the mode scan so the early chunk is
dropped and the init frame's own theme wins. Deleting `resetWriteQueue` from `init` makes
the terminal write `early chunk\x1b[0mreplayed` — the early bytes ahead of the snapshot,
which is the corruption the reset prevents.
- The transport, both ways: the native document installs `message` on `window` and on
`document` and keeps them, because the WebView never stops its document; the page's
factory with a no-op transport installs none, so none of the shell's own frames are taken.
Installing on one target fails the first half, installing a real listener the second.
- What the bundle carries: no input from node_modules, no `__commonJS`, no `__esm(`.
Pointing the presets back at `storage/preferences` fails it with the six inputs named.
The build options become one object the census and the build share, so what is measured is
what ships.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct the closure reading for the text-scale leaf
The presets moving to their own module adds one more to the page's closure than the reading
recorded, and five bytes with it. Measured the same way on both sides.
modules 4320 -> 4322 (+2)
local modules 970 -> 972 (+2)
minified bytes 3,768,122 -> 3,764,937 (-3,185)
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): separate the lane's closure reading from main's
The byte figure in the note was measured before `origin/ota-c7-5b-document-factory` and the
main it carries were merged in. This head reads 3,765,180; the 243 between the two are the
touch-root predicates and main's #21687 momentum change, which are not this lane's to claim
in either direction. Both numbers are named rather than one of them silently replaced.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare the start sequence with what the modules export, by name
Round 1's finding on the census: completeness was a count, so a module exporting a start
nobody calls red only as `expected 11 to be 10` and greened again the moment the literal
moved with it. The two sets are compared by name now, with the order asserted as well: the
starts the factory calls are every exported `start*(scope)` there is, the stops are every
exported `stop*(scope)`, and the stops of modules that have both run in the reverse of the
order their starts did.
`cancelDocumentFrames` is held out of the set comparison and asserted by position instead —
last, after every stop that might still hold a frame. `stopEdgeScroll` is the one exported
stop the sequence does not call, and it is not a lifecycle undo but the overlay's own for a
drag that is over; the test asserts `stopSelectionOverlay` reaches it rather than waving it
through.
The names come from the tree rather than the text, because a regex over the file would also
match the sequence's own name in the unwind inside `startTerminalDocument`'s catch.
Red-first control, `export function startReflow(scope)` added to `reflow.ts` and not called:
the set comparison fails naming `startReflow`, where the count failed with a number. A
second case plants the same shape against the reader itself, so the comparison is a
measurement rather than an agreement between two empty lists.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the bundle census and the artifact off one build
Round 1's finding: the input assertion built the bundle while the wrapper assertions read
the committed string, so under the presets-to-preferences control the rebuild red and
`__commonJS` passed against a stale artifact. And `inputs.length > 40` was a bound, not a
census.
`terminalDocumentBundle()` returns the text and the module list from one build, and
`buildTerminalDocumentScript` is that function's text — so the thing measured is the thing
written. The case asserts the exact input count, no node_modules input, neither wrapper, and
that the committed artifact equals what the sources build. A stale artifact now reds.
Controls: the presets pointed back at `storage/preferences` fails on the inputs and on
`__commonJS` in the same run; `MIN_FIT_COLS` changed to 21 without rebuilding fails the
equality. Worth knowing for the next reader: an unused export or a dropped comment does not
fail it, because esbuild does not emit either — the assertion is about what ships.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): show that the page's capture buffer is written, and say what writes it
Round 1 read the page's `capturedEngineErrors` array as never written and asked for an empty
readonly list instead. It is written, and the empty list would drop a line from every report
the page makes: `startHostNotify` installs the reporter through `installErrorReporter`,
which on the page is a `window` error listener, and the reporter appends each error it
forwards before `reportEngineError` quotes the buffer back. `host-notify.ts` is the file that
proves it; the comment on the mount said none of this and now says it.
The pre-start window round 1 asked about is the half the page genuinely cannot have. The
WebView's `<head>` opens its buffer before the engine script tag, so an engine that throws
while loading is captured by something no document has started; on the page the engine is a
static import of this module, so there is nothing to capture before the document exists.
Red-first: two errors dispatched at a real mount, and the reports quote `captured: first
failure` then `captured: first failure | second failure`; a second mount quotes its own line
and not the first document's. Answering the seam with `() => []`, which is what round 1 asked
for, fails the first assertion.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): put the engine doc block back on the engine seam, in the present tense
Round 1's last two. The block describing how the WebView knows the engine is there had ended
up above `windowCapturedEngineErrors`, one function too high; it is on `windowHasEngine`
again, with that function's own note about the optional global folded in.
Two references that had outlived what they named: the host-seams case cited
`runtime-constants`, which this branch deleted when its one element read moved into
`startSurfaceSwap`, and now cites modules the sequence still has; the query-reply harness
narrated what its oracle used to be instead of what it is.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): reverse the stop order without mutating the list
`Array#reverse` mutates, which the lint rule refuses and which would have left the census
comparing a list it had just reordered. `toReversed` on the filtered copy says the same thing
and cannot.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): put every document import back in its import block
pullfrog caught `fit-scale`, where `scheduleDocumentFrame` sat below `MIN_FIT_COLS`. A
statement walk over all 41 non-test modules under `document/` — the tree, not a grep, so a
multi-line import or one inside a comment cannot hide — found one more: `host-notify` split
its two `document-host-seams` lines around the re-export between them. Both imports moved up;
the re-export stays where it was, below the block, with its comment in the present tense.
The sweep reports no misplaced import across the 41 modules now.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): name the haptics module inside the merged session-closure reading (OTA phase C, C7.5b)
The merge's re-measured 4284 sat one above this branch's -40 added to PR B's +3,
and the comment could only say main had drifted "a module of its own". It is
`src/mobile-web-shell/bridge/bridge-haptics-notify.ts`, which C7.10 item E put on
the session route after PR B recorded 4323 — so pristine main reads 4324 against
the 4323 it holds, which is what #21908 re-pins.
Named here as #21908 names it on main. Nothing measured changes: 4284 is the same
number, and the module is in it by main's route rather than by anything this branch
did. `haptics.web.ts` was already in the closure; the bridge module joins it.
Verified by reading the closure's own module list rather than inferred from the
count: both haptics modules are in `local`, with the artifact-level totals
unchanged at 4284 / 934.
Which is why the reading is re-measured and not summed. A merged number arrived at
as -40 plus +3 would have read 4283 and been wrong about a module neither side of
the merge touched.
After #21908 lands, a further merge of main reconciles the two comments.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): stop every document a host-seams case started
CodeRabbit's finding, and it measures: `startedScope` runs the whole start sequence and no
case stopped it, so the `afterEach` restored the globals and left the listeners. A start
installs six on `document` and `window` — the dispatcher's four capture-phase touch handlers,
the fit's resize and the recovery's visibilitychange — and those are page-wide by nature, so
a document nobody stopped keeps answering events in the next case, with a scope that case has
never seen and host hooks that belong to the case before it.
Every started scope is tracked and stopped in the existing `afterEach`, before the globals go
back, because a stop reads the scope's own seams and one of them is a window read a case may
have stubbed.
Measured by counting `addEventListener` and `removeEventListener` on both targets across the
file's run: 18 added and 0 removed before this, 18 added and 18 removed after. For a single
case it is 6 and 0 against 6 and 6.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): pair each doc block with the declaration under it
Two comment placements, both mine and both from the merges.
The closure test's `SESSION_ROUTE_MODULES` block opened twice: my conflict resolution kept
the opener that was above the marker and supplied another with the replacement text, so the
file carried a literal `/**` inside the block it opens. Nothing flags that, because it parses
as one comment. Swept the rest of the files the merge touched for stacked openers and for
stray markers; there are none.
In the host-seams case, `startedScopes` and its block landed between `startedScope`'s doc
comment and the function, which left the function undocumented and stacked two blocks on the
const. The const moves above the function's doc, so each comment sits on what it describes.
host-seams 10 tests, the closure test 3, both exit 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the terminal WebView document byte for byte
The document is already pinned as a digest, which says whether the emitted
bytes moved and nothing about where. C7.1 moves the hand-written script inside
it into modules the web page can import and rebuilds the document from them,
and the claim that has to hold through every one of those commits is that the
native screen kept the document it had. A digest cannot be the instrument for
that: it fails as two hexadecimal strings.
So the document is also committed as itself. The fixture is generated by
`scripts/build-terminal-document-fixture.mjs`, never pasted, and the test
rebuilds the comparison through that script's own substitution rather than
restating it, so a fixture written by one rule and read by another cannot agree
with itself.
The generated xterm engine is stored as two placeholders. It is already covered
by the digest test, postinstall regenerates it from whatever xterm the lockfile
holds, and inlining it would put 612 KiB of vendored bytes into the file whose
job is to isolate hand-written changes. Two further cases keep that from
becoming a hole: the placeholders must each appear exactly once and the engine
must not appear at all, and the restored document must equal the real one.
Regenerating the fixture is a review event. It is only correct when the emitted
document was meant to change, and the diff in that commit is the evidence.
Red-first: flipping one character inside a comment in `write-queue.ts` fails
both identity cases with a one-line diff naming the comment, where the digest
test reports a hash.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare two terminal documents as programs, not as bytes
The C7.1 flip commit moves the document's 57 reassigned variables onto a scope
object, because a variable assigned across ES modules is a syntax error, and
every read and write of them gains a qualifier. The ruling asks that the review
of that commit be a test rather than a 515-line read. This is that test's
instrument.
It cannot be a byte comparison. Once the script's source is modules, `oxfmt`
owns its style, and the repository's style has no semicolons where the
hand-written document has one on nearly every line. A byte diff would therefore
be dominated by changes that are not the refactor, which is the opposite of
what the reviewer needs.
So the comparison is over tokens: semicolons are excluded for the same reason
they moved, comments never reach the stream, and one difference is allowed —
`name` becoming `<qualifier>.name`, three tokens for one — which it counts and
reports. It is stricter than "it still runs": a reordered statement, a changed
literal, a dropped operator, a renamed local and a qualifier under the wrong
object name all diverge, each reported with the token index and both sides.
Acorn carries `value` on its tokens but does not declare it, so the field is
read through a narrowing check rather than asserted onto the declared type.
Red-first, by mutation: dropping the qualifier-name check fails the case that
names it; removing the leftover-token check fails the dropped- and
added-statement cases; treating semicolons as significant fails the three cases
that depend on ignoring them. The acceptance case runs on the real 2,758-line
script rather than on a fixture, so the instrument is known to survive
everything the document actually contains.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): count each normalisation the move makes, separately
Measured while extracting the first group: the document's ES5 style is not a
style this repository's own rules permit. `curly` braces 279 brace-less
if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446
`var` declarators become `const`, `let` or a scope field. Those rewrites land
before the qualifier is considered at all, so "the qualifier and nothing else"
was never reachable once the source is a linted module.
The comparison now allows exactly four classes and counts each on its own: a
reference that gained the qualifier, a declaration that moved onto the scope
object, a `var` that only changed keyword, a body that gained braces, and a
catch clause that lost its binding. Separate counters rather than a total,
because the flip commit pins each number and a total would let one class absorb
another — which is the drift the pin exists to catch. The two `var` classes
partition the 446, and the qualifier's 641 sites partition into references that
kept their declaration and declarations that moved.
Two ordering facts the cases pin. The catch rule is tried before the brace rule,
or the inserted-brace rule eats the `{` that follows `catch` and the streams
never resynchronise. A body braced at the very end leaves its closing brace
after the baseline has run out, so trailing closes are absorbed after the walk
rather than reported as a length difference.
Everything outside the four classes still refuses with the token index and both
sides: a changed literal, a dropped operator, a reordered pair, a renamed local,
a qualifier under another object's name, a brace opened and never closed, and a
brace closed where none was opened.
Red-first, by mutation: disabling the catch rule, disabling the trailing-brace
absorption, folding scope-field declarations into plain references, and not
counting brace insertions each fail exactly the case that covers them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the mouse-report cell a module the page can import
The first of the twelve groups the document already names. `*-injected.ts` has
been splicing JS strings into the document for a while, and tests evaluate
those strings, so the one-source-two-consumers shape is already there; what is
missing is that a string cannot be imported by the web page, typechecked, or
linted. This turns one of them into a module and adds the generator that puts
it back into the document.
The generator is a transform, not a bundle: a bundler orders its output by the
dependency graph, and the document's order is part of what the equivalence test
holds fixed. Imports are dropped rather than resolved, because inside the
document every name is already in scope — that is what the single IIFE means —
and `document-externals.ts` declares the names whose groups have not moved yet
and emits nothing at all. esbuild prints an ESM module's exports as a trailing
block, so that block is dropped whole rather than by its keyword; leaving the
keyword behind would put a bare block statement in the document.
Both sides of the comparison now go through that same printer before being
read. Otherwise every choice the printer makes — semicolons, property
shorthand, quote style — reads as a difference in the program when it is a
difference in who typed it, and each would need its own rule. A script that
does not parse is reported as a refusal naming its side, not thrown.
`let` is contextual outside strict mode, so acorn reports it as a name and not
as a keyword; without that the var-to-let rewrite the linter performs would be
refused on every reassigned local.
The group's counts are pinned exactly: nine references gained the qualifier
(`term` seven times, `panX` and `panY` once each), nine locals became `const`
or `let`, thirteen one-statement `if` bodies gained braces, no declaration
moved onto the scope object and no catch clause lost a binding.
The document is untouched, so the byte pin from 3006d8dfdf is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* 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
* refactor(mobile): make reflow a module, and give the generator its own tests
The third group, and the defect it found: esbuild wraps a long import list
across lines, and the generator was skipping only the first of them, which left
the remaining names loose in the emitted script. The document did not parse, and
the equivalence check said so by name rather than throwing — which is what that
refusal path was added for. Both lists, import and export, are now skipped to
their closer instead of by their first line.
The generator's own tests cover what the per-group comparisons cannot say on
their own: an export is unmarked and indented into the document scope, a
one-line import is dropped, a wrapped import is dropped whole, the trailing
export block esbuild prints is dropped rather than left as a bare block
statement, and types are erased without touching the program.
Reflow's counts: eleven qualified references — the terminal ten times and the
settled row count once — six locals that became `const`, and the two early
returns braced. The row count is written from three groups, so unlike the
query-reply flag it is the document's state rather than one module's.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the keyboard-avoidance metrics a module
The fourth group, and the first that needed a non-null assertion.
`lineHasVisibleContent` reads the terminal's column count with no guard of its
own; the guard is in `computeContentBottomRow`, which is its only caller. Adding
a guard would change the program, and optional chaining would change what
happens when there is no terminal — the document throws there today. TypeScript
erases a non-null assertion, so the emitted script is unchanged and the
invariant is written down where the reader needs it.
Reflow now imports the metrics call from this module rather than declaring it an
external, which is the shape every group takes as its neighbours arrive.
Counts: fourteen qualified references, nine locals rebound, ten one-statement
bodies braced, and the two `catch (e) {}` clauses — the row scan and the
alternate-screen probe — unbound.
The scope table's rule is stated more precisely with it: a variable is this
module's own only when the group both declares and assigns it. While the rest of
the document is still strings, one the main slice declares stays shared even if
every use is in one group, because emitting a second declaration beside the one
the slice still carries would not be the same program.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make WebGL loss recovery a module
The fifth group, and the first carrying a top-level statement rather than only
declarations: the visibility listener it registers. In the document that runs
when the IIFE reaches it; as a module it runs on import, which is the same
single registration.
The context-loss listener disposes the addon it is registered on, so it cannot
run before that addon exists, but the assignment is to a `let` a closure
captures and TypeScript will not carry the narrowing across it. A non-null
assertion, erased by the compiler, keeps the emitted script identical and puts
the invariant where the reader is.
Counts: twenty-three qualified references across the terminal, the addon, its
retry timer and the theme the host last sent; three locals rebound; twelve
one-statement bodies braced; five of the six catch clauses unbound, the sixth
keeping its binding because the attach failure reads the error into its
diagnostic.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make indirect-pointer scroll a module, and count a fifth class
The sixth group found a rule the four classes do not cover, so I measured the
whole script rather than meeting them one at a time: linting all 2,757 lines as
a module trips `curly` 279 times and `no-unused-vars` 38, both already counted,
and then five further rules at 23 sites — `prefer-number-properties` 17,
`prefer-includes` 2, `no-useless-escape` 2, `prefer-exponentiation-operator` 1
and `no-unused-expressions` 1.
Seventeen of those 23 are one rewrite: a global numeric function moved onto
`Number`. It has the same token shape as the qualifier, so it is counted as its
own class rather than folded into anything, and only the four numeric globals
are admitted — anything else appearing under `Number` is refused, which a case
pins. Every site is already behind a `typeof … === 'number'` check or is parsing
a string, so the two forms are the same test.
The remaining six sites are each a different shape and too few to be worth
matching; they will surface as refusals in whichever group carries them, and I
will report each rather than widen this.
The scroll accumulator is the first declaration to move onto the scope: it is
declared in this group but a touch scroll in another slice resets it, so the
`var` becomes an assignment to the shared field and the class that exists for
exactly that counts one.
Counts: five qualified references, one declaration moved, four locals rebound,
eight bodies braced, one `Number` rewrite.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal surface-swap group into a module
The seventh named group. `surface` and the uncommitted terminal are read by
other slices, so both move onto the scope; the two committed handles and the
pending surface are declared and assigned only here and stay module locals.
Counts: qualified 7, scope declarations 1, rebindings 4, braced bodies 2,
unbound catches 2, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): substitute build-time constants into the emitted document
The document's script text is not all hand-written: parts of it are template
literals interpolating real values, starting with the theme background. A
module cannot interpolate and still be the same program, so the generator now
derives an esbuild `define` from `document-constants.ts` and substitutes after
the import lines are dropped, when the names are free again. The page imports
the very same bindings, so there is one source either way.
The fixture script's TypeScript loader moves beside it rather than being
written twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal theme group into a module
The eighth named group, and the first parameterised one: its background
fallback comes from the mobile theme through `document-constants.ts`.
Two sites carry a line-scoped lint disable rather than the rewrite the rule
asks for: `indexOf(',') >= 0` and `Math.pow`. Both rewrites are outside every
normalisation class the equivalence instrument counts, so taking them would
change the program the native document carries, which is the one thing this
branch holds fixed. The reason is on the disable line.
Counts: qualified 12, scope declarations 0, rebindings 28, braced bodies 13,
unbound catches 0, number properties 9.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal path-tap group into a module
The ninth named group, and a pure query: it reads no shared state, so it has
no qualifier sites at all.
Two things this group forced. The generator now drops lint directive lines
before the transform, because a directive inside an expression makes esbuild
parenthesise that expression to keep the comment where it was, and those
parentheses are tokens the document does not have. And the two regexes keep
their `no-useless-escape` escapes behind a line-scoped disable, for the same
reason the theme group keeps `Math.pow`.
One name the document declares twice in one function stays `var`. Two
block-scoped declarations would be two bindings where the document has one,
and esbuild renames the inner one to say so.
Counts: qualified 0, scope declarations 0, rebindings 31, braced bodies 20,
unbound catches 0, number properties 2.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal tap-dispatch group into a module
The tenth named group, and the heaviest reader of shared state: the selection,
its elements, its thresholds and both press origins are all declared by the
overlay slice, which is still document text, so all of them move onto the
scope with their declarations left where they are.
Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11,
unbound catches 0, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal mouse-click-drag group into a module
The eleventh named group. The escape byte and both SGR mouse modes join the
scope from the runtime slice; the gesture itself is declared here and never
read outside, so it stays a module local.
Counts: qualified 17, scope declarations 0, rebindings 22, braced bodies 27,
unbound catches 1, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal url-tap group into three modules
The twelfth and last named group, and the second parameterised one: both
candidate patterns and the length bound come through `document-constants.ts`.
Three modules rather than one. At 303 lines it was over the file cap, and the
document's own order interleaves the OSC 8 lookup with the file-URL parsing,
so the split follows that order and the group's text is the three emissions
joined. The test does the joining.
Note for a later lane: `terminal-webview-url-tap.ts` and
`terminal-file-url-tap.ts` already hold TypeScript twins of some of this,
written for the React Native side and not identical to what the document
carries. Collapsing the two is a behaviour change and does not belong in a
branch whose whole claim is that the document did not move.
Counts: qualified 10, scope declarations 0, rebindings 41, braced bodies 25,
unbound catches 6, number properties 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the mouse-mode DECSET scan slice into a module
The first of the thirteen inline slices. Both control-sequence introducers,
the straddling scan tail and all three mode fields are declared by the
runtime-state slice, which is still document text, so they move onto the scope
with their declarations left where they are.
Counts: qualified 20, scope declarations 0, rebindings 10, braced bodies 9,
unbound catches 0, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal message-bridge slice into a module
The script and the document end in the same slice, so the slice splits in two
at the point where the IIFE closes: the script half becomes a module, the
document half stays text. The byte pin proves the join is unchanged.
The second catch keeps its binding: it names the error and reports it.
Counts: qualified 1, scope declarations 0, rebindings 1, braced bodies 0,
unbound catches 1, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): give the document close its own slice file
The previous commit put two exports in one slice file, which the slice-count
guard reads as a mismatch: it derives the slice list from the composer's
imports and cross-checks it against the composed entries, one per file. Five
suites failed to load.
Splitting the file rather than the constant is the better shape anyway. The
file was called `message-bridge-and-document-close` because it carried two
concerns; now each has its own.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal term-observers slice into modules
This slice interpolates the already-extracted keyboard-avoidance group between
its own two halves, so its text is three emissions joined in that order and
the test does the joining.
A sixth normalisation class, measured here rather than assumed: the printer
writes `{ name: name }` back as shorthand, and qualifying the value makes the
property name unavoidable again, so one baseline token faces four. It is
counted on its own like the others, with its own acceptance case in the
instrument's test, and every existing group's pin now carries a zero for it.
Counts: qualified 36, scope declarations 1, rebindings 12, braced bodies 12,
unbound catches 6, number properties 0, shorthand properties 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the selection-state-and-eviction slice into a module
The slice that declares most of the shared selection state: every threshold,
every overlay element and the selection itself, twenty-two scope declarations
in one place. The eviction counter is declared and assigned only here, so it
stays a module local.
Counts: qualified 12, scope declarations 22, rebindings 2, braced bodies 3,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the smooth-scroll and cell-geometry slice
Two modules, not one: the slice carries the normal-buffer smooth scroll and
then the cell-to-pixel geometry, and the split follows that order so the
group's text is the two emissions joined. Four names stop being externals and
become real imports.
Counts: qualified 39, scope declarations 0, rebindings 15, braced bodies 16,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* 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
* refactor(mobile): extract the terminal fit-scale slice into a module
The slice opens with the already-extracted theme group, so its text is two
emissions joined. Four more names stop being externals.
Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20,
unbound catches 0, number properties 9, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* 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
* refactor(mobile): extract the runtime-state and text-scaling slice
The document's declaration block, where almost everything it shares is
declared, with the query-reply and surface-swap groups interpolated inside it.
Three modules: the two declarations that come before the groups, the text
scaling, and the viewport transform with the scroll indicator. Seven more
names stop being externals.
Two things this slice forced.
The scope-declaration rule now counts each declarator of one `var`, because
`var panX = 0, panY = 0` becomes two assignments onto the scope. It has its
own acceptance case in the instrument's test.
The two halves are compared against their own text rather than as one joined
program. The declaration the slice opens with is shadowed by a parameter
inside one of the interpolated groups, and printing the baseline as one
program renames that parameter; qualifying the outer name removes the shadow,
so the rename has nothing to correspond to. Splitting the slice on the group
constants compares like with like, and those groups have their own tests.
Build-time constants are now substituted textually rather than through an
esbuild `define`: a `define` whose value is an object or an array is injected
as a helper binding instead of being inlined.
Counts, head: scope declarations 2. Tail: qualified 31, scope declarations 38,
rebindings 25, braced bodies 13, unbound catches 1.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): format the two test files the last commit left unformatted
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the mouse-report and scroll-routing slice
Two modules around the already-extracted mouse-report-cell group: the viewport
cell lookup that precedes it, and the mouse input encoding and scroll routing
that follow. Eight more names stop being externals, which leaves ten.
Counts: qualified 49, scope declarations 0, rebindings 49, braced bodies 42,
unbound catches 3, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the host-message-router slice into modules
Two modules after the already-extracted reflow group: the postMessage bridge
with the engine error reporting that rides on it, and the router itself.
`notify`, `handleMsg` and `reportEngineError` stop being externals, which
leaves seven.
The catch binding handed to the error reporter keeps a cast: a catch variable
is `unknown` under strict mode, and the reporter reads only `message` before
falling back to `String()`. The reason is on the line.
Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12,
unbound catches 2, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the selection-overlay slice into modules
Two modules after the already-extracted path-tap and url-tap groups: the
selection range with the xterm mirror, and the overlay positioning with the
edge scroll. Six more names stop being externals, which leaves one.
Counts: qualified 77, scope declarations 0, rebindings 96, braced bodies 63,
unbound catches 9, number properties 6, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the surface-touch-gestures slice into modules
The last of the thirteen slices. Two modules after the three already-extracted
groups: the selection menu's buttons, and the touch gestures with the pinch
and the momentum scroll. `attachSurfaceEventHandlers` was the last external,
so `document-externals.ts` is gone: every name the document uses now resolves
to a module.
The instrument reads both sides strict. A loose script has to defend Annex B's
block-scoped function declarations, and the printer does that by hoisting a
`var` and renaming the function, so one side carried a rename the other could
not. Neither name escapes its block, so the two readings agree on behaviour
and only the strict one can be compared. It has its own acceptance case.
Counts: qualified 104, scope declarations 1, rebindings 69, braced bodies 57,
unbound catches 2, number properties 2, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the document's opening declarations into a module
The document shell carried the IIFE opener and the eight declarations inside
it, so it splits the way the message-bridge slice did: the shell keeps the
HTML and the opener, a new slice file holds the declarations, and the byte pin
proves the join is unchanged.
With this every line of the document's script has a module behind it.
Counts: qualified 3, scope declarations 8, rebindings 0, braced bodies 0,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the whole document script against the modules
Every line of the script now has a module behind it, so the whole thing can be
compared at once. This is the review of the move, as one number per class:
qualifier 609 references + 73 declarations = 682 sites
var rebindings 373, the document's 446 declarators less those 73
curly braces 279, the number measured before any of this started
unbound catches 36 of 38; two name their error and report it
Number properties 17, also measured up front
shorthand properties 4, two SGR flags written twice each
unshadowed names 7
A seventh class was needed and is counted like the others: a binding that
shadowed a document variable stops being a shadow once that variable moves
onto the scope, so the printer stops disambiguating it. It has its own
acceptance case.
The module order lives in one file that both this test and the generator read,
so neither can drift from the other.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): keep only the lint directives that do something
Seventeen of the disables were inert: `typescript/no-non-null-assertion` is
not enabled here, and a directive naming two rules on one line is not parsed
at all, so the one rule that did apply was being ignored too. The changed-code
quality gate reports an inert directive as a finding.
The two that matter are back, one rule per line: the guard-as-expression in
the observer disposal, and the local the document declares and never reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): generate the terminal document from its modules
The WebView document is no longer a hand-written IIFE pasted into a template
string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts`
and the 36 modules under `src/terminal/document/` in document order, strips
their imports, exports and line-scoped lint directives, substitutes the
`document-constants.ts` exports textually, reprints each with esbuild and wraps
the result in one IIFE. `terminal-webview-html.ts` composes the shell, that
generated script and the close fragment. The artifact is gitignored and built by
postinstall, like the two engine artifacts.
The emitted document is token-equivalent to the old one under eight counted
normalisation classes, each pinned as an exact number in
`document/terminal-document-flip.test.ts` against the pre-flip text:
qualifiedReferences 609
scopeFieldDeclarations 73
rebindings 373
bracedBodies 279
unboundCatches 36
numberProperties 17
shorthandProperties 4
unshadowedNames 7
Any other difference fails with the token index and both sides. The second case
pins that the new document adds the scope object and nothing else.
Ruling 17: the behavioural tests now grep the generated document through
`XTERM_HTML`, never a module source, so every assertion still speaks about what
the WebView runs. Every assertion stays and the `expect` count per file is
unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22,
keyboard-avoidance 18, query-reply 14. One control per file was run by deleting
the module line the updated pattern guards; all seven red, and the tree restores
green.
Pattern changes, old -> new.
terminal-webview-scroll-routing.test.ts
var deltaY = ts.lastY - y; -> const deltaY = ts.lastY - y;
smoothScrollOffsetY -= deltaY; -> scope.smoothScrollOffsetY -= deltaY;
var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
-> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);
'touchmove' single-quoted, one line -> "touchmove" double-quoted, printer line break
}, { capture: true, passive: false }); -> { capture: true, passive: false }
function momentumStep() -> let momentumStep = function()
pendingNormalScrollDeltaY += deltaY; -> scope.pendingNormalScrollDeltaY += deltaY;
if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) {
normalScrollFrameId = requestAnimationFrame( -> scope.normalScrollFrameId = requestAnimationFrame(
pendingNormalScrollDeltaY = 0; -> scope.pendingNormalScrollDeltaY = 0;
cancelAnimationFrame(normalScrollFrameId); -> cancelAnimationFrame(scope.normalScrollFrameId);
var writeQueueHead = 0; -> scope.writeQueueHead = 0;
writeQueueHead++; -> scope.writeQueueHead++;
writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);
surface.style.transform = 'translate(' + panX -> scope.surface.style.transform = "translate(" + scope.panX
getVisualPanY() + 'px) scale(' -> getVisualPanY() + "px) scale("
var FRICTION = 0.972; -> const FRICTION = 0.972;
var MIN_VEL = 0.012; -> const MIN_VEL = 0.012;
edgeScrollDir = dir; -> scope.edgeScrollDir = dir;
term.scrollLines(edgeScrollDir); -> scope.term.scrollLines(scope.edgeScrollDir);
// Latching document-level touch dispatcher -> function attachSurfaceEventHandlers(
edgeScrollClientX = clientX; -> scope.edgeScrollClientX = clientX;
edgeScrollClientY = clientY; -> scope.edgeScrollClientY = clientY;
return mode !== 'none'; -> return mode !== "none";
var pixelX = cell.x; -> const pixelX = cell.x;
var pixelY = cell.y; -> const pixelY = cell.y;
...isSafeSgrMouseCoordinate(cell.y)) return -> ...isSafeSgrMouseCoordinate(cell.y)) {
...isSafeSgrMouseCoordinate(sgrRow)) return -> ...isSafeSgrMouseCoordinate(sgrRow)) {
if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress;
if (mouseTrackingMode === 'x10') return sgrPress; -> if (mouseTrackingMode === "x10") { return sgrPress;
if (mouseTrackingMode === 'x10') return press; -> if (mouseTrackingMode === "x10") { return press;
if (col > 126 || row > 126) return ''; -> if (col > 126 || row > 126) { return "";
document.addEventListener('touchend' -> document.addEventListener( "touchend"
}, { capture: true, passive: true }); -> { capture: true, passive: true }
notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...)
document.addEventListener('touchstart' -> document.addEventListener( "touchstart"
var clickInput = buildMouseClickInput -> const clickInput = buildMouseClickInput
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput });
terminal-webview-text-zoom.test.ts
var CLAUDE_STATUS_DOT = -> scope.CLAUDE_STATUS_DOT =
var PRIVATE_MODE_SCAN_TAIL_LIMIT -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT
\n\n function enqueueWrite -> \n function enqueueWrite
var terminalFontFamily = -> scope.terminalFontFamily =
output = terminalFontFamily; -> output = scope.terminalFontFamily;
String.fromCharCode(0x23fa) -> String.fromCharCode(9210)
TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)
EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)
data.replace(CLAUDE_STATUS_DOT_PATTERN, ...) -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR )
writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data))
var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData)
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
statusDotPendingSelector = false -> scope.statusDotPendingSelector = false (x2)
term.open(surface) -> scope.term.open(scope.surface)
term.unicode.activeVersion = '11' -> scope.term.unicode.activeVersion = "11"
enqueueWrite(ESC + '[0m' + replayData) -> enqueueWrite(scope.ESC + "[0m" + replayData)
fontFamily: terminalFontFamily -> fontFamily: scope.terminalFontFamily
fontWeight: '300' -> fontWeight: "300"
fontWeightBold: '500' -> fontWeightBold: "500"
terminal-webview-engine.test.ts
var webglAddon = null; .. var webglRecoveryTimer = null;
-> the refreshTerminalSurface()..init( block, with the scope preamble
window.addEventListener('resize' -> window.addEventListener("resize"
'terminal init failed' -> "terminal init failed"
'terminal message failed' -> "terminal message failed"
var everReady = false; -> scope.everReady = false;
everReady = true; -> scope.everReady = true;
fatal === undefined ? !everReady : !!fatal -> fatal === void 0 ? !scope.everReady : !!fatal
msg.type === 'init' && !everReady -> msg.type === "init" && !scope.everReady
/fatal === undefined \? !ready\b/ -> /fatal === void 0 \? !scope\.ready\b/
if (msg.type === 'ping') -> if (msg.type === "ping")
notify({ type: 'pong', pingId: msg.id }) -> notify({ type: "pong", pingId: msg.id })
terminal-webview-reflow.test.ts
} else if (msg.type === 'reflow') { -> } else if (msg.type === "reflow") { (x2)
var MIN_FIT_COLS = 20; -> scope.MIN_FIT_COLS = 20;
if (cols < MIN_FIT_COLS) return; -> if (cols < scope.MIN_FIT_COLS) {
flog('measure-skip-small-width' -> flog("measure-skip-small-width"
notify({ type: 'measure-result', ... }) -> notify({ type: "measure-result", ... })
var dispatch = { mode: 'idle' -> const dispatch = { mode: "idle"
window.addEventListener('message' -> window.addEventListener("message"
terminal-keyboard-avoidance-webview.test.ts
\n // reflow() -> \n function reflow(
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
\n var panX -> \n scope.panX
TERMINAL_REFLOW_JS fragment import -> the reflow(cols, rows)..notify( slice of the document
terminal-webview-query-reply.test.ts
attachTerminalQueryReplyBridge(term, gen) -> attachTerminalQueryReplyBridge(scope.term, gen) (x2)
term.attachCustomKeyEventHandler(function() { return false; })
-> term.attachCustomKeyEventHandler(function() { \n return false; \n });
term.textarea.readOnly = true -> term.textarea.readOnly = true;
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
terminal-webview-url-tap.test.ts
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the
generated document's digest, 730472 -> 723480 bytes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): delete the slice constants and injected fragments
The document is generated from its modules now, so the strings it used to be
pasted together from are dead. Deleted: the fourteen slice constants under
`terminal-webview-html/` (host-message-router, message-bridge,
mouse-mode-decset-scan, mouse-report-and-scroll-routing, runtime-constants,
runtime-state-and-text-scaling, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-touch-gestures,
term-observers-and-mode-mirroring, terminal-fit-scale, terminal-init-and-write,
write-queue) and the eleven `*-injected.ts` files. `document-shell.ts`,
`document-close.ts` and `theme.ts` stay: the shell and close are still the
document's HTML, and `theme.ts` is where `document-constants.ts` reads the
palette from.
Ruling 17, second commit. Tests that asserted the extraction mechanism itself
went with it: they compared one module's emission against the slice text it was
extracted from, and the flip test now pins the whole document against the whole
pre-flip script with the same eight classes. Deleted, all under `document/`:
fit-scale, host-message-router, keyboard-avoidance-metrics, message-bridge,
mouse-click-drag, mouse-mode-decset-scan, mouse-report-and-scroll-routing,
mouse-report-cell, path-tap, query-reply, reflow, runtime-constants,
runtime-state, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-swap, surface-touch-gestures,
tap-dispatch, term-observers, terminal-init, terminal-theme, webgl-recovery,
wheel-scroll. `document/url-tap.test.ts` stays: it pins against
`URL_TAP_WEBVIEW_JS`, which is neither a slice constant nor an injected file and
still has a consumer.
Tests that asserted behaviour through a deleted string now read the generated
document. `document/generated-document-region.test-support.ts` is the one way in:
`documentScopePreamble()` returns the scope object the document opens with, and
`generatedDocumentModule(name)` re-emits a module and refuses unless the document
carries that text verbatim, so an evaluated block is the WebView's own bytes. The
two local copies of the preamble in the engine and text-zoom tests were folded
into it.
Moved, with every assertion kept and the `expect` count per file unchanged:
terminal-webview-html/write-queue.test.ts -> document/write-queue.test.ts 34
terminal-webview-theme-injected.test.ts -> terminal-webview-theme.test.ts 14
terminal-webview-query-reply.test.ts 14
terminal-path-tap.test.ts 25
terminal-webview-url-tap.test.ts 33
terminal-keyboard-avoidance-webview.test.ts 18
terminal-webview-reflow.test.ts 22
terminal-webview-text-zoom.test.ts 59
terminal-webview-engine.test.ts 49
Pattern changes, old -> new.
terminal-webview-reflow.test.ts
if (!term || isAlternateBufferActive()) return;
-> if (!scope.term || isAlternateBufferActive()) {
term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows);
var wasAtBottom = buffer.viewportY >= buffer.baseY;
-> const wasAtBottom = buffer.viewportY >= buffer.baseY;
term.scrollToBottom(); -> scope.term.scrollToBottom();
if (nextCols === term.cols && nextRows === term.rows) return;
-> if (nextCols === scope.term.cols && nextRows === scope.term.rows) {
The other eight files kept their patterns; only the text they read changed, from
a deleted constant to the document block. The harnesses that evaluate a block now
build the document's scope object instead of declaring the vars it replaced, and
hand the terminal in as `scope.term`.
Controls, one per file: the module line an updated pattern guards was removed,
the document rebuilt, and the test run. All red, and the tree restores green.
query-reply terminalDataRepliesEnabled = true -> query-reply test, 2 failed
path-tap const parsed = parsePathLineCol(...) -> path-tap test, red
keyboard-avoidance-metrics contentBottomRow -> keyboard-avoidance test, 4 failed
reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
webgl-recovery new window.WebglAddon.WebglAddon() -> engine and text-zoom tests, 4 failed
osc-link-tap return parsePathLineCol(value) -> url-tap test, 1 failed
terminal-theme scope.term.options.minimumContrastRatio = ...
-> theme test, 4 failed
write-queue scope.writeQueue[scope.writeQueueHead] = undefined
-> write-queue test, 4 failed
`document-scope.ts` docstrings named the slice each field belonged to; they name
the owning module now. Three module comments pointed at deleted injected files
and point at the modules instead. Neither changes the document: esbuild drops
comments, and the byte pin is unmoved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name the right number of counted classes
The flip test's title still said seven; the table it asserts has eight.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): name the shape applyTerminalTheme writes through
The anti-slop gate refused `loadThemeApplier(term: object)` in the theme test.
`applyTerminalTheme` touches exactly two slots on the terminal it is handed, so
`terminal-theme.ts` now exports that shape as `TerminalDocumentThemeTarget` and
the test's parameter and both fixtures use it. The theme is optional on the way
in because `applyTerminalTheme` is what writes it.
No cast. The type is erased by the generator's transform, so the document is
unchanged and the flip test's class table and the byte pin both still hold.
Control: restoring the `object` parameter reproduces the finding at
terminal-webview-theme.test.ts:35:33 and the gate exits 1; with the named type
it exits 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): retire the flip pin, leaving the byte golden as the document's fence
`terminal-document-flip.test.ts` compared the emitted modules against
`terminal-document-pre-flip-script.txt`, the hand-written script as it stood before
C7.1, and held exactly while no module changed. That is the proof of the flip, not a
standing fence: the first lane that must change a module has to retire it or restate
its counted classes for a reason that has nothing to do with the move.
C7.5 is that lane — the document's host seams become scope fields so the page can set
them — so both go here, while the test is still green. The flip proof lives at
51ae7b1b03 ("test(mobile): name the right number of counted classes"), which is where
anyone reviewing the move should read it.
From here the standing pin is the whole-document byte golden,
`terminal-document-golden.txt`, checked by `terminal-document-identity.test.ts` and by
the payload-hash digest beside it. Regenerating it is a review event: the emitted diff
is listed old to new in the commit message and in the PR body, and a golden that moves
without a listed diff is a blocking finding.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): give the terminal document's host seams a field on its scope
Ruling 19: on the page `window.ReactNativeWebView` is the *shell's* bridge, so a
terminal `notify` through it would post raw terminal JSON into the bridge's channel,
and there is no engine IIFE hanging `Terminal` and the two addons off `window` because
the page imports xterm. Four reads had to become seams:
host-notify.ts notify() -> scope.postToHost
viewport-transform flog() -> scope.postToHost
terminal-init.ts new Terminal(...) -> scope.createTerminal
terminal-init.ts window.Unicode11Addon-> scope.createUnicode11Addon
webgl-recovery.ts window.WebglAddon -> scope.createWebglAddon
Each default is the window read the site already did, still performed at call time and
not captured when the scope is built, so inside the WebView the program is the one it
was. `document-host-seams.ts` holds the four and is emitted ahead of the scope object,
because the scope's defaults are those functions and the factory runs as the script is
parsed. `document-terminal-shape.ts` takes the xterm-shape types out of the scope's
file, which the four fields pushed over the 300-line cap; document-scope re-exports
them, so no importer moves. The page's side of the seam lands in C7.5's later commits.
Two shapes kept faithful rather than tidied. The unicode11 addon is still built inside
the `try` it was built in, so a constructor that throws is still swallowed; and no
WebGL addon still returns false from `attachWebglAddon` without reaching the `catch`,
which is the DOM-renderer fallback rather than a failure.
Golden regenerated: terminal-document-golden.txt 105,446 -> 105,968 bytes, document
723,480 -> 724,002. 20 lines out, 36 in, all at the five sites above and nowhere else:
+ (new, top of the IIFE) function postToReactNativeWebView(message) { if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(message)); } }
+ (new) function createEngineTerminal(options) { return new Terminal(options); }
+ (new) function createEngineUnicode11Addon() { return window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon ? new window.Unicode11Addon.Unicode11Addon() : null; }
+ (new) function createEngineWebglAddon() { return window.WebglAddon && window.WebglAddon.WebglAddon ? new window.WebglAddon.WebglAddon() : null; }
- " pendingTerm: null"
+ " pendingTerm: null," and four fields: postToHost: postToReactNativeWebView, createTerminal: createEngineTerminal, createUnicode11Addon: createEngineUnicode11Addon, createWebglAddon: createEngineWebglAddon
- flog's nine lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify({ type: "log", tag: "[fit]" + tag, payload })); }"
+ flog's five lines "scope.postToHost({ type: "log", tag: "[fit]" + tag, payload });"
- " if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) {"
+ " if (!scope.term) {"
- " addon = new window.WebglAddon.WebglAddon();"
+ " addon = scope.createWebglAddon();" then " if (!addon) {" / " return false;" / " }"
- " scope.term = new Terminal({"
+ " scope.term = scope.createTerminal({"
- " if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {" / " try {" / " scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon());" / " } catch {"
+ " try {" / " const unicodeAddon = scope.createUnicode11Addon();" / " if (unicodeAddon) {" / " scope.term.loadAddon(unicodeAddon);" / " } catch {"
- notify's three lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(msg)); }"
+ " scope.postToHost(msg);"
Nothing else in the document moved: the emitted indentation, statement order and every
other literal are byte for byte what they were.
Two pinned readers follow the move. `terminal-webview-payload-hash.test.ts` takes the
new length and digest. `terminal-webview-text-zoom.test.ts` kept both WebGL assertions
and aimed them where the text now is: `window.WebglAddon.WebglAddon` and
`new window.WebglAddon.WebglAddon()` are asserted on the scope preamble rather than on
the recovery module, and the recovery module is asserted to call
`scope.createWebglAddon()`. `host-seams.test.ts` is the new pin: it builds a scope
before the globals exist to show the defaults read the window when they post, shows
each addon factory answering null when the engine has none, and drives a host message
in and a notify out with all four fields set, asserting the bridge is never touched.
Red before this commit at 6 of 7 cases.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* build(mobile): write the xterm stylesheet as its own generated artifact
The page mounts xterm itself, so it needs the engine's stylesheet and must never
resolve the engine string: 612 KiB of minified IIFE built to be injected as text into
a WebView document, unusable under the shell's `script-src 'self'` with no nested
frame to load one into, and the largest single module the session route's closure
would carry. Both lived in `terminal-webview-engine.generated.ts`, so one import of
the CSS pulled the string in behind it.
`build-terminal-webview-engine.mjs` now writes `terminal-webview-engine-css.generated.ts`
beside it from the same read of `@xterm/xterm/css/xterm.css`, with the same comment
strip and the same `http%3A//` scrub the no-external-URL gate wants. Gitignored beside
its neighbour and written by the same postinstall step, so a fresh tree gets both or
neither. `document-shell.ts` takes the CSS from the new module and the engine string
from the old one; `build-terminal-document-fixture.mjs` and the two tests that hold
both constants read them from their new homes.
The document did not move: `terminal-document-golden.txt` is byte for byte what the
last commit left, 105,968 bytes, and the payload digest is unchanged.
The fence is `config/scripts/mobile-web-terminal-engine-closure.test.mjs`. It walks
every module under `src/terminal/document/` as an entry point — the document is one
script whose modules reach each other by side effect, so no single one of them roots
a graph holding the rest — and asserts the engine string is in none of their closures,
with two modules named as the precondition that the walk resolved anything at all. The
native document's own closure is asserted to still hold both generated modules, so the
first case cannot pass by the CSS having gone missing. And the third case plants a
document module that imports the engine string in a scratch tree and shows the walk
reports it, which is what makes the absence above a measurement.
`mobileWebAppRouteClosure` is now a caller of `mobileWebAppEntryClosure`, which takes
the entry points and an optional working directory; the route closure's own two entry
points and its extensionless-specifier reason are unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the dead URL-tap constant and two stale reflow guards
Round 1 fixes, all three folded here.
1. `URL_TAP_WEBVIEW_JS` is gone from terminal-webview-url-tap.ts, with
`document/url-tap.test.ts` deleted alongside it. The document is generated
from its modules now, so that constant was a second copy of the URL-tap group
with no consumer but its own tests. terminal-webview-url-tap.test.ts's
resolver harness reads the document's own text instead, the path-tap,
url-tap, osc-link-tap and surface-tap modules in document order through
`generatedDocumentModule`, which refuses unless the document carries each
verbatim. Its 33 expects all stay. One mechanism-only assertion went with the
file: `document/url-tap.test.ts`'s single `compareTerminalDocumentScripts`
pin of the three emissions against the constant, which the flip test's
whole-document pin already covers. The file's other exports stay.
The deletion surfaced a third reader. terminal-webview-scroll-routing.test.ts
concatenated terminal-webview-url-tap.ts into its `source`, and its
`notify({ type: 'terminal-tap' });` assertion was matching the constant's
single-quoted text, not the document. The read is dropped, since nothing else
in that file needed it, and the assertion is the document's form:
notify({ type: 'terminal-tap' }); -> notify({ type: "terminal-tap" });
Its 95 expects stay. Leaving the read in place would let a document assertion
pass against a module source, which is the hazard this lane exists to remove.
2. terminal-webview-reflow.test.ts guarded a template placeholder that no longer
exists, so it could not fail:
expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}')
-> expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1)
Same intent against the generated document: the reflow module's emitted text
is in the document exactly once. The case is renamed to say so and the
comment above it describes the generator, not the deleted template.
3. Same file, the routine assertion still passed as a substring of the qualified
call; qualified as line 30 already was:
term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows);
Its 22 expects stay.
Controls, each verified to have changed the file first, all red, tree green
after restore:
osc-link-tap return parsePathLineCol(value) -> url-tap test, 3 failed
surface-tap notify({ type: 'terminal-tap' }) -> scroll-routing, 1 failed
reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
module order 'reflow' listed twice -> reflow test, expected 2 to be 1
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): mount the terminal document in the page instead of a WebView
`react-native-webview` has no web build that renders anything: measured, it paints the
line "React Native WebView does not support this platform" where the terminal was. So
the page mounts the document itself — xterm imported from `@xterm/xterm` with the
unicode11 and webgl addons, and the document's own modules imported in the order the
generator emits them — behind the identical `TerminalWebViewProps` and
`TerminalWebViewHandle`.
Written as one implementation, not two. `use-terminal-webview-controller.ts` is
everything `TerminalWebView.tsx` did that was not about `react-native-webview`: the
readiness handshake, the pending queue, the write coalescer, the notify dispatch and
the whole imperative handle. Its two arguments are the difference between the hosts —
a sink that takes one `TerminalWebViewCommand`, and whether a foreground return has to
re-prove the document with a ping. The native component posts across the bridge and
answers yes on iOS; the web component calls `handleMsg` and answers no, because its
document is the page's own modules and there is no second content process to lose. A
second copy of that file is the fork the series exists to avoid, since the handle is
the contract every consumer holds.
`terminal-webview-ready-promises.ts` carries the two promises the handle hands out,
`awaitReady` and `measureFitDimensions`, which the controller's length made a module.
`document-style.ts` and `document-markup.ts` carry the stylesheet and the elements out
of the document shell; the shell composes them and the golden is byte for byte
unchanged, 105,968 bytes. `terminal-webview-html.web.ts` answers those two and the
caret options and nothing else, so the page resolves no document string and no engine
string.
`terminal-web-document-mount.ts` is what the WebView's HTML used to be: it plants the
stylesheet and the markup, sets the four scope seams, and reaches the modules by one
dynamic import — they read their elements as they are parsed, so a static import would
hoist above the planting and leave every one of them holding null.
`page-document-modules.ts` is the order, `message-bridge` excluded per ruling 19
because on the page those `message` frames belong to the shell; its one non-bridge
duty, the window-resize refit, is re-armed by the mount.
`page-document-module-order.test.ts` holds that list against the generator's own,
so a sorted import list or a module added on one side cannot pass.
Two page-side degradations, both bounded and both stated. The document assigns
`window.onerror` as it is parsed, so while a terminal is mounted page errors reach its
reporter; the mount restores the previous handler on dispose. And a browser that
refuses a WebGL context gets the DOM renderer, which is the fallback `webgl-recovery`
already has for a context loss, with a `[fit]webgl-unavailable` notify saying so
rather than a silent halving of the drain rate.
`terminal-webview-consumer-census.test.ts` is the pin the substitution rests on: it
scans `src/session` and the terminal directory for an import of the component file by
name, of `terminal-webview-html`, of either generated engine module or of anything
under `document/`, finds none outside the component and its mount, and shows on
planted text that it would report each. `mobile-web-terminal-engine-closure.test.mjs`
gains the component's own closure: `TerminalWebView.web.tsx` and
`terminal-webview-html.web.ts` are in it, the engine string, the native HTML module
and `message-bridge` are not.
Four source greps follow the code into its new home, every assertion kept:
`terminal-write-coalescer-boundaries` reads the coalescer's four boundaries in the
controller, and reads the two lifecycle clears once in `resetReadiness` plus both
WebView callers in the component; `terminal-webview-reflow` and
`terminal-webview-scroll-routing` read the handle in the controller and the two timers
in the promises module (`measureResolveRef.current === finish` -> `measureResolve ===
finish`, `void p.finally` -> `void pending.finally`).
One behaviour was nearly lost and is pinned by an existing case: the native
foreground-recovery ping reads `Platform.OS` at the moment of recovery, not at render,
so the transport asks a predicate rather than carrying a boolean.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): render the page's terminal in a browser under the shell's policy
Everything below the contract is new on the page: xterm is an import rather than a
612 KiB string in a WebView document, the document's modules run in the page's own
realm, and the elements they read by id are planted by the component. No module test
settles whether that opens at all under `script-src 'self'` with neither
`unsafe-inline` nor `unsafe-eval`, or whether a real terminal byte stream reaches the
buffer intact.
Three cases in the C6 render harness, against the bundle built by the real builder and
served under the policy parsed out of the shell's own Kotlin constant.
The stream is built for the grid rather than committed: an SGR colour change per cell,
an erase-to-end and an absolute cursor position per row, run out past the host's own
48 KiB chunk. 49,302 bytes applied through `handle.write`. It is read back through the
document's own path — select all, then the Copy button the overlay carries — so the
oracle is the component's `onSelectionCopy` prop and not a private reach into xterm:
6,133 characters, both edge markers present, and no escape byte or SGR text left in
them, which is what says the parser consumed the stream instead of printing it.
The second case takes a fit through the handle, which on the page is a command in and
a notify back with no bridge between, and carries design §8's cheap half of the IME
question. It first pins something that changes where that probe can even point:
xterm's own textarea is inert by the document's design — `query-reply.ts` makes it
read-only, untabbable and `inputmode=none` so touch and hardware keys go to the
screen's input — so text entering a terminal on the page arrives at a `TextInput`, and
that is what is typed into. Chrome reports `insertText` with `isComposing` false for
each character, logged as `[c7.5][beforeinput]`. A composing IME on a real soft
keyboard is the device step and this does not claim to answer it.
CSP violations are counted with a `securitypolicyviolation` listener installed before
anything else runs, which is stricter than the console-error filter the other render
checks use — and the first thing it found was not the terminal's. The page entry
carries Zod, whose `new Function` probe is swallowed by its own catch, so
`script-src: eval` is refused once on any page route with no page error and no console
line. The first case is the control that names it, on a route that mounts a marker and
no terminal; the two terminal cases subtract it and report zero of their own. Zero
page errors and zero console errors besides.
No route serves this screen until C7.7, so the component is bundled through a scratch
route tree, naming it extensionlessly so the bundler resolves `TerminalWebView.web.tsx`
exactly as a real route would. That step retires when the session route is registered.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): retire the last module concatenator and guard the order list
Round 2 fixes, all five folded here.
1. Deleted terminal-webview-html-source.test-support.ts.
`readTerminalWebViewHtmlSource()` had no consumers left once the behavioural
tests moved to the generated document, and it was the last thing that built a
document-shaped string by concatenating module sources — its filter admitted
`.test-support.ts` files too, so it could have grown one. Confirmed by grep
that the only occurrence of either name in the repository was its own
declaration.
2. New document-module-order.test.ts asserts both directions: the non-test,
non-test-support `.ts` files under `document/` are exactly
`{document-scope} + TERMINAL_DOCUMENT_MODULE_ORDER + {document-constants}`,
and no name is listed twice. `document-constants` is the one exception
because it is never emitted: its exports are substituted into the modules
that import them as literals, so the document carries its values without
carrying the module. A module added here and forgotten there would be dead
code that reads as live; a name left after its file goes makes the generator
throw at build time rather than at review time.
3. terminal-document-flip.test.ts's docstring now carries the retirement policy
from ruling 18: the test is the proof of the flip and holds only while no
module changes, the first lane that must change one retires it together with
`terminal-document-pre-flip-script.txt`, and the standing pin from then on is
`terminal-document-identity.test.ts`, whose fixture regeneration is a review
event. Comment only.
4. terminal-document-equivalence.test-support.ts said 57 reassigned variables
and "Four classes and no others". It now says 73 declaration sites and eight
classes, with each class's measured figure named. Two doc comments sat above
the wrong declaration and were moved onto what they describe: the
`NUMBER_GLOBALS` one down to that constant, and the printing one down to
`significantTokens`, with `STRICT_DIRECTIVE` given its own line.
5. build-terminal-document-script.mjs substituted constants with
`replaceAll(regexp, literal)`, where `$&`, `` $` ``, `$'` and `$n` in a
constant's value are read as replacement patterns. The substitution is now
`substituteDocumentConstants`, exported so it can be tested directly, and
replaces with a function.
Controls, each verified to have changed its input first, all red, tree green
after restore:
plant document/zz-planted-module.ts -> order guard, "+ zz-planted-module"
drop 'wheel-scroll' from the order -> order guard, "+ wheel-scroll"
revert to the string replacer -> 4 failed, "a $& b" became "a marker b"
The `$n` case is deliberately absent from that table: the pattern has no capture
group, so `$1` is already literal under either form and a case for it could not
tell them apart.
The document did not move. The byte golden, the digest and the flip test's class
table are all unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): measure what the page terminal costs the session route's closure
The session route is not served on the page until C7.7, but the closure the bundler
would walk is the same one and the terminal is the largest thing in it. Measured
against this branch's base, `ota-c7-1-terminal-document` at 51ae7b1b03:
modules 4316 -> 4363 (+47)
local modules 927 -> 971 (+44)
minified bytes 3,930,787 -> 3,883,532 (-47,255)
The route gets smaller. It sheds six modules — the native component, the 612 KiB
engine string, the 105 KiB generated document script, the HTML module and the shell
and close around it — all string literals of a program the page cannot run, and gains
fifty: the component, its mount, the stylesheet and markup modules, the two the
controller split made, and the document's own thirty-nine, with xterm and the two
addons behind them at 607,945 bytes minified ESM on their own. `document-terminal-shape.ts`
is not among them: it declares types and esbuild emits nothing for it.
The census pins the trade in both directions, because "the engine string is absent"
passes just as well on a closure that resolved nothing: the six shed modules are
asserted gone, the eight gained ones and the three xterm packages asserted present,
and the document asserted whole except `message-bridge`, which ruling 19 keeps off the
page. It also holds the 16 px seam where C7.2 found it — nine offenders, no unresolved
styles — since the terminal's modules joining this closure is exactly the change that
could add a tenth unread.
The page-closure families were run before and after on the full corpus, never a
filtered scenarios file. Both sides: 7 files, 879 tests, exit 0 — and those 879
include the four page-closure pins, which assert the verdict of every golden C1, C2,
C3 and C5 record, so an unchanged run is an unchanged verdict table rather than an
unmeasured one. Per family with `vitest -t "session.terminal"`, both sides 19 passed
and 773 skipped. No family moved, which is what an inert lane should show: this
branch changes no RPC, no opcode, no grant and nothing the recorder reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): clear the changed-code gate findings this lane introduced
Eleven findings from `check-changed-code-quality.mjs` against the base, all in code
this lane added, none of them a behaviour change.
Two type assertions lost their directive to the formatter. The xterm `Terminal` cast
sits on the second line of a wrapped arrow body, so a directive above the assignment
aims at the wrong line; it moves onto the line the assertion is on. The WebGL addon
cast had no directive at all. Both keep the same `SAFETY:` rationale on one line,
which is the only shape oxlint reads.
Two more assertions in `host-seams.test.ts` are gone rather than annotated. The
terminal double's `element` is a getter over a local the double's own `open` writes,
and `withSeams` reads each field it is about to overwrite through
`getOwnPropertyDescriptor` instead of indexing the scope with a cast.
Then three `eslint-disable no-console` directives that disabled nothing, an
`oxlint-disable` for `react-hooks/exhaustive-deps` that the rule never fired on — the
reason it carried stays as a comment, since the dependency list is still deliberate —
and one duplicated `node:fs/promises` import.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(config): name the closure helper what main already named it
A trial merge against `origin/main` conflicts on this function: main grew the same
generalisation independently, as `mobileWebAppModuleClosure(entryModules)` with
`mobileWebAppRouteClosure` delegating to it and three callers in the page-closure
families census. This branch is based on `ota-c7-1-terminal-document` and so cannot
merge main, but it can stop being a second spelling of the same thing.
Taken over wholesale: main's name, its parameter, its extension stripping and its
comment, with `mobileWebAppRouteClosure` reduced to the one-line delegation main
already has. The only addition is an options bag carrying `absWorkingDir`, which the
engine-closure census needs to plant a module in a tree of its own and show the walk
would report it; the real measurements never pass it. What was a whole-function
conflict is now that one hunk.
The census case that measured the native document had named
`terminal-webview-html.ts` with its extension, which main's stripping does not allow.
It names `terminal-webview-html/document-shell` instead — the module that actually
reads both generated ones — which is the better probe anyway and needs no extension
to resolve, since it has no `.web` sibling.
`web-overrides.json` also conflicts and is left alone: both sides append entries to
one list and the resolution is mechanical.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(config): put the two closure helpers in main's order
The previous commit took main's name and signature but left the route closure below
the module closure, where this branch had written it. Git merged both orderings and
produced two copies of `mobileWebAppRouteClosure` on the merged tree, which oxlint
reports as a duplicated export — a red the trial merge found and neither side's own
lint could.
Same order as main now: the route closure and its docstring first, the module closure
under it. The trial merge is down to one hunk, the `absWorkingDir` parameter, and the
merged tree lints clean.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): make the flip comparator refuse what it was accepting
Round 2 items 6 and 7, both in the equivalence instrument.
6. `isPrinterDisambiguation` accepted any `name2` facing `name` without proving
the two were the same binding, so an unrelated rename ending in a digit would
have been counted rather than refused. It is replaced by `UNSHADOWED_RENAMES`,
an explicit list of pre-flip name, generated name and declaring module. The
whole script has one entry: `term2` -> `term` in `query-reply`, which is the
`term` parameter of `attachTerminalQueryReplyBridge` and its six uses, seven
sites in all. That is stated in the docstring rather than encoded as a second
pin, since the flip test already pins the total.
7. Brace absorption treated every unexpected `{` as a linter-added body and
absorbed any later `}` while one was outstanding, so a bare block anywhere
would have been swallowed. `isBraceableHeadBody` now requires the open to be
the body of `if`, `for`, `while`, `else` or `do` — walking a `)` back to its
`(` and reading the keyword before it — and `matchingCloseIndex` records the
index the close must appear at, so the absorbed `}` is that body's own.
That check had to move ahead of the equality check. Wherever a braced body
ends a block, the baseline's next token is a `}` as well, so pairing them
would consume the wrong one and leave the counts right for the wrong reason.
Both refusals are tested over snippets:
function f() { return value2; } vs return value;
-> token 6: expected name value2, generated name value
let value = 1; use(value); vs { let value = 1; } use(value);
-> token 0: expected name let, generated {
and the braceable heads are tested one by one, `if`, `for`, `while`,
`if`/`else` and `do`, so the new rule is shown to accept every shape the `curly`
rule produces and not only the one the document happens to exercise.
Controls: restoring the shape rule fails the first refusal case and nothing
else; restoring the accept-any-brace rule fails the second and nothing else.
The eight counts did not move: 609, 73, 373, 279, 36, 17, 4, 7.
Splitting out `terminal-document-tokens.test-support.ts` is not cosmetic. The
tightened rules put the file over the 300-line cap, and a `max-lines` disable is
forbidden, so the token reader moved to its own module: that side answers what a
script says, and says nothing about which differences between two of them are
allowed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(config): take main's docstrings for the two closure helpers
The order matched but the prose did not, so the trial merge still conflicted on the
whole block. Both docstrings are now main's own text, with one sentence trimmed: main
names `MobileBrowserPane` as the first component with a pin of its own, which is C6's
fact and not one this branch can assert.
What remains between this branch and main in this file is the `absWorkingDir`
parameter, which is what the engine-closure census plants a module with.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): write the page terminal's notify sink in an effect, not during render
React Doctor's one error on this branch, and a real one: `receiveRef.current = receive`
ran during render. React may replay or discard render work, so a mutation made there
can leak from UI that never commits — and this ref is read from a callback the mounted
document keeps, which outlives the render that installed it.
Moved into its own effect, declared above the mount effect so the first read already
sees a sink. `check-react-doctor-changed.mjs` goes from exit 1 to exit 0.
Found late because the first run of that gate was read through `| tail`, which reports
the pipeline's last command rather than the gate's own exit code.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): teach C7.1's order guard the three modules this lane added
The guard C7.1 landed says the document directory and the order list name the same
modules. On this branch three files are in that directory and not in that list, so it
was red on the merge — which is the guard working, and the fix is to name each of them
with its reason rather than to loosen the scan.
document-host-seams emitted, but ahead of the scope rather than inside the order
list, because the scope's defaults are its four functions and
the factory runs as the script is parsed
document-terminal-shape types only; esbuild emits nothing and an empty emission
would add a blank line to the document
page-document-modules the page's entry, not the WebView's, holding the same order
for a host that has no generator to splice them
Named one by one, not filtered by a pattern, so a fourth cannot join them by looking
similar. A third case asserts the seams module is neither in the order list nor the
scope module, which is the ordering the first two cannot see.
Red before this commit: C7.1's version of the file on this tree reports
`document-host-seams` and the other two as directory modules the list does not name.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): re-measure the session closure against the merged C7.1 base
Same module counts — 4316 -> 4363 and 927 -> 971 local — but the minified figure moved
from -47,255 to -55,561, and the 8,306-byte difference is C7.1's rather than this
lane's. Its round-1 fold deleted `URL_TAP_WEBVIEW_JS` from `terminal-webview-url-tap.ts`,
a module that enters this closure only once the page's component reaches it, so the
saving shows on the after side and cannot show on the base. Both readings are recorded
with the commit each was taken against, because a number with one base named and
another used is the kind of thing a reviewer cannot check.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): retire the flip comparator with the pin it was built for
The token comparator had exactly two consumers and neither survives. `document/url-tap.test.ts`
went in C7.1's own round-1 fold at 8da7680c9b, and `terminal-document-flip.test.ts`
went in this lane's first commit under ruling 18, because the flip pin holds only
while no module changes and C7.5 is the lane that changes them. What was left was a
tool, its token reader and a test of the tool, answering to nothing.
So `terminal-document-equivalence.test-support.ts`, the
`terminal-document-tokens.test-support.ts` C7.1 split out of it, and
`terminal-document-equivalence.test.ts` all go. That closes round 3's two LOW notes on
the comparator — bounding an absorbed body to one statement, and refusing a bare block
as `use();` against `{ use(); }` — since there is no comparator left to tighten. The
standing pin on the document is the whole-document byte golden, which is a stronger
claim than token equivalence ever was: it admits no normalisation at all.
`document-module-order.test.ts` gains the case its exception list was asserting in
prose. `document-terminal-shape` is not in the order list because esbuild erases a
module of type declarations to the empty string, and emitting it would put a blank
line in the document rather than a program; that emission is now measured and pinned
as `''`. If the module ever declares a value the case goes red and the module belongs
in the order list with its own line in the golden diff.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): make the document's error reporter the sixth host seam
Ruling 19 reaches `window.onerror`. The document assigned it as it was parsed, which
inside the WebView is taking nothing from anyone — that document owns its page — and
on the page is a guest displacing whatever the host installed. Restoring it on dispose
was a patch over the takeover, not an answer to it: while a terminal was mounted, every
page error still went to the terminal's reporter.
So `scope.installErrorReporter` joins the five, with today's assignment as its default.
`host-notify` hands it the same handler it always installed, and the WebView's document
is the program it was.
The page supplies its own: an `error` listener that adapts the event to the reporter's
arguments, added on mount and removed on dispose, and `window.onerror` is never
written. This one seam is *called* as the modules are parsed rather than later, so the
mount now reaches `document-scope` on its own first and sets every field before a
single document module runs — which is also the safer order for the other five.
Golden regenerated: 105,968 -> 106,116 bytes, document 724,002 -> 724,150. Three lines
out, seven in, and nowhere else:
+ (new, beside the other defaults) function installWindowErrorReporter(report) { window.onerror = report; }
- " createWebglAddon: createEngineWebglAddon"
+ " createWebglAddon: createEngineWebglAddon," and " installErrorReporter: installWindowErrorReporter"
- " window.onerror = function(msg, source, line, column, err) {"
+ " scope.installErrorReporter(function(msg, source, line, column, err) {"
- " };"
+ " });"
`terminal-webview-payload-hash.test.ts` takes the new length and digest.
Pinned on both sides. `host-seams.test.ts` gains the default taking `window.onerror`
and a host that installs its reporter elsewhere leaving it null. The render check adds
a browser case: `window.onerror` is null before the mount, null after it, and null
after the component unmounts — with a real uncaught error thrown in between and
asserted to reach `onEngineError`, so the first reading cannot pass on a terminal that
had simply stopped reporting, and a second error after dispose asserted to reach
nothing. Red with the mount's override removed: `expected undefined to be null`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): empty the session closure's react-native-webview list
C7.6's census on main names the terminal as the last consumer and says whose work it
is: "The terminal is the third and is C7.5's, which drops the engine string and mounts
xterm in the document". This is that lane, so the list it left is now empty and the
session closure reaches `react-native-webview` from nothing at all.
Emptying a list weakens the case that reads it, because an empty result is also what a
scan that read no file reports, so two things change with it. The main case gains its
preconditions: the walk read a closure of more than 500 local modules, and it read the
three web siblings whose native halves are exactly the modules that would have
imported the package. And the control stops walking the list — with the list empty that
compared nothing against nothing — and walks the three native files instead, which do
import it, alongside the three web siblings, which do not.
`TerminalWebView.web.tsx` joins the answered list, so the case that the builder
resolves a web sibling rather than its native file now covers all three.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): pin the onerror seam against a handler the page actually owns
The case read `null` before the mount, while mounted and after dispose. That is true
but weak: a terminal that assigned `null` over a real handler would pass it, which is
exactly the takeover ruling 19 forbids.
So the page now installs a handler of its own in an init script, before the bundle
loads, and the assertion is identity — `window.onerror === globalThis.__orcaSentinel`,
compared inside the page because a function does not survive `evaluate` — at all three
points. Between them an uncaught error is thrown and both reporters are asserted to
see it: the page keeps the handler it installed, and the terminal's own listener still
works, so the readings cannot pass on a terminal that had simply stopped reporting.
After dispose a second error reaches the page's handler and not the terminal's, which
is what taking the listener off has to mean.
The `null` reading stays as its own case, because the other half matters too: on a page
that installed nothing the terminal must not leave a handler behind for the next
consumer to find.
Both go red with the mount's `installErrorReporter` override removed — `expected false
to be true` and `expected undefined to be null`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): start the terminal document per mount (ruling 20)
Round 1's blocking finding: ES module bodies run once per page, so the page's
second mount re-imported nothing and inherited the first mount's elements,
listeners and error reporter. Measured after a remount: zero .xterm nodes in
the live DOM, no selection overlay, nothing reaching onEngineError, and
onWebReady still firing.
Ruling 20: no emitted module does work as it is parsed. Every top-level effect
moved into an exported per-module start function — 86 statements across 14
modules, plus three parse-time captures whose declarations became typed lets.
The generator emits one call sequence in module order at the foot of the
document, so the native script still runs them once at parse; the page runs the
same sequence per mount and dispose undoes the three that outlive the host
element (tap-dispatch, webgl-recovery, host-notify).
installErrorReporter now hands back its own undo, so it stays five seams at six
document sites rather than growing a sixth.
M2: a failed document chunk was an unhandled rejection with no engine error.
It now goes down the document's own reporting path, so the overlay names the
cause instead of the 15s readiness watchdog. Pinned by refusing that chunk at
the wire in the render check.
L3: the seam count now reads five fields / six sites / three files everywhere.
L4: three unrelated web-overrides entries keep main's escaping.
Golden: 106116 -> 108134 bytes; payload 724150 -> 726168, sha256
2d089b8d9ab9491eed79cf7fe353dde6444799a3d297269ab660aee63ba56c82.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the parse-time census tree without assertions
The changed-code gate refuses type assertions. The walker reached node fields
through `as Record<string, unknown>`; it now reads them with Object.entries,
which is checked and says the same thing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): move the document's state onto the scope (ruling 21)
Round 2's blocking finding, and ruling 20's second half: moving parse-time
effects out of the module bodies left the state behind. Nine module-level
bindings survived a mount, so the second terminal inherited a spent non-fatal
error budget (reporting nothing however it failed), the first terminal as its
committed surface (disposing it twice), and the first mount's momentum loop.
Every mutable binding now lives on the scope, and the scope carries one reset
the start sequence calls first: native once at parse, the page once per mount.
Moved, by module: query-reply 1, surface-swap 3, text-scaling 2, fit-scale 1,
host-notify 2, selection-state-and-eviction 1, mouse-click-drag 1,
tap-dispatch 1, surface-touch-gestures 1 — thirteen fields, two of them the
objects tap-dispatch and surface-touch-gestures used to own outright.
Because the reset is now the one initialiser, the start functions keep only
what it cannot do: element reads, listener installs and the reporter install.
Four start functions emptied and went; terminal-handle held nothing else and
is deleted from the order list. The scope type splits into state and host
seams, because a reset must restore the first and never the second.
Every stop function cancels what its module scheduled. Timers go back through
the handles the scope already held; frames go through the scope's own
scheduleDocumentFrame, so dispose can take back the ones no module tracks by
id. terminalGeneration and fitRetryToken carry forward across a reset, because
a stale callback tests itself against them and a reset to zero would make the
old number match again.
L2: the seams-before-scope case asserts the order in the emitted document, not
just non-membership. L3: the style docstring says what is true — one scope per
page, so mount refuses a second live document and gives the page back when a
mount fails.
Golden: 108134 -> 108047 bytes; payload 726168 -> 726081, sha256
6a5a3216aab7b99daeb26bcdcfe6e325c415e5ef60c16405eea329ca141405fe.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse frames from a stopped document
The frame case went red under full-suite load: tearing the terminal down runs
the engine's own disposal, which calls back into these modules, and a frame
asked for on the way out was owed by nobody because the cancel had already run.
A stopped document now asks for no frames at all, so the ordering inside
dispose stops mattering.
The render case is also rewritten around the work that survives a loaded
machine. It gives the terminal a scrollback and sends one wheel, which reveals
the scroll indicator and arms the 550 ms timer to hide it again, and the
boundary between the two mounts is drawn when the first terminal leaves the
page rather than when the component is told to go — React unmounts on its own
schedule, and a callback that runs while the first terminal is still up is not
a leak. The precondition counts what the document scheduled under the first
mount, so an empty leak list cannot mean the wheel reached nothing.
Verified both ways at this head: red with stopViewportTransform and
cancelDocumentFrames removed, green with them, and green in the whole
config/scripts suite.
Payload 726081 -> 726195, sha256
67a7b82bcd87b811214d02ca0e2f29bb634da47607e50f701bf153b9bf7323ef.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): style only what the page mount owns
CodeRabbit on document-style.ts:16. The mount appended the document's whole
stylesheet to the page head, so its `*`, `html` and `body` rules restyled every
screen the shell can show and went on doing it after unmount. Ruling 19's
shape: the native document owns its page and keeps the sheet as it is; the page
mount may style only what it owns.
The sheet splits into TERMINAL_DOCUMENT_ROOT_STYLE and
TERMINAL_DOCUMENT_ELEMENT_STYLE, composed in the same order, so the emitted
document does not move for the split - verified byte-identical before the seam
below. The page injects the element half only, with every selector held under
the host's own class, and xterm's sheet goes through the same rewrite. The
rewrite refuses an at-rule rather than passing its inner selectors through
unscoped.
A second leak of the same kind was in the same measurement: applyTerminalTheme
wrote the terminal background straight onto `html` and `body`. That is a sixth
seam - six fields at seven document sites now. Its default does exactly the two
writes it did; the page paints the host element instead. Emitted lines, old to
new: `paintWindowDocumentBackground` added beside the other defaults (3 lines);
`paintDocumentBackground: paintWindowDocumentBackground` added to the seam
factory (1 line); in applyTerminalTheme, the two `document...style.background`
writes become one `scope.paintDocumentBackground(background)`.
Leaving the sheet in the head after unmount is kept, and is now defensible: the
host drops the class on dispose, so every rule in it matches nothing until the
next mount.
The render check gains a case comparing `body` and `html` computed styles,
while mounted and after dispose, against a page of the same application with no
terminal on it, and asserting no rule of the injected sheet matches an element
outside the host. Verified red both ways at this head: unscoped sheet moves
`background-color` and `box-sizing`, and the inline theme write moves
`background-color`.
Payload 726195 -> 726363, sha256
9950f1770cd85ad2f80c69e074111869f6c66a724c87b66ba81f1ff10318a0ce.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): give the page mount's rules and frames their own oracles
Round 3 blocks on evidence, not on shipped behaviour. Each item:
H1. The scoping had no positive oracle: dropping the host class, or injecting
an empty xterm sheet, left the render check green, because every assertion was
about rules not escaping. The containment case now also reads four things off
the live elements under the host — xterm's own `position: relative`, the
viewport's `overflow-y: hidden`, that the viewport reserves no scrollbar width,
and the overlay's `position: fixed`. Red both ways: no host class reds all
four, an empty engine sheet reds the first.
H3. `cancelDocumentFrames` had no witness: the only leak the timer case could
see was the 550 ms hide timer, which its own module's stop cancels. There is
now a case whose witness is a frame taken through `scheduleDocumentFrame` —
the fit retry loop, with the surface hidden so the fit never commits and one
frame is always owed at dispose — and it reds when only `cancelDocumentFrames`
is removed. A unit covers the registry itself: a frame is held until it runs,
a cancel takes back every pending one and then refuses to schedule, and a reset
re-enables it.
The two scheduling cases now assert on their own witness kind, so neither can
stand in for the other, and the recorder judges a leak by whether the
`#terminal-container` that was on the page at schedule time is still in the
document — React unmounts on its own schedule, and a callback that runs while
the first terminal is still up is not a leak. The timer witness moved from the
scroll-indicator timer to the long-press timer, because the first needed a
drained scrollback and raced the engine under load; its precondition caught
that rather than passing.
L1. The two seam docstrings each sit on their own function.
L2. The parse-time census plants an element-read initialiser, which the
statement filter cannot see, and an inert object literal, which a reader that
flagged every initialiser would wrongly report.
L3. Dispose disposes `scope.committedTerm` as well as `scope.term`: a swap that
never committed leaves two terminals and only one was reached. Deduplicated,
because they are the same object whenever no swap is open, and pinned both ways.
L5. `document-style-scoping.ts` joins GAINED_OUTSIDE_THE_DOCUMENT.
Golden unchanged at 108,329 bytes; payload and its hash unchanged. Render
check: 12 cases.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): make the page document's dispose idempotent and owner-checked
CodeRabbit on terminal-web-document-mount.ts:180. Dispose was neither. A
handle outlives what it built - the component keeps one in a ref and React can
run a cleanup after a later mount has started - and everything dispose touches
is shared: the scope, the module sequences, window.__engineErrors. So a second
call, or a call from a handle whose document had already been replaced, tore
down the terminal that was on the screen and handed the page away while it was
still in use.
Each mount now carries a token, and dispose acts only when that token is still
the live one. A token rather than the host element or its class: two mounts can
be handed the same element, because the page remounts into a host React has
reused, so an element is not an identity and the class says only that some
document is using the host. The failed-mount path releases the page under the
same check.
Pinned both ways, red with the check removed: disposing twice leaves a terminal
put back after the first teardown alone, and a stale handle disposed after a
second document mounted changes nothing - the live markup stays, its terminal
is not disposed, and the page is still refused to a third mount.
Golden unchanged at 108,329 bytes; payload and hash unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): let a pending page mount be disposed before its import lands
Round 4 on #21809.
H1. The mount claimed the page before its dynamic import and handed back a
promise, so a component cleanup that ran while the chunk was still in flight had
nothing to dispose: the claim outlived the mount it was made for, and Reload —
the recovery ruling 20 names — was refused as a second document. The claim, the
markup and the handle are now made synchronously, `ready` settles on its own,
and a mount disposed while its import was in flight releases without starting
anything. Pinned in the render check by holding the document chunk 20 s past the
15 s readiness watchdog, clicking Reload and waiting for the second mount to
become live; red at that wait before the change.
M1. The frame case's precondition asserted that a frame had been asked for while
the document owned the page, not that one was owed when it was disposed. The fit
retry commits on its first attempt whenever the grid still measures, so a dispose
between two refits owed nothing and agreed with an empty leak list for exactly
the reason under test — one run in five. The refit and the unmount now share one
discrete click, which React flushes before the event returns, and a mutation
observer reads the registry at the instant the host is emptied. Five red runs
without `cancelDocumentFrames`, all on the leak and none on the precondition,
and five green with it.
M2. Two mounts handed the same element, which is what the token is for: the
other six cases use a different element each, so a host comparison passes all of
them.
L1. A throw inside the start sequence released the token but ran no stop, leaving
the host-notify error listener installed until the next reset nulled its undo.
The sequence now unwinds the starts that completed, in reverse, before it
rethrows.
L2. A render case comparing the window and document listeners the page holds
with no terminal on it, before and after a mount, so a stop that forgets one is
a failure rather than a second copy per terminal ever shown.
L4. Separated the stacked docstrings in the parse-time-effects census.
The render check's bundle, server, browser and page helpers move to their own
fixture module: the cases are what is under review and the scratch route tree is
not, and the file was 16 code lines under its cap.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): count the page document's leaked frames from dispose, not from detach
CI's addendum to round 4's M1: the frame case failed with the fix present,
`expected [ Array(1) ] to deeply equal []`, on a slower runner.
What scheduled it: `applyFitScale`, through `scheduleDocumentFrame` like every
other frame the document asks for — the document has no other rAF call site. It
is not an escape from the registry, so the registry is not what changes here.
Why it was counted: React unmounts in two steps. The mutation phase detaches the
host, and the passive cleanup that calls `dispose` runs after it — about 1 ms
later here, 20 to 35 ms later with the CPU throttled 20x, which is the runner
shape this failed on. A frame served in that gap runs with a detached container
while the document is still live and has not been asked to stop, and nothing
could have taken it back: `cancelDocumentFrames` had not been called yet. The
oracle judged by the captured container's connectedness, so it read the gap as a
leak. It now counts only what runs after the last statement of `dispose`, which
is the class coming off the host, observed on the element because React may have
detached it already.
The same reading fixes the other direction. The precondition is read at that
same moment, and the witness is a refit re-armed from a frame of the test's own,
so the document is owed a frame at the end of every frame the browser serves and
a dispose cannot land where nothing is owed. The single refit the case used
before bought one frame, and the retry loop commits on its first attempt
whenever the grid still measures.
Evidence: with the boundary removed the case reproduces CI's `Array(1)` in two
runs of three unthrottled, and in five of five with the CPU throttled 20x, where
the detach-to-dispose gap measures 20 to 35 ms; with it, five green runs; with
`cancelDocumentFrames` removed, five red runs, all on the leak read and none on
the precondition.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop a page mount that lost its claim before it writes the scope
Round 5 on #21809.
F1 (blocking). `buildTerminalWebDocument` had no token, so after its `await
import(...)` the whole body ran whatever had happened in the meantime: it
overwrote the six seams, called `startPageDocumentModules` and added the resize
listener, and only then did the caller's `.then` read the claim and throw the
result away. Everything after that await is shared — the seams are fields on a
module-singleton scope, and the start sequence resets that scope and installs
the document's listeners — so a mount disposed while its chunk was in flight was
writing over a mount that owns the page. The claim is now re-read the instant
the import lands, before any of it, and the build returns null.
`ready` for such a mount resolves rather than rejecting. Nothing failed: the
caller asked for the terminal and then asked for it to go away, and the chunk
arriving afterwards is not something for the error overlay to name. Before this
it rejected with a TypeError from `startSelectionMenuButtons` reaching for an
emptied host.
F2. The rejection handler called `release()` unconditionally, emptying a host the
mount may no longer own. It now releases only when the page is still its own.
Pins, both red first. In happy-dom: mount, dispose, then await ready — no
listener, timer or frame added while it resolves, the six seams unchanged,
`terminalGeneration` unmoved because the start sequence never ran, and the page
free for the next mount. Without the fix that case rejects with the
`startSelectionMenuButtons` TypeError. In the browser, the Reload-while-in-flight
case now reads the page's listeners with no terminal on it and compares them
against a page that mounted once and disposed once; without the fix the
abandoned mount leaves `window error` and `window resize` behind, because the
second mount's scope reset nulls the first mount's reporter undo.
The listener snapshot helper is shared with the mount-and-dispose case rather
than written twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(config): give the render fixture's server and scratch tree back when it cannot start
CodeRabbit on the render fixture, plus its note on `release`.
The fixture. `chromium.launch` is the last step of the setup and the one that
fails in practice — no Chromium on the machine, an
`ORCA_MOBILE_WEB_RENDER_BROWSER` pointing nowhere — and by then the bundle
server is listening and the scratch tree is on disk. Rejecting there left the
caller without a handle, so `afterAll` had nothing to close and both stayed
allocated; the listening socket is the one that bites, because an open server
handle keeps the vitest worker alive after its last test has reported. The setup
after `mkdtemp` is now wrapped, gives back whatever it managed to take, and
rethrows the original error rather than anything the cleanup raised. The normal
close path awaits the server-close callback instead of firing it.
`release` in the page mount. The ownership check covered the claim but not the
two lines that make the terminal disappear, so a release that skipped the claim
would still empty the host and drop its class. The check now guards the whole
function, and round 5's caller-side check is gone as a duplicate of it: one rule,
inside the thing it governs. Both existing callers are unchanged in behaviour —
the synchronous planting catch always owns the page, and the rejection handler
was already guarded.
Pinned red first. The new case points the launch at an executable that is not
there, then asks the port the fixture actually served on for a connection and
reads the scratch directories in the temp dir. Without the rollback the port
still accepts and the scratch tree is still there; with it, neither. The port is
recorded by wrapping the real `createBundleServer` rather than standing a double
in front of it, and the case asserts a server was created at all, or the refusal
would mean nothing.
Two oracles were discarded on the way. `rejects.toThrow()` with no argument
passes for a build that broke for its own reason, so the rejection is matched by
message. `process.getActiveResourcesInfo()` reports `TCPServerWrap`, not
`TCPSERVERWRAP`, so a count filtered on the upper-case spelling was zero in both
arms and agreed with everything; it also still lists the handle at the moment
the close callback runs.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): read the render fixture's rollback in a temp root of its own
Two defects in the case I committed in cb1833e675, both found by running it.
The anti-slop gate refuses module mocking, and it is right to: the case recorded
the served port by mocking the harness module around the real
`createBundleServer`. Gone, with no disable.
Its replacement read the shared temp directory for the fixture's scratch prefix,
which the render check next door writes to from a worker of its own. So the case
watched that tree appear and be swept up mid-run and called it a change: one red
in four alone, and red in the full suite, where the two run together. `TMPDIR`
now points at a directory this worker made, so the fixture's scratch tree lands
somewhere nothing else writes and what is left in there afterwards was left by
the setup under test. The failed launch also leaves Playwright artifacts and a
browser profile in there, which are Playwright's to clean, so the reading is
filtered to the name the fixture gives its own trees.
The listening-socket half is unchanged and was right: spelled `TCPServerWrap` as
Node spells it, and read a tick after the close callback, because the handle is
still listed while that callback runs.
Both halves now fail on their own without the thing they measure: with no
rollback at all the socket count is one above its baseline, twice out of twice;
with the rollback but no `rm`, the scratch tree is still there. Three green runs
with both.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hand the started document to the mount in the turn that started it
Round 6's two LOW items, and the pins for the owner-checked release.
LOW 1. `started` was assigned in the `.then` after the build, a microtask later
than the start sequence and the resize listener it installs. A dispose in that
window found nothing started, skipped the teardown and released the page with the
document still running on it. The build now takes an `adopt` callback and calls it
as its last statement, inside the guarded region, so whoever has to undo the
start is holding it before that turn ends. Pinned by queuing the dispose behind
the document import the build awaits, which lands in exactly that window: without
the change the started document's resize listener survives the dispose, five red
runs out of five.
The owner-checked release, which landed in 8b37221b57 without a pin of its own.
The one path that reaches a mount's cleanup holding someone else's page is a
rejected import: everywhere else the build re-reads the claim after its await and
stops, but a rejection never gets that far. So the pin drives that — the chunk
fails for the first mount only, the mount is disposed while pending, a second one
is built into the same element as Reload does, and then the first rejection
arrives. Without the guard inside `release` it empties the live mount's host:
three red runs out of three, on the markup. It also disposes the abandoned handle
a second time afterwards and asserts nothing moves, which is LOW 2's missing pin
for round 5's F2.
That case is its own file because the import has to fail before the mount module
loads, and the mocking the failure needs is only permitted in `.test.ts` — the
anti-slop override does not cover `.test.mjs`, which is what refused the port
recording in the render fixture's case. It fails once, so the mount that replaces
it gets real modules and is a live document worth protecting; its own resize
listener is the witness that it started.
Two oracles were dropped. Vitest reports its own message when a mock factory
throws, not the one thrown, so which import failed is read from the factory's
counter instead. And a counter of successful factory calls read zero even though
the second mount got a working document, which measures vitest's caching rather
than this code; the live mount's listener replaced it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the listener wrappers the mount pins install
The mobile tests-typecheck ratchet was red on 63eb8a40ae: six TS7006 implicit
`any` parameters in each of the two mount pins, from arrow functions assigned
over `window.addEventListener` and `window.removeEventListener`. An overloaded
method gives an assigned arrow no contextual parameter types, so each wrapper's
`type`, `listener` and `options` were implicitly `any` under
`tsconfig.test.json`, which the product typecheck does not read.
Both wrappers now take their parameters from the bound original as
`Parameters<typeof realAdd>` and spread them through, so the signature is the
real one rather than three widened parameters. No casts and no `any`.
Re-verified that the change did not quietly disarm either pin, because a recorder
that counted nothing would also go green: with `release` unguarded the rejection
case still fails on the live mount's markup, and with the adopt deferred by a
microtask the single-mount case still fails on the started document's resize
listener surviving its dispose.
The ratchet itself is the finding worth keeping. It is not part of the mobile
`tsc` the rest of my gate set runs, and it had dropped out of that set when these
folds began, so three reports listed the other ratchets and not this one. It is
back in, and stays in.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): drop what a disposed page mount adopted, and close the fixture's three resources apart
Round 7's five items.
1. The queued-dispose case's precondition was vacuous. It read the host for a
missing container, which dispose empties on every path, so a build that returned
straight after its ownership check satisfied it. The wrapper now counts resize
adds and the case asserts exactly one, which is the document having started. Red
under that mutation, on the count.
2. The render fixture's rollback awaited its cleanup unguarded, so a cleanup that
also refused replaced the error the caller needs — the reason the setup failed.
The rollback is best-effort now and the original error is what comes back.
3. That cleanup stopped at the first throw, so a browser refusing to close took
the socket and the scratch tree with it, which is the leak the rollback exists to
prevent. Each of the three is asked independently and the first failure is
rethrown after all three have been tried.
4. The rejection case restores its `window` patch in a `finally`, as its sibling
does, so a failure part way through no longer leaves the patched functions behind
for everything that runs after it.
5. `dispose` left `started` set. `send` reads it, and what it holds names the
page's one set of document modules, so a stale handle could route a host command
into whichever document is live next. Nulled, and pinned: the stale handle pings,
and with the old code the *live* mount's `receive` answers `pong`, because the
scope's seam belongs to it by then. The precondition is the live handle's own ping
being answered, so the silence is the stale handle declining rather than the
command doing nothing.
Items 2 and 3 have no pin of their own. Both are failure paths of the cleanup
itself, reachable only by making a browser or a socket refuse to close, and
standing something in front of Playwright to do it is what the anti-slop gate
refuses in this file's suffix. The rollback's own pin still covers the path that
matters, and both changes are read by it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): emit the terminal document as a factory
Ruling 22, commit 1 of C7.5b. The generator's concatenation already gave the 38
modules one function scope with one local `scope`; naming that scope a function is
what makes it the shape both hosts run, and what will let the page have its own
state per mount instead of a module singleton with a reset between them.
`createTerminalDocument(host)` is emitted around the same module bodies, in the
same order, followed by the same start sequence. It then declares `stop`, which
calls every module's stop in reverse order and takes back the frames the document
is still owed, and returns `{ send: handleMsg, stop }`. The native document is
that function plus one call with no argument, which is what the WebView has always
run: no argument means every seam is the window read it already did.
`createTerminalDocumentScope` takes the host and spreads the hooks it names over
the window defaults, filtering undefined so absent and present-but-undefined mean
the same thing. The emitted scope declaration is the one line the host reaches, so
the generator rewrites it and refuses if the line it expects is not there — a
rename would otherwise leave every call on the defaults with nothing to say so.
The golden moves by the wrapper and that one line, and by nothing else. 108,329 to
108,831 bytes, the whole diff:
-(function() {
+function createTerminalDocument(host) {
- function createTerminalDocumentScope() {
- return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams() };
+ function createTerminalDocumentScope(host = {}) {
+ const named = Object.fromEntries(Object.entries(host).filter(([, hook]) => hook !== void 0));
+ return { ...createTerminalDocumentState(), ...createTerminalDocumentHostSeams(), ...named };
- const scope = createTerminalDocumentScope();
+ const scope = createTerminalDocumentScope(host);
-})();
+ function stop() {
+ stopSurfaceTouchGestures();
+ stopTapDispatch();
+ stopSelectionOverlay();
+ stopNormalBufferSmoothScroll();
+ stopHostNotify();
+ stopTerminalInit();
+ stopWebglRecovery();
+ stopFitScale();
+ stopViewportTransform();
+ cancelDocumentFrames();
+ }
+ return { send: handleMsg, stop: stop };
+}
+createTerminalDocument();
The byte golden and the payload hash are re-pinned once: 726,363 to 726,865 bytes,
sha256 9950f177 to c7bbcb0b.
Four test files sliced the document with their own copy of the IIFE bounds, which
ruling 17 allows moving. They now share one reader in the test-support module
beside the one that locates a single module, and that reader names the factory and
its call. Every assertion is unchanged. The module-order guard and the region
reader compare against the text the document carries rather than a raw emit, since
the scope module is the one the generator rewrites; both go through one exported
function so neither can describe the rewrite differently from the generator.
`TerminalDocumentHostSeams` and the new `TerminalDocumentHost` moved to
`document-host-seams.ts`, which owns the six functions they type. Types emit
nothing, so the golden is unchanged by the move; it keeps `document-scope.ts`
inside its 300-line cap with no disable and no bump.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* build(mobile): emit the page's terminal document factory beside the WebView's
Ruling 23, and the first half of C7.5b commit 2: the artifact the page will
import. The page cannot run the native script, because building a function from a
string needs `eval` and the page's policy refuses it, and it cannot run the
modules either, because they are one singleton while the whole point of the
factory is a scope per call. So one emitted body gets two wrappers.
`buildTerminalDocumentFactoryBody` is now the shared half: the modules in order,
the start sequence, the stop handle and the return. The native script wraps it in
the declaration and the trailing call, exactly as before. The new
`terminal-webview-document-factory.generated.ts` wraps the same lines in a
`@ts-nocheck` module whose only other content is the type import and the
annotated signature. One generator run writes both, so the page's factory cannot
be a build behind the WebView's.
`@ts-nocheck` covers this one generated file. Every line of its body is esbuild
output from a module that was type-checked at its source, with `declare global`
blocks and type re-exports already erased and constants already substituted; the
one line a caller reads is the signature, and the generator writes it with its
types. `TerminalDocument` joins `TerminalDocumentHost` in `document-host-seams.ts`
as the shape the factory returns.
The pin is byte equality. `document-factory-artifacts.test.ts` strips each
wrapper and holds the remaining text equal, so the byte golden pins the page's
artifact by construction rather than by a second golden; it also reads the file on
disk against what the generator would write now, since that file is gitignored and
built by postinstall, and it refuses a trailing call in the page's copy, which
would start a document as the module was imported.
The path joins `.gitignore` and the oxlint ignore list beside the engine artifact.
The consumer census gains the generated file by name: it is the document, and its
one import is the host contract its signature is written against.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): call the document instead of starting its modules
The page's half of ruling 23, and ruling 24. The mount plants the markup and calls
the factory; the handle is `send` and a `dispose` that stops it. The document is a
function, so the page holds an object per call and nothing else.
Deleted with the singleton it was written for: `page-document-modules.ts`, the
claim token and `liveDocument`, `release`, the owner-checked `dispose`, the
second-mount refusal, `resetTerminalDocumentScope`, the `adopt` callback, `ready`
and every pending-import path. All of it existed because two mounts shared one
module-level scope and because the handle had to come back before its import did.
A call is a document now, so a second mount cannot reach the first one's state and
a caller's cleanup cannot arrive before there is something to clean up. The
second-mount refusal is not replaced by a one-line guard: with a scope per call
there is no shared state left to refuse for, and a host element with two
documents planted in it is the caller's own doing, visible on the screen.
Ruling 24 splits `message-bridge` by what it is, which is what made the page able
to run this text at all. Two more seams, eight now: `installHostTransport`, whose
window default installs the `message` listeners on window and document and hands
back their removal, and `hasEngine`, whose default is the `window.Terminal` the
engine bundle installs. The page answers a transport that installs nothing,
because its transport is the handle, and an engine that is always there, because
the engine is the import above. So the page no longer takes the shell's frames or
reports a missing engine on every mount, and `stopMessageBridge` takes the
listeners off — the WebView never removed them, which ruling 21 asks for.
The refit the bridge happened to own moves to `fit-scale`, which is whose work it
is; both hosts start it, and the mount's hand-copied five calls are gone. The
engine's disposal moves into `stopTerminalInit` for the same reason: the mount
cannot reach the scope any more, and a stopped document's terminal is a WebGL
context nothing will read again.
The start sequence the generator emits is now inside the document's own undo: a
start that throws runs `stop` and rethrows, so neither host can be left holding a
listener from a build that failed. That replaces the deleted entry module's
unwind, and it covers every start rather than the four that had one.
Readiness arrives the same way on both hosts. The document posts `web-ready`
through `postToHost`, which the controller already handles, so the mount-side
`confirmWebReady` is gone. That flush is also the one caller that reaches `post`
before the effect has a handle, which is why the component's queue stays and now
says so.
The golden and the payload hash move, 102 diff lines: the two seam defaults and
their state fields, the reset gone, `startFitScale`, the disposal, the bridge over
its seams, and the start sequence inside its try.
Tests: the seam tests and the unwind test move to the factory and the derived
start sequence; the frame registry builds a fresh scope instead of resetting one;
the two mount test files and the page entry's order test go with their subjects.
The render check keeps every behavioural case and loses two whose subject the
static import removed — a Reload while the chunk is in flight, and a chunk that
will not load, which is now the route's chunk rather than the document's.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): stop censusing state a call of the document already isolates
Ruling 22 answers what ruling 21's state half was for. A module's top level is
emitted inside the factory, so a `let` there is one binding per call — which is
exactly what moving it onto the scope was achieving. The census that refused it,
and the planted-module precondition beside it, go.
The effect half stays, and the distinction is what a stop can reach. An effect in a
module body runs at the position its module is emitted rather than in the start
sequence, so no stop function undoes it and each call leaks another one. A binding
leaks nothing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): stop the derived start sequence warning on every suite run
The helper reaches its neighbours through a variable specifier, which the bundler
answers by rewriting as a glob — and it refuses to glob the directory the import is
written in, so every suite that loads this file printed the refusal twice.
`@vite-ignore` leaves the specifier alone and the module runner resolves it, which
is what was already happening. An extension does not help: with one the refusal
becomes the own-directory rule, and the path alias is not resolved for a runtime
specifier at all.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): separate the two commits inside the closure reading
The factory arriving is not the whole -1,890. Making the document a factory put the
`host` argument on `createTerminalDocumentScope`, which is this lane's only edit to
a module the closure already carried, and that alone is +80. Both numbers are in
the note now, so neither commit's cost is read as the other's.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): read a document's elements from the host it was planted in
The last thing two documents on one page shared. Ruling 22 gave each call its own
scope, but the element reads were `document.getElementById` and the ids are in the
markup every host plants, so the second document's start sequence took the first
host's surface, overlay, handles and menu — two documents driving one terminal,
with the second host left empty.
Reachable, not theoretical: expo-router keeps the outgoing screen mounted for the
length of a stack transition, so two routes that both hold a terminal have two live
documents on the page while the animation runs.
`root` joins the host argument and `elementInRoot` is the one reader; the ten reads
in runtime-constants, surface-swap, selection-state-and-eviction and text-scaling go
through it. No id is renamed and nothing is refused: two documents on one page are
two terminals.
Two deviations from the ruling, both about *when* the default is read. `root` is
`ParentNode | null` with null meaning "the page I am in", rather than defaulting to
`document`: a data default is evaluated whenever a scope is built, which put a DOM
read into every slice evaluation and took eight keyboard-avoidance cases down with a
`ReferenceError` in their `vm` context. Null defers it to the read, which is the rule
the eight seams above it already follow. And the reader lives in
`document-host-seams.ts`, which declares the type, taking the root as an argument:
in `document-scope.ts` it was four lines over the file's 300 (no bump, no disable).
Red first, and the red was the second document: with a page-wide read the second
engine opens on an element outside its own host. `document-host-root.test.ts` plants
two hosts, starts a document in each, and reads which surface each engine was opened
on through the `createTerminal` seam, because the scope is not reachable from
outside. Falsified again after the fix by pointing the emitted reader back at
`document`: red, one case.
Two neighbours checked while here. The document-level touch listeners are already
host-scoped, because every handler tests its target against the scope's own surface,
overlay and handles, which are now this document's. `window.__engineErrors` is the
one page global left, and it is now kept rather than replaced per mount: a capped
diagnostic buffer, where a second mount was costing the first its captured lines.
Golden 27 diff lines, hash and length repinned: the reader, the `root: null`
default, and the ten reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the two-host engine double as the shape the seam returns
The double was reaching `createTerminal`'s return type through
`as unknown as Parameters<typeof queueMicrotask>[0] & never`, which the type-aware
gate reads correctly as an intersection with `never` and which was a cast standing in
for naming the type.
`TerminalDocumentTerminal` names it. Every member the type declares is present — the
ones `init` and the start sequence reach do something, the rest answer in the shape
their caller reads — and the shape needed no narrowing to accept a double. Two things
the type does not declare moved off it: where `open` was called is handed back beside
the terminal rather than exposed as a second getter, so the literal carries nothing
excess, and the buffer gained the `getLine` the type requires.
No cast, so nothing to write a SAFETY line about. Still red without the fix, checked
again after the retype by pointing the emitted reader back at `document`: one case.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): filter a document's page-wide touch listeners to its own host (OTA phase C, C7.5b round 1)
Round 1 H1. The dispatcher's four listeners are on `document`, so with two
documents on one page (legitimate since `712daa80e6`) each is handed the other's
touches, and the two-finger branch acts before any target filtering: a pinch in
host B posted `mobile-clip-cancel-by-pinch` from document A and dropped A's
selection. Fixed at the source, one predicate beside `elementInRoot`, asked once
at the top of each handler rather than inside a branch. `root === null` is the
WebView, where the document is the page, so it answers yes to everything and the
native document is unchanged.
`e.target` is the element the finger went down on for the life of the touch, so a
select-drag travelling outside the host still answers yes on move, end and cancel.
Census of every global listener install under `src/terminal/document/` (non-test):
- `tap-dispatch.ts:241-244`, four capture-phase `document` touch listeners
(touchstart, touchmove, touchend, touchcancel): MUST be root-filtered; this fix.
- `document-host-seams.ts:165-166`, `window`+`document` `message` in
`installWindowHostTransport`: WebView-only. It is that host's transport seam
default and the page installs nothing (ruling 24), so no page carries two.
- `fit-scale.ts:163`, `window` `resize`: page-wide by nature. A viewport change
concerns every document on the page and the event has no target in either host;
both must refit.
- `webgl-recovery.ts:104`, `document` `visibilitychange`: page-wide by nature.
Backgrounding concerns every document on the page; its target is the document.
- No document-level mouse, wheel, keyboard or selection listener exists: those
are all on `targetSurface` or the menu buttons, read through `elementInRoot`,
so they are already inside their own host.
Red-first, the reviewer's own repro in `document-host-root.test.ts`: A and B both
in select mode, a two-finger touchstart in B's surface. Before: 2 failed
(A posted the pinch cancel too, and the control in A's own host cancelled B).
After: 3 passed. The control keeps the assertion honest — the same touch inside
the document's own host still cancels its selection.
Golden and payload hash move (regen is a review event): six hunks, +22/-1.
`eventTargetInRoot` emitted after `elementInRoot`; `touchIsThisDocuments` after
the CAPTURE constants; the three-line guard at the top of each of the four
handlers; `onDocumentTouchCancel()` becomes `onDocumentTouchCancel(e)`.
Document 728,119 -> 728,589 bytes, sha256 5b65315b... -> 1556f532...
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): unit-test the page mount's three paths no happy path reaches (OTA phase C, C7.5b round 1)
Round 1 M2. `terminal-web-document-mount.ts` had no unit test: the only reading
of it was the render check, which drives the whole page bundle in a browser —
right for behaviour, too coarse for three lines that only a failure reaches.
The body's "five test files whose subjects no longer exist" is wrong for three of
them. What ruling 22 deleted was the machinery (the claim token, `liveDocument`,
the owner-checked dispose, the second-mount refusal); these three subjects
survived it and lost their only cover:
- both engines disposed when a swap never committed (`terminal-init.ts:203-213`);
- the host given back when a start throws (`startDocumentOrGiveTheHostBack`);
- the component naming that throw's cause (`TerminalWebView.web.tsx:83`).
`terminal-web-document-mount.test.ts` (happy-dom) covers all three against the
real generated factory. Only the factory's *arrival* is mocked, delegating to the
real `createTerminalDocument` except for the one case that makes a start throw, so
no stub stands in for the program under test.
Six cases, each red against a deliberately broken line:
- two distinct terminals both disposed. Broken `new Set([scope.term,
scope.committedTerm])` -> `new Set([scope.term])`: expected [1,1], got [0,1].
- the same terminal disposed once. Broken the set -> a plain array: expected 1,
got 2. The pair is the dedup's own oracle; either half alone passes for the
wrong reason.
- the host emptied and the class dropped on a throw. Broken by deleting the two
lines in the mount's catch: host still carried `#terminal-container`.
- control: a live document keeps the markup and the class, so the two assertions
above cannot pass for a mount that planted nothing.
- `onEngineError` gets `terminal document failed to start - engine missing`.
Broken by deleting the component's `receive` in its catch: expected one
message, got none.
- control: nothing reported when the document starts.
The engine double moves to `document-terminal-double.test-support.ts` and both
readers of the seam share it; a second hand-written copy of thirty members would
drift as the shape grows. It now counts disposals beside reporting `open`.
Terminal suite 67 files / 625 tests -> 68 / 631. No product line changed, so the
golden and the payload hash do not move.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin both generated wrappers, and say what a shadow root would break now (OTA phase C, C7.5b round 1)
Round 1 M1 and L2.
M1, the mount's stylesheet comment was one version behind: it said the document
reads its elements with `document.getElementById`, which `712daa80e6` replaced
with `elementInRoot`, and drew its shadow-root conclusion from that read. Both
halves re-derived rather than reworded. A shadow root no longer breaks the reads
(`elementInRoot` is a `querySelector` under the host, which a shadow root
answers); it breaks this sheet, because a rule in the document's head does not
cross a shadow boundary, so it would have to move inside each root and be parsed
once per host instead of once per page.
L2, `document-factory-artifacts.test.ts` anchored the page body at `):
TerminalDocument {` and nothing else, so the header, the `@ts-nocheck` line, the
`import type` and the parameter's own line could all drift with the test green —
and that signature is the one line a caller of the page's artifact reads. Both
wrappers are now literal lines: nine for the page (header, directive, import,
blank, the three-line signature) and one plus two for the native script
(declaration, closing brace, trailing call). Literal rather than the generator's
own constants, which would only agree with whatever it emits.
Red controls, each with the generator changed and then restored:
- the page's `import type` reordered to `{ TerminalDocumentHost, TerminalDocument }`:
2 failed ("the page module opens with its wrapper", and the on-disk reading).
- the native trailing call changed to `createTerminalDocument({});`: 1 failed
("the native script closes with its wrapper"). The old anchor caught neither.
No emitted line moved: golden and payload hash unchanged, terminal suite 68 files
/ 631 tests.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): count only the fingers inside this document's own host (OTA phase C, C7.5b round 2)
Round 2's residual of `9824145e1f`'s class, one level in. `eventTargetInRoot`
settles whose event it is; every branch then counts `e.touches`, which is every
finger on the screen. A finger resting in host A is therefore B's second finger:
a one-finger touch in B's own surface reads `length === 2`, latches a pinch and
drops B's selection, and on touchend `length === 0` is never true so B's surface
tap never fires.
`touchesInRoot(root, touches)` beside `eventTargetInRoot` returns this document's
own fingers, and every count and index reads through it. A list rather than a
count, because `touches[0]` and `touches[1]` are page-wide in exactly the same
way as `touches.length` — the first finger on the screen may be the other
terminal's. `root === null` is the WebView, whose fingers are all its own: the
list is returned untouched, so nothing is allocated on a path that runs at frame
rate.
Census of every `touches` / `changedTouches` / `targetTouches` read under
`src/terminal/document/` (non-test). There are no `changedTouches` or
`targetTouches` reads at all; every read is `e.touches`:
- `tap-dispatch.ts`, 15 reads across the three handlers that take an event
(`[0]`, `[1]`, `.length`, and the list handed to `touchById`): MUST be filtered.
The document listens on `document`, so the event and its list are both page-wide.
- `surface-touch-gestures.ts`, 18 reads across its touchstart, touchmove and
touchend handlers: MUST be filtered. These listeners are on the document's own
surface, so the event is always this document's — but the list inside it is
still every finger on the screen, which is the whole defect.
- `tap-dispatch.ts:21-24`, `touchById(touches, id)`: no filtering of its own. It
reads whatever list it is given, and all three callers now hand it a filtered
one; its parameter widens from `TouchList` to `ArrayLike<Touch>`.
Red-first in `document-host-root.test.ts`, the reviewer's two repros, with the
three product files at `aba99c3e4f` and the artifacts rebuilt: 2 failed / 3
passed (pinch cancel posted with one finger on B's overlay; `terminal-tap` never
posted). With the fix: 5 passed. The pinch-inside-own-host control stays, and the
first repro lands on B's menu pill rather than its surface, because a single
finger on the surface dismisses a selection by design — on the pill, keeping the
selection is the whole assertion.
`document-host-seams.ts` also rewritten in the present tense where it read as
history.
Golden re-pinned: 19 hunks, +51/-33. `touchesInRoot` emitted after
`eventTargetInRoot`; one `const touches = touchesInRoot(scope.root, e.touches)`
at the top of each of the six touch handlers, and every `e.touches` read inside
them now reads `touches`. Document 728,589 -> 729,152 bytes, sha256 1556f532...
-> 02633389...
Correction to `9824145e1f`'s message: it cites `tap-dispatch.ts:241-244` for the
four installs, which at that commit are `261-264` (the line numbers are the
pre-fold ones from the review).
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): name the haptics module inside the merged session-closure reading (OTA phase C, C7.5b)
The merge's re-measured 4284 sat one above this branch's -40 added to PR B's +3,
and the comment could only say main had drifted "a module of its own". It is
`src/mobile-web-shell/bridge/bridge-haptics-notify.ts`, which C7.10 item E put on
the session route after PR B recorded 4323 — so pristine main reads 4324 against
the 4323 it holds, which is what #21908 re-pins.
Named here as #21908 names it on main. Nothing measured changes: 4284 is the same
number, and the module is in it by main's route rather than by anything this branch
did. `haptics.web.ts` was already in the closure; the bridge module joins it.
Verified by reading the closure's own module list rather than inferred from the
count: both haptics modules are in `local`, with the artifact-level totals
unchanged at 4284 / 934.
Which is why the reading is re-measured and not summed. A merged number arrived at
as -40 plus +3 would have read 4283 and been wrong about a module neither side of
the merge touched.
After #21908 lands, a further merge of main reconciles the two comments.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): measure mermaid rendered in the page
Red-first for C7.10 item B. The check mounts the real web sibling in
chromium and webkit under the shipped shell CSP and asks four things of
it: that a diagram renders with zero policy violations and zero eval /
new Function calls, that the SVG is the native buildHtml's own output
once the diagram id and xmlns:xlink are normalised away, that a hostile
diagram lands inert, and that a source change, an unmount and a remount
leave exactly one SVG and no listener of the first mount.
The equality oracle is buildHtml itself, bundled for Node behind a
Proxy stub for its native imports and served as its own document in the
same browser, so neither side of the comparison is retyped.
All eight cases fail on this commit: the sibling is still the labelled
source box.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): fence the session download rather than its module list
Ruling 28. mobileWebAppRouteClosure reads metafile.inputs, which holds
dynamically imported modules under splitting: true exactly as it does
under splitting: false, so it cannot say "on demand" about anything: an
on-demand mermaid moves the session route's module list 4320 -> 6362
while its download does not move at all.
So the fence moves to entryStaticClosure. The new helper walks the
emitted chunks from the output the route's own module landed in and
follows import-statement edges only, and hands back both halves, because
mermaid's absence from the download is only a measurement while its 66
files are present in the deferred half.
The module list's new total is recorded in the docstring with its reason
and asserted beside the engine's own file count, which moves only when
the pinned mermaid version does.
Red on this commit: no mermaid in the closure yet.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): render mermaid in the page
The web sibling stops being a source box. mermaid is a browser library,
so the page imports it inside the render effect and draws the diagram in
this document: no WebView, no 3.7 MB engine string, and nothing of the
engine downloaded by a session with no diagram on it.
What replaces the sandbox is mermaid's own securityLevel: 'strict',
which runs its serialized SVG through DOMPurify. The native path's
</script> escaping has no analogue here and needs none, because the
source is a JS string argument rather than text spliced into an inline
script. Measured in both engines: a script in a label, a </script>, an
onerror and a javascript: click all land inert.
The configuration is now one object both hosts read, so the theme cannot
drift between the page and the phone; buildHtml serializes it instead of
holding a second copy. It gains suppressErrorRendering, because mermaid
otherwise draws its own error diagram into a temporary element and leaves
that element behind when it rethrows -- an orphan SVG on the page, and on
native a diagram the component is about to replace with the source box
anyway.
The dispose clears the host on unmount and on a source change; the id is
a useId, because mermaid writes it into the stylesheet inside the SVG and
it has to be a CSS identifier.
Also re-records the closure total the previous commit pinned: with the
real component the session route's module list is 6376, not the design
probe's 6362, and the reason is in that file's docstring.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): budget the deferred engine's chunks apart from the routes
Putting mermaid on the page took the app bundle from 69 emitted scripts
to 172, and the asset budget failed: 215 assets against a ceiling of
115. The cause is not a page split running away, which is what that
ceiling is for -- it is that mermaid lazily imports each of its own
diagram types, so one import() lands 103 scripts no route count
predicts.
So the ceiling gains a second term, named and measured (172 scripts with
mermaid against 69 with it aliased to a stub, at 11.17.2), rather than
the route term being raised to cover it. A page split running away still
fails on the route term, and the failure still says which of the two
grew.
The consequence is worth reading twice: the derived ceiling has to stay
inside the 256 assets the shell will load, and with 42 images it now
crosses that at 24 routes instead of 50. The bundle is at 215 today with
14 routes, so there is room for about ten more routes before a green
build produces a manifest no phone will open.
Measured by the config/scripts suite failing on this head, not predicted.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): pre-bundle the page's mermaid into one artifact
import('mermaid') from inside the app bundle emitted 103 scripts, not
one: mermaid lazily imports each of its own diagram types and esbuild
splits along those boundaries. Every one of those scripts sits inside
the OTA generation the phone has already downloaded, so the split moved
no bytes over the wire and spent 103 of the 256 manifest assets the
shell will load -- which is the scarce resource here, and the reason the
previous commit had to invent a second ceiling term.
So a sibling generator bundles the package into one ESM module beside
the WebView engine it already builds, emitted by the same postinstall
run, gitignored and lint-ignored with the others. The page imports that
artifact on demand instead, through a loader whose return type names the
two calls the component makes -- checked against the artifact's own
inferred export rather than cast to it.
Measured, at 14 routes:
emitted scripts 172 -> 69 (68 with no deferred engine at all)
manifest assets 215 -> 112 (111 with none)
session modules 6376 -> 4323 (+3 over main: config, loader, artifact)
chunks fetched for one graph TD 27 -> 1
bytes fetched 837,530 -> 3,482,965
The static-closure fence is unchanged in meaning and now reads on the
artifact: absent from every chunk the route reaches by an import
statement, present in the deferred half. The rendered SVG is byte-for-
byte what it was, so the equality against the native document still
holds on both engines.
Also adds the diagram to the webview-consumers list, which is what that
list means: its native component imports the package and its sibling is
what the builder resolves instead.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* revert(mobile): drop the deferred-engine ceiling term, keep the control
With the engine pre-bundled into one artifact the bundle emits 69 scripts
at 14 routes against the route term's 72, so the second term this series
added has nothing left to do and the route count is the only term again.
mobileWebAppBundleMaxChunks and the asset ceiling derived from it are
back to what main has; the shell's 256 assets are crossed at 50 routes
again rather than at 24.
What stays is why. A ceiling raised to admit 172 scripts would have
admitted any split at all, so the budget test gains the control that
holds the line: the single-artifact count passes the ceiling and the
lazily-chunked count fails it, both measured at 14 routes, with mermaid
named as what produced the second.
Red before the term came out: the control failed asserting 172 > 175.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): keep build output out of the raw-request-port census
The census walks mobile/src for AST reaches into the unvalidated request
port, and the pre-bundled mermaid artifact is the first generated file
under src that is executable code rather than a string literal. Two of
its own vendored dependencies contain the token `sendRequest`, so the
walk read minified third-party code as a new call site and asked for an
inventory line nobody can ever migrate.
So `*.generated.ts` joins node_modules and test files in that file's
stated list of what it does not scan, with the reason. The scripts that
emit those artifacts are ordinary source and are still scanned, which is
where a real reach would be.
Two halves to the new control, because a filter that skipped everything
would satisfy either alone: nothing generated is left in the scan, and
the matcher still finds the port when handed one line of code.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): escape the shared config into the native inline script
buildHtml spliced JSON.stringify(MERMAID_DIAGRAM_CONFIG) straight into
the inline <script>, twenty lines below the function that exists because
JSON.stringify leaves `<`, `>`, `&` and the U+2028/9 separators raw. Inert
at today's five hex colours, and not inert for a themeCSS or a font stack,
which is free text going into the same script element.
So the escaping splits from the stringify and both callers use it: the
source keeps its own wrapper, the config gets one. Those characters only
ever appear inside JSON string literals, so escaping them is valid for an
object serialization exactly as it is for a string.
Red first: a config carrying `</script><script>` put four raw closers in
the document where a benign build has two.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): pin the page mermaid type against the package's own
The loader returned the artifact's default as PageMermaid, which checked
that two names exist and nothing about their shapes: the artifact is
minified vendor output and both members infer as `any` there -- a probe
assigning engine.render to a number compiles -- and `any` satisfies every
signature there is.
So the shapes are asserted against the package's `Mermaid`, which is
precise. A PageMermaid member whose signature the engine does not really
have now fails at this line rather than at a call the page makes.
In the product module, not a test: mobile/tsconfig.json excludes test
files, so a type-only assertion in one is never compiled. Underscored
because it is a compile-time statement with no runtime reader, which is
the form the linter asks for.
Control, verified both ways: changing render to (id: number) => Promise<{
svg: number }> reds tsc naming both parameter and return, and the real
signatures compile.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): re-measure the chunk series and say what it does not show
The four-point series was stale and read as a slope it is not. Measured
again on this head, by copying the route tree and dropping routes from
the end of the sorted key list -- both siblings of each, because deleting
a .web.tsx alone leaves the native file for the builder to resolve and
measures an entirely different closure, which is how the first attempt
at this produced 77 scripts for 14 routes:
8 routes -> 32 scripts
10 routes -> 43
12 routes -> 61
14 routes -> 69 (the real tree)
Between four and nine more per route depending on which route, so 4r + 16
is a bound and not a fit, and the justification now says that instead of
claiming three per route. It also says the part that matters more: at 14
routes the tree measures 69 against 72, and the last two routes cost the
8 the ceiling grants for two. The fence is at break-even, and the new
assertion states that slope from the function rather than from a comment.
Also records what the generation weighs, since every chunk ships in it
whether or not a phone fetches one: 8,016,714 bytes across 112 assets
against the 9 MiB ceiling, 84.9%, 1,420,470 left. It was 4,539,090 before
item B, and the engine is the difference.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the native fallback under suppressErrorRendering
The shared config reaches the phone too, and it gained a key the native
path did not have. So the native document is now loaded for a diagram
that throws, in both engines, with window.ReactNativeWebView standing in
for the host: mermaid's run still rethrows, the document's own catch
still posts `error`, and that is the message the component turns into the
source box.
Measured both ways, so the case says which half the key owns. Whether
the fallback fires does not depend on it -- `error` is posted with the
key and without it. What depends on it is that nothing is drawn behind
the fallback: removing the key leaves mermaid's own error diagram in the
document and reds this case at 1 SVG against 0, on chromium and webkit
alike.
The control is the same document for a diagram that parses: a height,
not `error`, and one SVG.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): one walk for every source census, without build output
Nine censuses under mobile/src each held a copy of the same recursive
walk, and each decided for itself what a source file is: seven had no
opinion about generated files, one excluded them in its own regex, and
one had the exclusion I added last round. So all nine read 7.9 MB of
emitted vendor code -- 3.7 MB of mermaid for the WebView, 3.5 MB of it
for the page -- and the two largest censuses TypeScript-parsed all of it,
looking for call sites nobody wrote and nobody can move.
That is what took rpc-params-contract-type-only-boundary over its 5 s
timeout in CI once the fifth artifact arrived. Measured here, median of
3, import plus tests:
main, 4 artifacts, no exclusion 1004 ms (slowest case 831 ms)
with the 5th, no exclusion 1513 ms (slowest case 1358 ms)
with the 5th, this commit 947 ms (slowest case 788 ms)
So it lands below where main has it, not merely below where I left it.
Across the nine, four more halve: rpc-operation-cast-fence 769 -> 441,
rpc-subscription-boundary 946 -> 468, unchecked-rpc-reader-boundary
1042 -> 538, lifecycle-owner 747 -> 433, reanimated-web-mapper-deps
1028 -> 516. The two that already excluded generated files do not move.
What each census counts as interesting -- extensions, whether test files
are in -- stays its own, because they genuinely disagree. What counts as
a source file at all is now said once.
The control is the file that started it: a *.generated.ts whose text
holds exactly the import a census is hunting, planted beside an ordinary
file carrying the same text. The generated one is not returned and the
ordinary one is, so the absence is a measurement. A second control reads
mobile/.gitignore and holds the predicate to every artifact the tree
generates, and a third fences the walk itself to one spelling, so a tenth
census cannot paste the cost back in.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): correct why the type pin sits in the product module
The comment said a type-only pin in a test file "is never compiled".
That is false: mobile/tsconfig.json excludes *.test.ts, but
tsconfig.test.json is a second program that does check them, run by
check:tests-typecheck and held by the tests-typecheck ratchet.
The conclusion is unchanged and the reason is now the true one. The app's
own typecheck is the unconditional gate and would not cover a pin written
in a test; the test program is real but carries a grandfathered baseline
and a few files held outside it on purpose. And the assertion is about
this module's own type either way, so it belongs beside it.
Comment only; tsc, the ratchet and both lints re-run on the file.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the terminal WebView document byte for byte
The document is already pinned as a digest, which says whether the emitted
bytes moved and nothing about where. C7.1 moves the hand-written script inside
it into modules the web page can import and rebuilds the document from them,
and the claim that has to hold through every one of those commits is that the
native screen kept the document it had. A digest cannot be the instrument for
that: it fails as two hexadecimal strings.
So the document is also committed as itself. The fixture is generated by
`scripts/build-terminal-document-fixture.mjs`, never pasted, and the test
rebuilds the comparison through that script's own substitution rather than
restating it, so a fixture written by one rule and read by another cannot agree
with itself.
The generated xterm engine is stored as two placeholders. It is already covered
by the digest test, postinstall regenerates it from whatever xterm the lockfile
holds, and inlining it would put 612 KiB of vendored bytes into the file whose
job is to isolate hand-written changes. Two further cases keep that from
becoming a hole: the placeholders must each appear exactly once and the engine
must not appear at all, and the restored document must equal the real one.
Regenerating the fixture is a review event. It is only correct when the emitted
document was meant to change, and the diff in that commit is the evidence.
Red-first: flipping one character inside a comment in `write-queue.ts` fails
both identity cases with a one-line diff naming the comment, where the digest
test reports a hash.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare two terminal documents as programs, not as bytes
The C7.1 flip commit moves the document's 57 reassigned variables onto a scope
object, because a variable assigned across ES modules is a syntax error, and
every read and write of them gains a qualifier. The ruling asks that the review
of that commit be a test rather than a 515-line read. This is that test's
instrument.
It cannot be a byte comparison. Once the script's source is modules, `oxfmt`
owns its style, and the repository's style has no semicolons where the
hand-written document has one on nearly every line. A byte diff would therefore
be dominated by changes that are not the refactor, which is the opposite of
what the reviewer needs.
So the comparison is over tokens: semicolons are excluded for the same reason
they moved, comments never reach the stream, and one difference is allowed —
`name` becoming `<qualifier>.name`, three tokens for one — which it counts and
reports. It is stricter than "it still runs": a reordered statement, a changed
literal, a dropped operator, a renamed local and a qualifier under the wrong
object name all diverge, each reported with the token index and both sides.
Acorn carries `value` on its tokens but does not declare it, so the field is
read through a narrowing check rather than asserted onto the declared type.
Red-first, by mutation: dropping the qualifier-name check fails the case that
names it; removing the leftover-token check fails the dropped- and
added-statement cases; treating semicolons as significant fails the three cases
that depend on ignoring them. The acceptance case runs on the real 2,758-line
script rather than on a fixture, so the instrument is known to survive
everything the document actually contains.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): count each normalisation the move makes, separately
Measured while extracting the first group: the document's ES5 style is not a
style this repository's own rules permit. `curly` braces 279 brace-less
if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446
`var` declarators become `const`, `let` or a scope field. Those rewrites land
before the qualifier is considered at all, so "the qualifier and nothing else"
was never reachable once the source is a linted module.
The comparison now allows exactly four classes and counts each on its own: a
reference that gained the qualifier, a declaration that moved onto the scope
object, a `var` that only changed keyword, a body that gained braces, and a
catch clause that lost its binding. Separate counters rather than a total,
because the flip commit pins each number and a total would let one class absorb
another — which is the drift the pin exists to catch. The two `var` classes
partition the 446, and the qualifier's 641 sites partition into references that
kept their declaration and declarations that moved.
Two ordering facts the cases pin. The catch rule is tried before the brace rule,
or the inserted-brace rule eats the `{` that follows `catch` and the streams
never resynchronise. A body braced at the very end leaves its closing brace
after the baseline has run out, so trailing closes are absorbed after the walk
rather than reported as a length difference.
Everything outside the four classes still refuses with the token index and both
sides: a changed literal, a dropped operator, a reordered pair, a renamed local,
a qualifier under another object's name, a brace opened and never closed, and a
brace closed where none was opened.
Red-first, by mutation: disabling the catch rule, disabling the trailing-brace
absorption, folding scope-field declarations into plain references, and not
counting brace insertions each fail exactly the case that covers them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the mouse-report cell a module the page can import
The first of the twelve groups the document already names. `*-injected.ts` has
been splicing JS strings into the document for a while, and tests evaluate
those strings, so the one-source-two-consumers shape is already there; what is
missing is that a string cannot be imported by the web page, typechecked, or
linted. This turns one of them into a module and adds the generator that puts
it back into the document.
The generator is a transform, not a bundle: a bundler orders its output by the
dependency graph, and the document's order is part of what the equivalence test
holds fixed. Imports are dropped rather than resolved, because inside the
document every name is already in scope — that is what the single IIFE means —
and `document-externals.ts` declares the names whose groups have not moved yet
and emits nothing at all. esbuild prints an ESM module's exports as a trailing
block, so that block is dropped whole rather than by its keyword; leaving the
keyword behind would put a bare block statement in the document.
Both sides of the comparison now go through that same printer before being
read. Otherwise every choice the printer makes — semicolons, property
shorthand, quote style — reads as a difference in the program when it is a
difference in who typed it, and each would need its own rule. A script that
does not parse is reported as a refusal naming its side, not thrown.
`let` is contextual outside strict mode, so acorn reports it as a name and not
as a keyword; without that the var-to-let rewrite the linter performs would be
refused on every reassigned local.
The group's counts are pinned exactly: nine references gained the qualifier
(`term` seven times, `panX` and `panY` once each), nine locals became `const`
or `let`, thirteen one-statement `if` bodies gained braces, no declaration
moved onto the scope object and no catch clause lost a binding.
The document is untouched, so the byte pin from 3006d8dfdf is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* 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
* refactor(mobile): make reflow a module, and give the generator its own tests
The third group, and the defect it found: esbuild wraps a long import list
across lines, and the generator was skipping only the first of them, which left
the remaining names loose in the emitted script. The document did not parse, and
the equivalence check said so by name rather than throwing — which is what that
refusal path was added for. Both lists, import and export, are now skipped to
their closer instead of by their first line.
The generator's own tests cover what the per-group comparisons cannot say on
their own: an export is unmarked and indented into the document scope, a
one-line import is dropped, a wrapped import is dropped whole, the trailing
export block esbuild prints is dropped rather than left as a bare block
statement, and types are erased without touching the program.
Reflow's counts: eleven qualified references — the terminal ten times and the
settled row count once — six locals that became `const`, and the two early
returns braced. The row count is written from three groups, so unlike the
query-reply flag it is the document's state rather than one module's.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the keyboard-avoidance metrics a module
The fourth group, and the first that needed a non-null assertion.
`lineHasVisibleContent` reads the terminal's column count with no guard of its
own; the guard is in `computeContentBottomRow`, which is its only caller. Adding
a guard would change the program, and optional chaining would change what
happens when there is no terminal — the document throws there today. TypeScript
erases a non-null assertion, so the emitted script is unchanged and the
invariant is written down where the reader needs it.
Reflow now imports the metrics call from this module rather than declaring it an
external, which is the shape every group takes as its neighbours arrive.
Counts: fourteen qualified references, nine locals rebound, ten one-statement
bodies braced, and the two `catch (e) {}` clauses — the row scan and the
alternate-screen probe — unbound.
The scope table's rule is stated more precisely with it: a variable is this
module's own only when the group both declares and assigns it. While the rest of
the document is still strings, one the main slice declares stays shared even if
every use is in one group, because emitting a second declaration beside the one
the slice still carries would not be the same program.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make WebGL loss recovery a module
The fifth group, and the first carrying a top-level statement rather than only
declarations: the visibility listener it registers. In the document that runs
when the IIFE reaches it; as a module it runs on import, which is the same
single registration.
The context-loss listener disposes the addon it is registered on, so it cannot
run before that addon exists, but the assignment is to a `let` a closure
captures and TypeScript will not carry the narrowing across it. A non-null
assertion, erased by the compiler, keeps the emitted script identical and puts
the invariant where the reader is.
Counts: twenty-three qualified references across the terminal, the addon, its
retry timer and the theme the host last sent; three locals rebound; twelve
one-statement bodies braced; five of the six catch clauses unbound, the sixth
keeping its binding because the attach failure reads the error into its
diagnostic.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make indirect-pointer scroll a module, and count a fifth class
The sixth group found a rule the four classes do not cover, so I measured the
whole script rather than meeting them one at a time: linting all 2,757 lines as
a module trips `curly` 279 times and `no-unused-vars` 38, both already counted,
and then five further rules at 23 sites — `prefer-number-properties` 17,
`prefer-includes` 2, `no-useless-escape` 2, `prefer-exponentiation-operator` 1
and `no-unused-expressions` 1.
Seventeen of those 23 are one rewrite: a global numeric function moved onto
`Number`. It has the same token shape as the qualifier, so it is counted as its
own class rather than folded into anything, and only the four numeric globals
are admitted — anything else appearing under `Number` is refused, which a case
pins. Every site is already behind a `typeof … === 'number'` check or is parsing
a string, so the two forms are the same test.
The remaining six sites are each a different shape and too few to be worth
matching; they will surface as refusals in whichever group carries them, and I
will report each rather than widen this.
The scroll accumulator is the first declaration to move onto the scope: it is
declared in this group but a touch scroll in another slice resets it, so the
`var` becomes an assignment to the shared field and the class that exists for
exactly that counts one.
Counts: five qualified references, one declaration moved, four locals rebound,
eight bodies braced, one `Number` rewrite.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal surface-swap group into a module
The seventh named group. `surface` and the uncommitted terminal are read by
other slices, so both move onto the scope; the two committed handles and the
pending surface are declared and assigned only here and stay module locals.
Counts: qualified 7, scope declarations 1, rebindings 4, braced bodies 2,
unbound catches 2, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): substitute build-time constants into the emitted document
The document's script text is not all hand-written: parts of it are template
literals interpolating real values, starting with the theme background. A
module cannot interpolate and still be the same program, so the generator now
derives an esbuild `define` from `document-constants.ts` and substitutes after
the import lines are dropped, when the names are free again. The page imports
the very same bindings, so there is one source either way.
The fixture script's TypeScript loader moves beside it rather than being
written twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal theme group into a module
The eighth named group, and the first parameterised one: its background
fallback comes from the mobile theme through `document-constants.ts`.
Two sites carry a line-scoped lint disable rather than the rewrite the rule
asks for: `indexOf(',') >= 0` and `Math.pow`. Both rewrites are outside every
normalisation class the equivalence instrument counts, so taking them would
change the program the native document carries, which is the one thing this
branch holds fixed. The reason is on the disable line.
Counts: qualified 12, scope declarations 0, rebindings 28, braced bodies 13,
unbound catches 0, number properties 9.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal path-tap group into a module
The ninth named group, and a pure query: it reads no shared state, so it has
no qualifier sites at all.
Two things this group forced. The generator now drops lint directive lines
before the transform, because a directive inside an expression makes esbuild
parenthesise that expression to keep the comment where it was, and those
parentheses are tokens the document does not have. And the two regexes keep
their `no-useless-escape` escapes behind a line-scoped disable, for the same
reason the theme group keeps `Math.pow`.
One name the document declares twice in one function stays `var`. Two
block-scoped declarations would be two bindings where the document has one,
and esbuild renames the inner one to say so.
Counts: qualified 0, scope declarations 0, rebindings 31, braced bodies 20,
unbound catches 0, number properties 2.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal tap-dispatch group into a module
The tenth named group, and the heaviest reader of shared state: the selection,
its elements, its thresholds and both press origins are all declared by the
overlay slice, which is still document text, so all of them move onto the
scope with their declarations left where they are.
Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11,
unbound catches 0, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal mouse-click-drag group into a module
The eleventh named group. The escape byte and both SGR mouse modes join the
scope from the runtime slice; the gesture itself is declared here and never
read outside, so it stays a module local.
Counts: qualified 17, scope declarations 0, rebindings 22, braced bodies 27,
unbound catches 1, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal url-tap group into three modules
The twelfth and last named group, and the second parameterised one: both
candidate patterns and the length bound come through `document-constants.ts`.
Three modules rather than one. At 303 lines it was over the file cap, and the
document's own order interleaves the OSC 8 lookup with the file-URL parsing,
so the split follows that order and the group's text is the three emissions
joined. The test does the joining.
Note for a later lane: `terminal-webview-url-tap.ts` and
`terminal-file-url-tap.ts` already hold TypeScript twins of some of this,
written for the React Native side and not identical to what the document
carries. Collapsing the two is a behaviour change and does not belong in a
branch whose whole claim is that the document did not move.
Counts: qualified 10, scope declarations 0, rebindings 41, braced bodies 25,
unbound catches 6, number properties 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the mouse-mode DECSET scan slice into a module
The first of the thirteen inline slices. Both control-sequence introducers,
the straddling scan tail and all three mode fields are declared by the
runtime-state slice, which is still document text, so they move onto the scope
with their declarations left where they are.
Counts: qualified 20, scope declarations 0, rebindings 10, braced bodies 9,
unbound catches 0, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal message-bridge slice into a module
The script and the document end in the same slice, so the slice splits in two
at the point where the IIFE closes: the script half becomes a module, the
document half stays text. The byte pin proves the join is unchanged.
The second catch keeps its binding: it names the error and reports it.
Counts: qualified 1, scope declarations 0, rebindings 1, braced bodies 0,
unbound catches 1, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): give the document close its own slice file
The previous commit put two exports in one slice file, which the slice-count
guard reads as a mismatch: it derives the slice list from the composer's
imports and cross-checks it against the composed entries, one per file. Five
suites failed to load.
Splitting the file rather than the constant is the better shape anyway. The
file was called `message-bridge-and-document-close` because it carried two
concerns; now each has its own.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal term-observers slice into modules
This slice interpolates the already-extracted keyboard-avoidance group between
its own two halves, so its text is three emissions joined in that order and
the test does the joining.
A sixth normalisation class, measured here rather than assumed: the printer
writes `{ name: name }` back as shorthand, and qualifying the value makes the
property name unavoidable again, so one baseline token faces four. It is
counted on its own like the others, with its own acceptance case in the
instrument's test, and every existing group's pin now carries a zero for it.
Counts: qualified 36, scope declarations 1, rebindings 12, braced bodies 12,
unbound catches 6, number properties 0, shorthand properties 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the selection-state-and-eviction slice into a module
The slice that declares most of the shared selection state: every threshold,
every overlay element and the selection itself, twenty-two scope declarations
in one place. The eviction counter is declared and assigned only here, so it
stays a module local.
Counts: qualified 12, scope declarations 22, rebindings 2, braced bodies 3,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the smooth-scroll and cell-geometry slice
Two modules, not one: the slice carries the normal-buffer smooth scroll and
then the cell-to-pixel geometry, and the split follows that order so the
group's text is the two emissions joined. Four names stop being externals and
become real imports.
Counts: qualified 39, scope declarations 0, rebindings 15, braced bodies 16,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* 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
* refactor(mobile): extract the terminal fit-scale slice into a module
The slice opens with the already-extracted theme group, so its text is two
emissions joined. Four more names stop being externals.
Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20,
unbound catches 0, number properties 9, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* 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
* refactor(mobile): extract the runtime-state and text-scaling slice
The document's declaration block, where almost everything it shares is
declared, with the query-reply and surface-swap groups interpolated inside it.
Three modules: the two declarations that come before the groups, the text
scaling, and the viewport transform with the scroll indicator. Seven more
names stop being externals.
Two things this slice forced.
The scope-declaration rule now counts each declarator of one `var`, because
`var panX = 0, panY = 0` becomes two assignments onto the scope. It has its
own acceptance case in the instrument's test.
The two halves are compared against their own text rather than as one joined
program. The declaration the slice opens with is shadowed by a parameter
inside one of the interpolated groups, and printing the baseline as one
program renames that parameter; qualifying the outer name removes the shadow,
so the rename has nothing to correspond to. Splitting the slice on the group
constants compares like with like, and those groups have their own tests.
Build-time constants are now substituted textually rather than through an
esbuild `define`: a `define` whose value is an object or an array is injected
as a helper binding instead of being inlined.
Counts, head: scope declarations 2. Tail: qualified 31, scope declarations 38,
rebindings 25, braced bodies 13, unbound catches 1.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): format the two test files the last commit left unformatted
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the mouse-report and scroll-routing slice
Two modules around the already-extracted mouse-report-cell group: the viewport
cell lookup that precedes it, and the mouse input encoding and scroll routing
that follow. Eight more names stop being externals, which leaves ten.
Counts: qualified 49, scope declarations 0, rebindings 49, braced bodies 42,
unbound catches 3, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the host-message-router slice into modules
Two modules after the already-extracted reflow group: the postMessage bridge
with the engine error reporting that rides on it, and the router itself.
`notify`, `handleMsg` and `reportEngineError` stop being externals, which
leaves seven.
The catch binding handed to the error reporter keeps a cast: a catch variable
is `unknown` under strict mode, and the reporter reads only `message` before
falling back to `String()`. The reason is on the line.
Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12,
unbound catches 2, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the selection-overlay slice into modules
Two modules after the already-extracted path-tap and url-tap groups: the
selection range with the xterm mirror, and the overlay positioning with the
edge scroll. Six more names stop being externals, which leaves one.
Counts: qualified 77, scope declarations 0, rebindings 96, braced bodies 63,
unbound catches 9, number properties 6, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the surface-touch-gestures slice into modules
The last of the thirteen slices. Two modules after the three already-extracted
groups: the selection menu's buttons, and the touch gestures with the pinch
and the momentum scroll. `attachSurfaceEventHandlers` was the last external,
so `document-externals.ts` is gone: every name the document uses now resolves
to a module.
The instrument reads both sides strict. A loose script has to defend Annex B's
block-scoped function declarations, and the printer does that by hoisting a
`var` and renaming the function, so one side carried a rename the other could
not. Neither name escapes its block, so the two readings agree on behaviour
and only the strict one can be compared. It has its own acceptance case.
Counts: qualified 104, scope declarations 1, rebindings 69, braced bodies 57,
unbound catches 2, number properties 2, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the document's opening declarations into a module
The document shell carried the IIFE opener and the eight declarations inside
it, so it splits the way the message-bridge slice did: the shell keeps the
HTML and the opener, a new slice file holds the declarations, and the byte pin
proves the join is unchanged.
With this every line of the document's script has a module behind it.
Counts: qualified 3, scope declarations 8, rebindings 0, braced bodies 0,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the whole document script against the modules
Every line of the script now has a module behind it, so the whole thing can be
compared at once. This is the review of the move, as one number per class:
qualifier 609 references + 73 declarations = 682 sites
var rebindings 373, the document's 446 declarators less those 73
curly braces 279, the number measured before any of this started
unbound catches 36 of 38; two name their error and report it
Number properties 17, also measured up front
shorthand properties 4, two SGR flags written twice each
unshadowed names 7
A seventh class was needed and is counted like the others: a binding that
shadowed a document variable stops being a shadow once that variable moves
onto the scope, so the printer stops disambiguating it. It has its own
acceptance case.
The module order lives in one file that both this test and the generator read,
so neither can drift from the other.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): keep only the lint directives that do something
Seventeen of the disables were inert: `typescript/no-non-null-assertion` is
not enabled here, and a directive naming two rules on one line is not parsed
at all, so the one rule that did apply was being ignored too. The changed-code
quality gate reports an inert directive as a finding.
The two that matter are back, one rule per line: the guard-as-expression in
the observer disposal, and the local the document declares and never reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): generate the terminal document from its modules
The WebView document is no longer a hand-written IIFE pasted into a template
string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts`
and the 36 modules under `src/terminal/document/` in document order, strips
their imports, exports and line-scoped lint directives, substitutes the
`document-constants.ts` exports textually, reprints each with esbuild and wraps
the result in one IIFE. `terminal-webview-html.ts` composes the shell, that
generated script and the close fragment. The artifact is gitignored and built by
postinstall, like the two engine artifacts.
The emitted document is token-equivalent to the old one under eight counted
normalisation classes, each pinned as an exact number in
`document/terminal-document-flip.test.ts` against the pre-flip text:
qualifiedReferences 609
scopeFieldDeclarations 73
rebindings 373
bracedBodies 279
unboundCatches 36
numberProperties 17
shorthandProperties 4
unshadowedNames 7
Any other difference fails with the token index and both sides. The second case
pins that the new document adds the scope object and nothing else.
Ruling 17: the behavioural tests now grep the generated document through
`XTERM_HTML`, never a module source, so every assertion still speaks about what
the WebView runs. Every assertion stays and the `expect` count per file is
unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22,
keyboard-avoidance 18, query-reply 14. One control per file was run by deleting
the module line the updated pattern guards; all seven red, and the tree restores
green.
Pattern changes, old -> new.
terminal-webview-scroll-routing.test.ts
var deltaY = ts.lastY - y; -> const deltaY = ts.lastY - y;
smoothScrollOffsetY -= deltaY; -> scope.smoothScrollOffsetY -= deltaY;
var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
-> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);
'touchmove' single-quoted, one line -> "touchmove" double-quoted, printer line break
}, { capture: true, passive: false }); -> { capture: true, passive: false }
function momentumStep() -> let momentumStep = function()
pendingNormalScrollDeltaY += deltaY; -> scope.pendingNormalScrollDeltaY += deltaY;
if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) {
normalScrollFrameId = requestAnimationFrame( -> scope.normalScrollFrameId = requestAnimationFrame(
pendingNormalScrollDeltaY = 0; -> scope.pendingNormalScrollDeltaY = 0;
cancelAnimationFrame(normalScrollFrameId); -> cancelAnimationFrame(scope.normalScrollFrameId);
var writeQueueHead = 0; -> scope.writeQueueHead = 0;
writeQueueHead++; -> scope.writeQueueHead++;
writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);
surface.style.transform = 'translate(' + panX -> scope.surface.style.transform = "translate(" + scope.panX
getVisualPanY() + 'px) scale(' -> getVisualPanY() + "px) scale("
var FRICTION = 0.972; -> const FRICTION = 0.972;
var MIN_VEL = 0.012; -> const MIN_VEL = 0.012;
edgeScrollDir = dir; -> scope.edgeScrollDir = dir;
term.scrollLines(edgeScrollDir); -> scope.term.scrollLines(scope.edgeScrollDir);
// Latching document-level touch dispatcher -> function attachSurfaceEventHandlers(
edgeScrollClientX = clientX; -> scope.edgeScrollClientX = clientX;
edgeScrollClientY = clientY; -> scope.edgeScrollClientY = clientY;
return mode !== 'none'; -> return mode !== "none";
var pixelX = cell.x; -> const pixelX = cell.x;
var pixelY = cell.y; -> const pixelY = cell.y;
...isSafeSgrMouseCoordinate(cell.y)) return -> ...isSafeSgrMouseCoordinate(cell.y)) {
...isSafeSgrMouseCoordinate(sgrRow)) return -> ...isSafeSgrMouseCoordinate(sgrRow)) {
if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress;
if (mouseTrackingMode === 'x10') return sgrPress; -> if (mouseTrackingMode === "x10") { return sgrPress;
if (mouseTrackingMode === 'x10') return press; -> if (mouseTrackingMode === "x10") { return press;
if (col > 126 || row > 126) return ''; -> if (col > 126 || row > 126) { return "";
document.addEventListener('touchend' -> document.addEventListener( "touchend"
}, { capture: true, passive: true }); -> { capture: true, passive: true }
notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...)
document.addEventListener('touchstart' -> document.addEventListener( "touchstart"
var clickInput = buildMouseClickInput -> const clickInput = buildMouseClickInput
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput });
terminal-webview-text-zoom.test.ts
var CLAUDE_STATUS_DOT = -> scope.CLAUDE_STATUS_DOT =
var PRIVATE_MODE_SCAN_TAIL_LIMIT -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT
\n\n function enqueueWrite -> \n function enqueueWrite
var terminalFontFamily = -> scope.terminalFontFamily =
output = terminalFontFamily; -> output = scope.terminalFontFamily;
String.fromCharCode(0x23fa) -> String.fromCharCode(9210)
TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)
EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)
data.replace(CLAUDE_STATUS_DOT_PATTERN, ...) -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR )
writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data))
var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData)
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
statusDotPendingSelector = false -> scope.statusDotPendingSelector = false (x2)
term.open(surface) -> scope.term.open(scope.surface)
term.unicode.activeVersion = '11' -> scope.term.unicode.activeVersion = "11"
enqueueWrite(ESC + '[0m' + replayData) -> enqueueWrite(scope.ESC + "[0m" + replayData)
fontFamily: terminalFontFamily -> fontFamily: scope.terminalFontFamily
fontWeight: '300' -> fontWeight: "300"
fontWeightBold: '500' -> fontWeightBold: "500"
terminal-webview-engine.test.ts
var webglAddon = null; .. var webglRecoveryTimer = null;
-> the refreshTerminalSurface()..init( block, with the scope preamble
window.addEventListener('resize' -> window.addEventListener("resize"
'terminal init failed' -> "terminal init failed"
'terminal message failed' -> "terminal message failed"
var everReady = false; -> scope.everReady = false;
everReady = true; -> scope.everReady = true;
fatal === undefined ? !everReady : !!fatal -> fatal === void 0 ? !scope.everReady : !!fatal
msg.type === 'init' && !everReady -> msg.type === "init" && !scope.everReady
/fatal === undefined \? !ready\b/ -> /fatal === void 0 \? !scope\.ready\b/
if (msg.type === 'ping') -> if (msg.type === "ping")
notify({ type: 'pong', pingId: msg.id }) -> notify({ type: "pong", pingId: msg.id })
terminal-webview-reflow.test.ts
} else if (msg.type === 'reflow') { -> } else if (msg.type === "reflow") { (x2)
var MIN_FIT_COLS = 20; -> scope.MIN_FIT_COLS = 20;
if (cols < MIN_FIT_COLS) return; -> if (cols < scope.MIN_FIT_COLS) {
flog('measure-skip-small-width' -> flog("measure-skip-small-width"
notify({ type: 'measure-result', ... }) -> notify({ type: "measure-result", ... })
var dispatch = { mode: 'idle' -> const dispatch = { mode: "idle"
window.addEventListener('message' -> window.addEventListener("message"
terminal-keyboard-avoidance-webview.test.ts
\n // reflow() -> \n function reflow(
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
\n var panX -> \n scope.panX
TERMINAL_REFLOW_JS fragment import -> the reflow(cols, rows)..notify( slice of the document
terminal-webview-query-reply.test.ts
attachTerminalQueryReplyBridge(term, gen) -> attachTerminalQueryReplyBridge(scope.term, gen) (x2)
term.attachCustomKeyEventHandler(function() { return false; })
-> term.attachCustomKeyEventHandler(function() { \n return false; \n });
term.textarea.readOnly = true -> term.textarea.readOnly = true;
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
terminal-webview-url-tap.test.ts
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the
generated document's digest, 730472 -> 723480 bytes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): delete the slice constants and injected fragments
The document is generated from its modules now, so the strings it used to be
pasted together from are dead. Deleted: the fourteen slice constants under
`terminal-webview-html/` (host-message-router, message-bridge,
mouse-mode-decset-scan, mouse-report-and-scroll-routing, runtime-constants,
runtime-state-and-text-scaling, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-touch-gestures,
term-observers-and-mode-mirroring, terminal-fit-scale, terminal-init-and-write,
write-queue) and the eleven `*-injected.ts` files. `document-shell.ts`,
`document-close.ts` and `theme.ts` stay: the shell and close are still the
document's HTML, and `theme.ts` is where `document-constants.ts` reads the
palette from.
Ruling 17, second commit. Tests that asserted the extraction mechanism itself
went with it: they compared one module's emission against the slice text it was
extracted from, and the flip test now pins the whole document against the whole
pre-flip script with the same eight classes. Deleted, all under `document/`:
fit-scale, host-message-router, keyboard-avoidance-metrics, message-bridge,
mouse-click-drag, mouse-mode-decset-scan, mouse-report-and-scroll-routing,
mouse-report-cell, path-tap, query-reply, reflow, runtime-constants,
runtime-state, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-swap, surface-touch-gestures,
tap-dispatch, term-observers, terminal-init, terminal-theme, webgl-recovery,
wheel-scroll. `document/url-tap.test.ts` stays: it pins against
`URL_TAP_WEBVIEW_JS`, which is neither a slice constant nor an injected file and
still has a consumer.
Tests that asserted behaviour through a deleted string now read the generated
document. `document/generated-document-region.test-support.ts` is the one way in:
`documentScopePreamble()` returns the scope object the document opens with, and
`generatedDocumentModule(name)` re-emits a module and refuses unless the document
carries that text verbatim, so an evaluated block is the WebView's own bytes. The
two local copies of the preamble in the engine and text-zoom tests were folded
into it.
Moved, with every assertion kept and the `expect` count per file unchanged:
terminal-webview-html/write-queue.test.ts -> document/write-queue.test.ts 34
terminal-webview-theme-injected.test.ts -> terminal-webview-theme.test.ts 14
terminal-webview-query-reply.test.ts 14
terminal-path-tap.test.ts 25
terminal-webview-url-tap.test.ts 33
terminal-keyboard-avoidance-webview.test.ts 18
terminal-webview-reflow.test.ts 22
terminal-webview-text-zoom.test.ts 59
terminal-webview-engine.test.ts 49
Pattern changes, old -> new.
terminal-webview-reflow.test.ts
if (!term || isAlternateBufferActive()) return;
-> if (!scope.term || isAlternateBufferActive()) {
term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows);
var wasAtBottom = buffer.viewportY >= buffer.baseY;
-> const wasAtBottom = buffer.viewportY >= buffer.baseY;
term.scrollToBottom(); -> scope.term.scrollToBottom();
if (nextCols === term.cols && nextRows === term.rows) return;
-> if (nextCols === scope.term.cols && nextRows === scope.term.rows) {
The other eight files kept their patterns; only the text they read changed, from
a deleted constant to the document block. The harnesses that evaluate a block now
build the document's scope object instead of declaring the vars it replaced, and
hand the terminal in as `scope.term`.
Controls, one per file: the module line an updated pattern guards was removed,
the document rebuilt, and the test run. All red, and the tree restores green.
query-reply terminalDataRepliesEnabled = true -> query-reply test, 2 failed
path-tap const parsed = parsePathLineCol(...) -> path-tap test, red
keyboard-avoidance-metrics contentBottomRow -> keyboard-avoidance test, 4 failed
reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
webgl-recovery new window.WebglAddon.WebglAddon() -> engine and text-zoom tests, 4 failed
osc-link-tap return parsePathLineCol(value) -> url-tap test, 1 failed
terminal-theme scope.term.options.minimumContrastRatio = ...
-> theme test, 4 failed
write-queue scope.writeQueue[scope.writeQueueHead] = undefined
-> write-queue test, 4 failed
`document-scope.ts` docstrings named the slice each field belonged to; they name
the owning module now. Three module comments pointed at deleted injected files
and point at the modules instead. Neither changes the document: esbuild drops
comments, and the byte pin is unmoved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name the right number of counted classes
The flip test's title still said seven; the table it asserts has eight.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): name the shape applyTerminalTheme writes through
The anti-slop gate refused `loadThemeApplier(term: object)` in the theme test.
`applyTerminalTheme` touches exactly two slots on the terminal it is handed, so
`terminal-theme.ts` now exports that shape as `TerminalDocumentThemeTarget` and
the test's parameter and both fixtures use it. The theme is optional on the way
in because `applyTerminalTheme` is what writes it.
No cast. The type is erased by the generator's transform, so the document is
unchanged and the flip test's class table and the byte pin both still hold.
Control: restoring the `object` parameter reproduces the finding at
terminal-webview-theme.test.ts:35:33 and the gate exits 1; with the named type
it exits 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): retire the flip pin, leaving the byte golden as the document's fence
`terminal-document-flip.test.ts` compared the emitted modules against
`terminal-document-pre-flip-script.txt`, the hand-written script as it stood before
C7.1, and held exactly while no module changed. That is the proof of the flip, not a
standing fence: the first lane that must change a module has to retire it or restate
its counted classes for a reason that has nothing to do with the move.
C7.5 is that lane — the document's host seams become scope fields so the page can set
them — so both go here, while the test is still green. The flip proof lives at
51ae7b1b03 ("test(mobile): name the right number of counted classes"), which is where
anyone reviewing the move should read it.
From here the standing pin is the whole-document byte golden,
`terminal-document-golden.txt`, checked by `terminal-document-identity.test.ts` and by
the payload-hash digest beside it. Regenerating it is a review event: the emitted diff
is listed old to new in the commit message and in the PR body, and a golden that moves
without a listed diff is a blocking finding.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): give the terminal document's host seams a field on its scope
Ruling 19: on the page `window.ReactNativeWebView` is the *shell's* bridge, so a
terminal `notify` through it would post raw terminal JSON into the bridge's channel,
and there is no engine IIFE hanging `Terminal` and the two addons off `window` because
the page imports xterm. Four reads had to become seams:
host-notify.ts notify() -> scope.postToHost
viewport-transform flog() -> scope.postToHost
terminal-init.ts new Terminal(...) -> scope.createTerminal
terminal-init.ts window.Unicode11Addon-> scope.createUnicode11Addon
webgl-recovery.ts window.WebglAddon -> scope.createWebglAddon
Each default is the window read the site already did, still performed at call time and
not captured when the scope is built, so inside the WebView the program is the one it
was. `document-host-seams.ts` holds the four and is emitted ahead of the scope object,
because the scope's defaults are those functions and the factory runs as the script is
parsed. `document-terminal-shape.ts` takes the xterm-shape types out of the scope's
file, which the four fields pushed over the 300-line cap; document-scope re-exports
them, so no importer moves. The page's side of the seam lands in C7.5's later commits.
Two shapes kept faithful rather than tidied. The unicode11 addon is still built inside
the `try` it was built in, so a constructor that throws is still swallowed; and no
WebGL addon still returns false from `attachWebglAddon` without reaching the `catch`,
which is the DOM-renderer fallback rather than a failure.
Golden regenerated: terminal-document-golden.txt 105,446 -> 105,968 bytes, document
723,480 -> 724,002. 20 lines out, 36 in, all at the five sites above and nowhere else:
+ (new, top of the IIFE) function postToReactNativeWebView(message) { if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(message)); } }
+ (new) function createEngineTerminal(options) { return new Terminal(options); }
+ (new) function createEngineUnicode11Addon() { return window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon ? new window.Unicode11Addon.Unicode11Addon() : null; }
+ (new) function createEngineWebglAddon() { return window.WebglAddon && window.WebglAddon.WebglAddon ? new window.WebglAddon.WebglAddon() : null; }
- " pendingTerm: null"
+ " pendingTerm: null," and four fields: postToHost: postToReactNativeWebView, createTerminal: createEngineTerminal, createUnicode11Addon: createEngineUnicode11Addon, createWebglAddon: createEngineWebglAddon
- flog's nine lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify({ type: "log", tag: "[fit]" + tag, payload })); }"
+ flog's five lines "scope.postToHost({ type: "log", tag: "[fit]" + tag, payload });"
- " if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) {"
+ " if (!scope.term) {"
- " addon = new window.WebglAddon.WebglAddon();"
+ " addon = scope.createWebglAddon();" then " if (!addon) {" / " return false;" / " }"
- " scope.term = new Terminal({"
+ " scope.term = scope.createTerminal({"
- " if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {" / " try {" / " scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon());" / " } catch {"
+ " try {" / " const unicodeAddon = scope.createUnicode11Addon();" / " if (unicodeAddon) {" / " scope.term.loadAddon(unicodeAddon);" / " } catch {"
- notify's three lines "if (window.ReactNativeWebView) { window.ReactNativeWebView.postMessage(JSON.stringify(msg)); }"
+ " scope.postToHost(msg);"
Nothing else in the document moved: the emitted indentation, statement order and every
other literal are byte for byte what they were.
Two pinned readers follow the move. `terminal-webview-payload-hash.test.ts` takes the
new length and digest. `terminal-webview-text-zoom.test.ts` kept both WebGL assertions
and aimed them where the text now is: `window.WebglAddon.WebglAddon` and
`new window.WebglAddon.WebglAddon()` are asserted on the scope preamble rather than on
the recovery module, and the recovery module is asserted to call
`scope.createWebglAddon()`. `host-seams.test.ts` is the new pin: it builds a scope
before the globals exist to show the defaults read the window when they post, shows
each addon factory answering null when the engine has none, and drives a host message
in and a notify out with all four fields set, asserting the bridge is never touched.
Red before this commit at 6 of 7 cases.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* build(mobile): write the xterm stylesheet as its own generated artifact
The page mounts xterm itself, so it needs the engine's stylesheet and must never
resolve the engine string: 612 KiB of minified IIFE built to be injected as text into
a WebView document, unusable under the shell's `script-src 'self'` with no nested
frame to load one into, and the largest single module the session route's closure
would carry. Both lived in `terminal-webview-engine.generated.ts`, so one import of
the CSS pulled the string in behind it.
`build-terminal-webview-engine.mjs` now writes `terminal-webview-engine-css.generated.ts`
beside it from the same read of `@xterm/xterm/css/xterm.css`, with the same comment
strip and the same `http%3A//` scrub the no-external-URL gate wants. Gitignored beside
its neighbour and written by the same postinstall step, so a fresh tree gets both or
neither. `document-shell.ts` takes the CSS from the new module and the engine string
from the old one; `build-terminal-document-fixture.mjs` and the two tests that hold
both constants read them from their new homes.
The document did not move: `terminal-document-golden.txt` is byte for byte what the
last commit left, 105,968 bytes, and the payload digest is unchanged.
The fence is `config/scripts/mobile-web-terminal-engine-closure.test.mjs`. It walks
every module under `src/terminal/document/` as an entry point — the document is one
script whose modules reach each other by side effect, so no single one of them roots
a graph holding the rest — and asserts the engine string is in none of their closures,
with two modules named as the precondition that the walk resolved anything at all. The
native document's own closure is asserted to still hold both generated modules, so the
first case cannot pass by the CSS having gone missing. And the third case plants a
document module that imports the engine string in a scratch tree and shows the walk
reports it, which is what makes the absence above a measurement.
`mobileWebAppRouteClosure` is now a caller of `mobileWebAppEntryClosure`, which takes
the entry points and an optional working directory; the route closure's own two entry
points and its extensionless-specifier reason are unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the dead URL-tap constant and two stale reflow guards
Round 1 fixes, all three folded here.
1. `URL_TAP_WEBVIEW_JS` is gone from terminal-webview-url-tap.ts, with
`document/url-tap.test.ts` deleted alongside it. The document is generated
from its modules now, so that constant was a second copy of the URL-tap group
with no consumer but its own tests. terminal-webview-url-tap.test.ts's
resolver harness reads the document's own text instead, the path-tap,
url-tap, osc-link-tap and surface-tap modules in document order through
`generatedDocumentModule`, which refuses unless the document carries each
verbatim. Its 33 expects all stay. One mechanism-only assertion went with the
file: `document/url-tap.test.ts`'s single `compareTerminalDocumentScripts`
pin of the three emissions against the constant, which the flip test's
whole-document pin already covers. The file's other exports stay.
The deletion surfaced a third reader. terminal-webview-scroll-routing.test.ts
concatenated terminal-webview-url-tap.ts into its `source`, and its
`notify({ type: 'terminal-tap' });` assertion was matching the constant's
single-quoted text, not the document. The read is dropped, since nothing else
in that file needed it, and the assertion is the document's form:
notify({ type: 'terminal-tap' }); -> notify({ type: "terminal-tap" });
Its 95 expects stay. Leaving the read in place would let a document assertion
pass against a module source, which is the hazard this lane exists to remove.
2. terminal-webview-reflow.test.ts guarded a template placeholder that no longer
exists, so it could not fail:
expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}')
-> expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1)
Same intent against the generated document: the reflow module's emitted text
is in the document exactly once. The case is renamed to say so and the
comment above it describes the generator, not the deleted template.
3. Same file, the routine assertion still passed as a substring of the qualified
call; qualified as line 30 already was:
term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows);
Its 22 expects stay.
Controls, each verified to have changed the file first, all red, tree green
after restore:
osc-link-tap return parsePathLineCol(value) -> url-tap test, 3 failed
surface-tap notify({ type: 'terminal-tap' }) -> scroll-routing, 1 failed
reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
module order 'reflow' listed twice -> reflow test, expected 2 to be 1
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): mount the terminal document in the page instead of a WebView
`react-native-webview` has no web build that renders anything: measured, it paints the
line "React Native WebView does not support this platform" where the terminal was. So
the page mounts the document itself — xterm imported from `@xterm/xterm` with the
unicode11 and webgl addons, and the document's own modules imported in the order the
generator emits them — behind the identical `TerminalWebViewProps` and
`TerminalWebViewHandle`.
Written as one implementation, not two. `use-terminal-webview-controller.ts` is
everything `TerminalWebView.tsx` did that was not about `react-native-webview`: the
readiness handshake, the pending queue, the write coalescer, the notify dispatch and
the whole imperative handle. Its two arguments are the difference between the hosts —
a sink that takes one `TerminalWebViewCommand`, and whether a foreground return has to
re-prove the document with a ping. The native component posts across the bridge and
answers yes on iOS; the web component calls `handleMsg` and answers no, because its
document is the page's own modules and there is no second content process to lose. A
second copy of that file is the fork the series exists to avoid, since the handle is
the contract every consumer holds.
`terminal-webview-ready-promises.ts` carries the two promises the handle hands out,
`awaitReady` and `measureFitDimensions`, which the controller's length made a module.
`document-style.ts` and `document-markup.ts` carry the stylesheet and the elements out
of the document shell; the shell composes them and the golden is byte for byte
unchanged, 105,968 bytes. `terminal-webview-html.web.ts` answers those two and the
caret options and nothing else, so the page resolves no document string and no engine
string.
`terminal-web-document-mount.ts` is what the WebView's HTML used to be: it plants the
stylesheet and the markup, sets the four scope seams, and reaches the modules by one
dynamic import — they read their elements as they are parsed, so a static import would
hoist above the planting and leave every one of them holding null.
`page-document-modules.ts` is the order, `message-bridge` excluded per ruling 19
because on the page those `message` frames belong to the shell; its one non-bridge
duty, the window-resize refit, is re-armed by the mount.
`page-document-module-order.test.ts` holds that list against the generator's own,
so a sorted import list or a module added on one side cannot pass.
Two page-side degradations, both bounded and both stated. The document assigns
`window.onerror` as it is parsed, so while a terminal is mounted page errors reach its
reporter; the mount restores the previous handler on dispose. And a browser that
refuses a WebGL context gets the DOM renderer, which is the fallback `webgl-recovery`
already has for a context loss, with a `[fit]webgl-unavailable` notify saying so
rather than a silent halving of the drain rate.
`terminal-webview-consumer-census.test.ts` is the pin the substitution rests on: it
scans `src/session` and the terminal directory for an import of the component file by
name, of `terminal-webview-html`, of either generated engine module or of anything
under `document/`, finds none outside the component and its mount, and shows on
planted text that it would report each. `mobile-web-terminal-engine-closure.test.mjs`
gains the component's own closure: `TerminalWebView.web.tsx` and
`terminal-webview-html.web.ts` are in it, the engine string, the native HTML module
and `message-bridge` are not.
Four source greps follow the code into its new home, every assertion kept:
`terminal-write-coalescer-boundaries` reads the coalescer's four boundaries in the
controller, and reads the two lifecycle clears once in `resetReadiness` plus both
WebView callers in the component; `terminal-webview-reflow` and
`terminal-webview-scroll-routing` read the handle in the controller and the two timers
in the promises module (`measureResolveRef.current === finish` -> `measureResolve ===
finish`, `void p.finally` -> `void pending.finally`).
One behaviour was nearly lost and is pinned by an existing case: the native
foreground-recovery ping reads `Platform.OS` at the moment of recovery, not at render,
so the transport asks a predicate rather than carrying a boolean.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): render the page's terminal in a browser under the shell's policy
Everything below the contract is new on the page: xterm is an import rather than a
612 KiB string in a WebView document, the document's modules run in the page's own
realm, and the elements they read by id are planted by the component. No module test
settles whether that opens at all under `script-src 'self'` with neither
`unsafe-inline` nor `unsafe-eval`, or whether a real terminal byte stream reaches the
buffer intact.
Three cases in the C6 render harness, against the bundle built by the real builder and
served under the policy parsed out of the shell's own Kotlin constant.
The stream is built for the grid rather than committed: an SGR colour change per cell,
an erase-to-end and an absolute cursor position per row, run out past the host's own
48 KiB chunk. 49,302 bytes applied through `handle.write`. It is read back through the
document's own path — select all, then the Copy button the overlay carries — so the
oracle is the component's `onSelectionCopy` prop and not a private reach into xterm:
6,133 characters, both edge markers present, and no escape byte or SGR text left in
them, which is what says the parser consumed the stream instead of printing it.
The second case takes a fit through the handle, which on the page is a command in and
a notify back with no bridge between, and carries design §8's cheap half of the IME
question. It first pins something that changes where that probe can even point:
xterm's own textarea is inert by the document's design — `query-reply.ts` makes it
read-only, untabbable and `inputmode=none` so touch and hardware keys go to the
screen's input — so text entering a terminal on the page arrives at a `TextInput`, and
that is what is typed into. Chrome reports `insertText` with `isComposing` false for
each character, logged as `[c7.5][beforeinput]`. A composing IME on a real soft
keyboard is the device step and this does not claim to answer it.
CSP violations are counted with a `securitypolicyviolation` listener installed before
anything else runs, which is stricter than the console-error filter the other render
checks use — and the first thing it found was not the terminal's. The page entry
carries Zod, whose `new Function` probe is swallowed by its own catch, so
`script-src: eval` is refused once on any page route with no page error and no console
line. The first case is the control that names it, on a route that mounts a marker and
no terminal; the two terminal cases subtract it and report zero of their own. Zero
page errors and zero console errors besides.
No route serves this screen until C7.7, so the component is bundled through a scratch
route tree, naming it extensionlessly so the bundler resolves `TerminalWebView.web.tsx`
exactly as a real route would. That step retires when the session route is registered.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): retire the last module concatenator and guard the order list
Round 2 fixes, all five folded here.
1. Deleted terminal-webview-html-source.test-support.ts.
`readTerminalWebViewHtmlSource()` had no consumers left once the behavioural
tests moved to the generated document, and it was the last thing that built a
document-shaped string by concatenating module sources — its filter admitted
`.test-support.ts` files too, so it could have grown one. Confirmed by grep
that the only occurrence of either name in the repository was its own
declaration.
2. New document-module-order.test.ts asserts both directions: the non-test,
non-test-support `.ts` files under `document/` are exactly
`{document-scope} + TERMINAL_DOCUMENT_MODULE_ORDER + {document-constants}`,
and no name is listed twice. `document-constants` is the one exception
because it is never emitted: its exports are substituted into the modules
that import them as literals, so the document carries its values without
carrying the module. A module added here and forgotten there would be dead
code that reads as live; a name left after its file goes makes the generator
throw at build time rather than at review time.
3. terminal-document-flip.test.ts's docstring now carries the retirement policy
from ruling 18: the test is the proof of the flip and holds only while no
module changes, the first lane that must change one retires it together with
`terminal-document-pre-flip-script.txt`, and the standing pin from then on is
`terminal-document-identity.test.ts`, whose fixture regeneration is a review
event. Comment only.
4. terminal-document-equivalence.test-support.ts said 57 reassigned variables
and "Four classes and no others". It now says 73 declaration sites and eight
classes, with each class's measured figure named. Two doc comments sat above
the wrong declaration and were moved onto what they describe: the
`NUMBER_GLOBALS` one down to that constant, and the printing one down to
`significantTokens`, with `STRICT_DIRECTIVE` given its own line.
5. build-terminal-document-script.mjs substituted constants with
`replaceAll(regexp, literal)`, where `$&`, `` $` ``, `$'` and `$n` in a
constant's value are read as replacement patterns. The substitution is now
`substituteDocumentConstants`, exported so it can be tested directly, and
replaces with a function.
Controls, each verified to have changed its input first, all red, tree green
after restore:
plant document/zz-planted-module.ts -> order guard, "+ zz-planted-module"
drop 'wheel-scroll' from the order -> order guard, "+ wheel-scroll"
revert to the string replacer -> 4 failed, "a $& b" became "a marker b"
The `$n` case is deliberately absent from that table: the pattern has no capture
group, so `$1` is already literal under either form and a case for it could not
tell them apart.
The document did not move. The byte golden, the digest and the flip test's class
table are all unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): measure what the page terminal costs the session route's closure
The session route is not served on the page until C7.7, but the closure the bundler
would walk is the same one and the terminal is the largest thing in it. Measured
against this branch's base, `ota-c7-1-terminal-document` at 51ae7b1b03:
modules 4316 -> 4363 (+47)
local modules 927 -> 971 (+44)
minified bytes 3,930,787 -> 3,883,532 (-47,255)
The route gets smaller. It sheds six modules — the native component, the 612 KiB
engine string, the 105 KiB generated document script, the HTML module and the shell
and close around it — all string literals of a program the page cannot run, and gains
fifty: the component, its mount, the stylesheet and markup modules, the two the
controller split made, and the document's own thirty-nine, with xterm and the two
addons behind them at 607,945 bytes minified ESM on their own. `document-terminal-shape.ts`
is not among them: it declares types and esbuild emits nothing for it.
The census pins the trade in both directions, because "the engine string is absent"
passes just as well on a closure that resolved nothing: the six shed modules are
asserted gone, the eight gained ones and the three xterm packages asserted present,
and the document asserted whole except `message-bridge`, which ruling 19 keeps off the
page. It also holds the 16 px seam where C7.2 found it — nine offenders, no unresolved
styles — since the terminal's modules joining this closure is exactly the change that
could add a tenth unread.
The page-closure families were run before and after on the full corpus, never a
filtered scenarios file. Both sides: 7 files, 879 tests, exit 0 — and those 879
include the four page-closure pins, which assert the verdict of every golden C1, C2,
C3 and C5 record, so an unchanged run is an unchanged verdict table rather than an
unmeasured one. Per family with `vitest -t "session.terminal"`, both sides 19 passed
and 773 skipped. No family moved, which is what an inert lane should show: this
branch changes no RPC, no opcode, no grant and nothing the recorder reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): clear the changed-code gate findings this lane introduced
Eleven findings from `check-changed-code-quality.mjs` against the base, all in code
this lane added, none of them a behaviour change.
Two type assertions lost their directive to the formatter. The xterm `Terminal` cast
sits on the second line of a wrapped arrow body, so a directive above the assignment
aims at the wrong line; it moves onto the line the assertion is on. The WebGL addon
cast had no directive at all. Both keep the same `SAFETY:` rationale on one line,
which is the only shape oxlint reads.
Two more assertions in `host-seams.test.ts` are gone rather than annotated. The
terminal double's `element` is a getter over a local the double's own `open` writes,
and `withSeams` reads each field it is about to overwrite through
`getOwnPropertyDescriptor` instead of indexing the scope with a cast.
Then three `eslint-disable no-console` directives that disabled nothing, an
`oxlint-disable` for `react-hooks/exhaustive-deps` that the rule never fired on — the
reason it carried stays as a comment, since the dependency list is still deliberate —
and one duplicated `node:fs/promises` import.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(config): name the closure helper what main already named it
A trial merge against `origin/main` conflicts on this function: main grew the same
generalisation independently, as `mobileWebAppModuleClosure(entryModules)` with
`mobileWebAppRouteClosure` delegating to it and three callers in the page-closure
families census. This branch is based on `ota-c7-1-terminal-document` and so cannot
merge main, but it can stop being a second spelling of the same thing.
Taken over wholesale: main's name, its parameter, its extension stripping and its
comment, with `mobileWebAppRouteClosure` reduced to the one-line delegation main
already has. The only addition is an options bag carrying `absWorkingDir`, which the
engine-closure census needs to plant a module in a tree of its own and show the walk
would report it; the real measurements never pass it. What was a whole-function
conflict is now that one hunk.
The census case that measured the native document had named
`terminal-webview-html.ts` with its extension, which main's stripping does not allow.
It names `terminal-webview-html/document-shell` instead — the module that actually
reads both generated ones — which is the better probe anyway and needs no extension
to resolve, since it has no `.web` sibling.
`web-overrides.json` also conflicts and is left alone: both sides append entries to
one list and the resolution is mechanical.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(config): put the two closure helpers in main's order
The previous commit took main's name and signature but left the route closure below
the module closure, where this branch had written it. Git merged both orderings and
produced two copies of `mobileWebAppRouteClosure` on the merged tree, which oxlint
reports as a duplicated export — a red the trial merge found and neither side's own
lint could.
Same order as main now: the route closure and its docstring first, the module closure
under it. The trial merge is down to one hunk, the `absWorkingDir` parameter, and the
merged tree lints clean.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): make the flip comparator refuse what it was accepting
Round 2 items 6 and 7, both in the equivalence instrument.
6. `isPrinterDisambiguation` accepted any `name2` facing `name` without proving
the two were the same binding, so an unrelated rename ending in a digit would
have been counted rather than refused. It is replaced by `UNSHADOWED_RENAMES`,
an explicit list of pre-flip name, generated name and declaring module. The
whole script has one entry: `term2` -> `term` in `query-reply`, which is the
`term` parameter of `attachTerminalQueryReplyBridge` and its six uses, seven
sites in all. That is stated in the docstring rather than encoded as a second
pin, since the flip test already pins the total.
7. Brace absorption treated every unexpected `{` as a linter-added body and
absorbed any later `}` while one was outstanding, so a bare block anywhere
would have been swallowed. `isBraceableHeadBody` now requires the open to be
the body of `if`, `for`, `while`, `else` or `do` — walking a `)` back to its
`(` and reading the keyword before it — and `matchingCloseIndex` records the
index the close must appear at, so the absorbed `}` is that body's own.
That check had to move ahead of the equality check. Wherever a braced body
ends a block, the baseline's next token is a `}` as well, so pairing them
would consume the wrong one and leave the counts right for the wrong reason.
Both refusals are tested over snippets:
function f() { return value2; } vs return value;
-> token 6: expected name value2, generated name value
let value = 1; use(value); vs { let value = 1; } use(value);
-> token 0: expected name let, generated {
and the braceable heads are tested one by one, `if`, `for`, `while`,
`if`/`else` and `do`, so the new rule is shown to accept every shape the `curly`
rule produces and not only the one the document happens to exercise.
Controls: restoring the shape rule fails the first refusal case and nothing
else; restoring the accept-any-brace rule fails the second and nothing else.
The eight counts did not move: 609, 73, 373, 279, 36, 17, 4, 7.
Splitting out `terminal-document-tokens.test-support.ts` is not cosmetic. The
tightened rules put the file over the 300-line cap, and a `max-lines` disable is
forbidden, so the token reader moved to its own module: that side answers what a
script says, and says nothing about which differences between two of them are
allowed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(config): take main's docstrings for the two closure helpers
The order matched but the prose did not, so the trial merge still conflicted on the
whole block. Both docstrings are now main's own text, with one sentence trimmed: main
names `MobileBrowserPane` as the first component with a pin of its own, which is C6's
fact and not one this branch can assert.
What remains between this branch and main in this file is the `absWorkingDir`
parameter, which is what the engine-closure census plants a module with.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): write the page terminal's notify sink in an effect, not during render
React Doctor's one error on this branch, and a real one: `receiveRef.current = receive`
ran during render. React may replay or discard render work, so a mutation made there
can leak from UI that never commits — and this ref is read from a callback the mounted
document keeps, which outlives the render that installed it.
Moved into its own effect, declared above the mount effect so the first read already
sees a sink. `check-react-doctor-changed.mjs` goes from exit 1 to exit 0.
Found late because the first run of that gate was read through `| tail`, which reports
the pipeline's last command rather than the gate's own exit code.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): teach C7.1's order guard the three modules this lane added
The guard C7.1 landed says the document directory and the order list name the same
modules. On this branch three files are in that directory and not in that list, so it
was red on the merge — which is the guard working, and the fix is to name each of them
with its reason rather than to loosen the scan.
document-host-seams emitted, but ahead of the scope rather than inside the order
list, because the scope's defaults are its four functions and
the factory runs as the script is parsed
document-terminal-shape types only; esbuild emits nothing and an empty emission
would add a blank line to the document
page-document-modules the page's entry, not the WebView's, holding the same order
for a host that has no generator to splice them
Named one by one, not filtered by a pattern, so a fourth cannot join them by looking
similar. A third case asserts the seams module is neither in the order list nor the
scope module, which is the ordering the first two cannot see.
Red before this commit: C7.1's version of the file on this tree reports
`document-host-seams` and the other two as directory modules the list does not name.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): re-measure the session closure against the merged C7.1 base
Same module counts — 4316 -> 4363 and 927 -> 971 local — but the minified figure moved
from -47,255 to -55,561, and the 8,306-byte difference is C7.1's rather than this
lane's. Its round-1 fold deleted `URL_TAP_WEBVIEW_JS` from `terminal-webview-url-tap.ts`,
a module that enters this closure only once the page's component reaches it, so the
saving shows on the after side and cannot show on the base. Both readings are recorded
with the commit each was taken against, because a number with one base named and
another used is the kind of thing a reviewer cannot check.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): retire the flip comparator with the pin it was built for
The token comparator had exactly two consumers and neither survives. `document/url-tap.test.ts`
went in C7.1's own round-1 fold at 8da7680c9b, and `terminal-document-flip.test.ts`
went in this lane's first commit under ruling 18, because the flip pin holds only
while no module changes and C7.5 is the lane that changes them. What was left was a
tool, its token reader and a test of the tool, answering to nothing.
So `terminal-document-equivalence.test-support.ts`, the
`terminal-document-tokens.test-support.ts` C7.1 split out of it, and
`terminal-document-equivalence.test.ts` all go. That closes round 3's two LOW notes on
the comparator — bounding an absorbed body to one statement, and refusing a bare block
as `use();` against `{ use(); }` — since there is no comparator left to tighten. The
standing pin on the document is the whole-document byte golden, which is a stronger
claim than token equivalence ever was: it admits no normalisation at all.
`document-module-order.test.ts` gains the case its exception list was asserting in
prose. `document-terminal-shape` is not in the order list because esbuild erases a
module of type declarations to the empty string, and emitting it would put a blank
line in the document rather than a program; that emission is now measured and pinned
as `''`. If the module ever declares a value the case goes red and the module belongs
in the order list with its own line in the golden diff.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): make the document's error reporter the sixth host seam
Ruling 19 reaches `window.onerror`. The document assigned it as it was parsed, which
inside the WebView is taking nothing from anyone — that document owns its page — and
on the page is a guest displacing whatever the host installed. Restoring it on dispose
was a patch over the takeover, not an answer to it: while a terminal was mounted, every
page error still went to the terminal's reporter.
So `scope.installErrorReporter` joins the five, with today's assignment as its default.
`host-notify` hands it the same handler it always installed, and the WebView's document
is the program it was.
The page supplies its own: an `error` listener that adapts the event to the reporter's
arguments, added on mount and removed on dispose, and `window.onerror` is never
written. This one seam is *called* as the modules are parsed rather than later, so the
mount now reaches `document-scope` on its own first and sets every field before a
single document module runs — which is also the safer order for the other five.
Golden regenerated: 105,968 -> 106,116 bytes, document 724,002 -> 724,150. Three lines
out, seven in, and nowhere else:
+ (new, beside the other defaults) function installWindowErrorReporter(report) { window.onerror = report; }
- " createWebglAddon: createEngineWebglAddon"
+ " createWebglAddon: createEngineWebglAddon," and " installErrorReporter: installWindowErrorReporter"
- " window.onerror = function(msg, source, line, column, err) {"
+ " scope.installErrorReporter(function(msg, source, line, column, err) {"
- " };"
+ " });"
`terminal-webview-payload-hash.test.ts` takes the new length and digest.
Pinned on both sides. `host-seams.test.ts` gains the default taking `window.onerror`
and a host that installs its reporter elsewhere leaving it null. The render check adds
a browser case: `window.onerror` is null before the mount, null after it, and null
after the component unmounts — with a real uncaught error thrown in between and
asserted to reach `onEngineError`, so the first reading cannot pass on a terminal that
had simply stopped reporting, and a second error after dispose asserted to reach
nothing. Red with the mount's override removed: `expected undefined to be null`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): empty the session closure's react-native-webview list
C7.6's census on main names the terminal as the last consumer and says whose work it
is: "The terminal is the third and is C7.5's, which drops the engine string and mounts
xterm in the document". This is that lane, so the list it left is now empty and the
session closure reaches `react-native-webview` from nothing at all.
Emptying a list weakens the case that reads it, because an empty result is also what a
scan that read no file reports, so two things change with it. The main case gains its
preconditions: the walk read a closure of more than 500 local modules, and it read the
three web siblings whose native halves are exactly the modules that would have
imported the package. And the control stops walking the list — with the list empty that
compared nothing against nothing — and walks the three native files instead, which do
import it, alongside the three web siblings, which do not.
`TerminalWebView.web.tsx` joins the answered list, so the case that the builder
resolves a web sibling rather than its native file now covers all three.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): pin the onerror seam against a handler the page actually owns
The case read `null` before the mount, while mounted and after dispose. That is true
but weak: a terminal that assigned `null` over a real handler would pass it, which is
exactly the takeover ruling 19 forbids.
So the page now installs a handler of its own in an init script, before the bundle
loads, and the assertion is identity — `window.onerror === globalThis.__orcaSentinel`,
compared inside the page because a function does not survive `evaluate` — at all three
points. Between them an uncaught error is thrown and both reporters are asserted to
see it: the page keeps the handler it installed, and the terminal's own listener still
works, so the readings cannot pass on a terminal that had simply stopped reporting.
After dispose a second error reaches the page's handler and not the terminal's, which
is what taking the listener off has to mean.
The `null` reading stays as its own case, because the other half matters too: on a page
that installed nothing the terminal must not leave a handler behind for the next
consumer to find.
Both go red with the mount's `installErrorReporter` override removed — `expected false
to be true` and `expected undefined to be null`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): start the terminal document per mount (ruling 20)
Round 1's blocking finding: ES module bodies run once per page, so the page's
second mount re-imported nothing and inherited the first mount's elements,
listeners and error reporter. Measured after a remount: zero .xterm nodes in
the live DOM, no selection overlay, nothing reaching onEngineError, and
onWebReady still firing.
Ruling 20: no emitted module does work as it is parsed. Every top-level effect
moved into an exported per-module start function — 86 statements across 14
modules, plus three parse-time captures whose declarations became typed lets.
The generator emits one call sequence in module order at the foot of the
document, so the native script still runs them once at parse; the page runs the
same sequence per mount and dispose undoes the three that outlive the host
element (tap-dispatch, webgl-recovery, host-notify).
installErrorReporter now hands back its own undo, so it stays five seams at six
document sites rather than growing a sixth.
M2: a failed document chunk was an unhandled rejection with no engine error.
It now goes down the document's own reporting path, so the overlay names the
cause instead of the 15s readiness watchdog. Pinned by refusing that chunk at
the wire in the render check.
L3: the seam count now reads five fields / six sites / three files everywhere.
L4: three unrelated web-overrides entries keep main's escaping.
Golden: 106116 -> 108134 bytes; payload 724150 -> 726168, sha256
2d089b8d9ab9491eed79cf7fe353dde6444799a3d297269ab660aee63ba56c82.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the parse-time census tree without assertions
The changed-code gate refuses type assertions. The walker reached node fields
through `as Record<string, unknown>`; it now reads them with Object.entries,
which is checked and says the same thing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): move the document's state onto the scope (ruling 21)
Round 2's blocking finding, and ruling 20's second half: moving parse-time
effects out of the module bodies left the state behind. Nine module-level
bindings survived a mount, so the second terminal inherited a spent non-fatal
error budget (reporting nothing however it failed), the first terminal as its
committed surface (disposing it twice), and the first mount's momentum loop.
Every mutable binding now lives on the scope, and the scope carries one reset
the start sequence calls first: native once at parse, the page once per mount.
Moved, by module: query-reply 1, surface-swap 3, text-scaling 2, fit-scale 1,
host-notify 2, selection-state-and-eviction 1, mouse-click-drag 1,
tap-dispatch 1, surface-touch-gestures 1 — thirteen fields, two of them the
objects tap-dispatch and surface-touch-gestures used to own outright.
Because the reset is now the one initialiser, the start functions keep only
what it cannot do: element reads, listener installs and the reporter install.
Four start functions emptied and went; terminal-handle held nothing else and
is deleted from the order list. The scope type splits into state and host
seams, because a reset must restore the first and never the second.
Every stop function cancels what its module scheduled. Timers go back through
the handles the scope already held; frames go through the scope's own
scheduleDocumentFrame, so dispose can take back the ones no module tracks by
id. terminalGeneration and fitRetryToken carry forward across a reset, because
a stale callback tests itself against them and a reset to zero would make the
old number match again.
L2: the seams-before-scope case asserts the order in the emitted document, not
just non-membership. L3: the style docstring says what is true — one scope per
page, so mount refuses a second live document and gives the page back when a
mount fails.
Golden: 108134 -> 108047 bytes; payload 726168 -> 726081, sha256
6a5a3216aab7b99daeb26bcdcfe6e325c415e5ef60c16405eea329ca141405fe.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): refuse frames from a stopped document
The frame case went red under full-suite load: tearing the terminal down runs
the engine's own disposal, which calls back into these modules, and a frame
asked for on the way out was owed by nobody because the cancel had already run.
A stopped document now asks for no frames at all, so the ordering inside
dispose stops mattering.
The render case is also rewritten around the work that survives a loaded
machine. It gives the terminal a scrollback and sends one wheel, which reveals
the scroll indicator and arms the 550 ms timer to hide it again, and the
boundary between the two mounts is drawn when the first terminal leaves the
page rather than when the component is told to go — React unmounts on its own
schedule, and a callback that runs while the first terminal is still up is not
a leak. The precondition counts what the document scheduled under the first
mount, so an empty leak list cannot mean the wheel reached nothing.
Verified both ways at this head: red with stopViewportTransform and
cancelDocumentFrames removed, green with them, and green in the whole
config/scripts suite.
Payload 726081 -> 726195, sha256
67a7b82bcd87b811214d02ca0e2f29bb634da47607e50f701bf153b9bf7323ef.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): style only what the page mount owns
CodeRabbit on document-style.ts:16. The mount appended the document's whole
stylesheet to the page head, so its `*`, `html` and `body` rules restyled every
screen the shell can show and went on doing it after unmount. Ruling 19's
shape: the native document owns its page and keeps the sheet as it is; the page
mount may style only what it owns.
The sheet splits into TERMINAL_DOCUMENT_ROOT_STYLE and
TERMINAL_DOCUMENT_ELEMENT_STYLE, composed in the same order, so the emitted
document does not move for the split - verified byte-identical before the seam
below. The page injects the element half only, with every selector held under
the host's own class, and xterm's sheet goes through the same rewrite. The
rewrite refuses an at-rule rather than passing its inner selectors through
unscoped.
A second leak of the same kind was in the same measurement: applyTerminalTheme
wrote the terminal background straight onto `html` and `body`. That is a sixth
seam - six fields at seven document sites now. Its default does exactly the two
writes it did; the page paints the host element instead. Emitted lines, old to
new: `paintWindowDocumentBackground` added beside the other defaults (3 lines);
`paintDocumentBackground: paintWindowDocumentBackground` added to the seam
factory (1 line); in applyTerminalTheme, the two `document...style.background`
writes become one `scope.paintDocumentBackground(background)`.
Leaving the sheet in the head after unmount is kept, and is now defensible: the
host drops the class on dispose, so every rule in it matches nothing until the
next mount.
The render check gains a case comparing `body` and `html` computed styles,
while mounted and after dispose, against a page of the same application with no
terminal on it, and asserting no rule of the injected sheet matches an element
outside the host. Verified red both ways at this head: unscoped sheet moves
`background-color` and `box-sizing`, and the inline theme write moves
`background-color`.
Payload 726195 -> 726363, sha256
9950f1770cd85ad2f80c69e074111869f6c66a724c87b66ba81f1ff10318a0ce.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): give the page mount's rules and frames their own oracles
Round 3 blocks on evidence, not on shipped behaviour. Each item:
H1. The scoping had no positive oracle: dropping the host class, or injecting
an empty xterm sheet, left the render check green, because every assertion was
about rules not escaping. The containment case now also reads four things off
the live elements under the host — xterm's own `position: relative`, the
viewport's `overflow-y: hidden`, that the viewport reserves no scrollbar width,
and the overlay's `position: fixed`. Red both ways: no host class reds all
four, an empty engine sheet reds the first.
H3. `cancelDocumentFrames` had no witness: the only leak the timer case could
see was the 550 ms hide timer, which its own module's stop cancels. There is
now a case whose witness is a frame taken through `scheduleDocumentFrame` —
the fit retry loop, with the surface hidden so the fit never commits and one
frame is always owed at dispose — and it reds when only `cancelDocumentFrames`
is removed. A unit covers the registry itself: a frame is held until it runs,
a cancel takes back every pending one and then refuses to schedule, and a reset
re-enables it.
The two scheduling cases now assert on their own witness kind, so neither can
stand in for the other, and the recorder judges a leak by whether the
`#terminal-container` that was on the page at schedule time is still in the
document — React unmounts on its own schedule, and a callback that runs while
the first terminal is still up is not a leak. The timer witness moved from the
scroll-indicator timer to the long-press timer, because the first needed a
drained scrollback and raced the engine under load; its precondition caught
that rather than passing.
L1. The two seam docstrings each sit on their own function.
L2. The parse-time census plants an element-read initialiser, which the
statement filter cannot see, and an inert object literal, which a reader that
flagged every initialiser would wrongly report.
L3. Dispose disposes `scope.committedTerm` as well as `scope.term`: a swap that
never committed leaves two terminals and only one was reached. Deduplicated,
because they are the same object whenever no swap is open, and pinned both ways.
L5. `document-style-scoping.ts` joins GAINED_OUTSIDE_THE_DOCUMENT.
Golden unchanged at 108,329 bytes; payload and its hash unchanged. Render
check: 12 cases.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): make the page document's dispose idempotent and owner-checked
CodeRabbit on terminal-web-document-mount.ts:180. Dispose was neither. A
handle outlives what it built - the component keeps one in a ref and React can
run a cleanup after a later mount has started - and everything dispose touches
is shared: the scope, the module sequences, window.__engineErrors. So a second
call, or a call from a handle whose document had already been replaced, tore
down the terminal that was on the screen and handed the page away while it was
still in use.
Each mount now carries a token, and dispose acts only when that token is still
the live one. A token rather than the host element or its class: two mounts can
be handed the same element, because the page remounts into a host React has
reused, so an element is not an identity and the class says only that some
document is using the host. The failed-mount path releases the page under the
same check.
Pinned both ways, red with the check removed: disposing twice leaves a terminal
put back after the first teardown alone, and a stale handle disposed after a
second document mounted changes nothing - the live markup stays, its terminal
is not disposed, and the page is still refused to a third mount.
Golden unchanged at 108,329 bytes; payload and hash unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): let a pending page mount be disposed before its import lands
Round 4 on #21809.
H1. The mount claimed the page before its dynamic import and handed back a
promise, so a component cleanup that ran while the chunk was still in flight had
nothing to dispose: the claim outlived the mount it was made for, and Reload —
the recovery ruling 20 names — was refused as a second document. The claim, the
markup and the handle are now made synchronously, `ready` settles on its own,
and a mount disposed while its import was in flight releases without starting
anything. Pinned in the render check by holding the document chunk 20 s past the
15 s readiness watchdog, clicking Reload and waiting for the second mount to
become live; red at that wait before the change.
M1. The frame case's precondition asserted that a frame had been asked for while
the document owned the page, not that one was owed when it was disposed. The fit
retry commits on its first attempt whenever the grid still measures, so a dispose
between two refits owed nothing and agreed with an empty leak list for exactly
the reason under test — one run in five. The refit and the unmount now share one
discrete click, which React flushes before the event returns, and a mutation
observer reads the registry at the instant the host is emptied. Five red runs
without `cancelDocumentFrames`, all on the leak and none on the precondition,
and five green with it.
M2. Two mounts handed the same element, which is what the token is for: the
other six cases use a different element each, so a host comparison passes all of
them.
L1. A throw inside the start sequence released the token but ran no stop, leaving
the host-notify error listener installed until the next reset nulled its undo.
The sequence now unwinds the starts that completed, in reverse, before it
rethrows.
L2. A render case comparing the window and document listeners the page holds
with no terminal on it, before and after a mount, so a stop that forgets one is
a failure rather than a second copy per terminal ever shown.
L4. Separated the stacked docstrings in the parse-time-effects census.
The render check's bundle, server, browser and page helpers move to their own
fixture module: the cases are what is under review and the scratch route tree is
not, and the file was 16 code lines under its cap.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): count the page document's leaked frames from dispose, not from detach
CI's addendum to round 4's M1: the frame case failed with the fix present,
`expected [ Array(1) ] to deeply equal []`, on a slower runner.
What scheduled it: `applyFitScale`, through `scheduleDocumentFrame` like every
other frame the document asks for — the document has no other rAF call site. It
is not an escape from the registry, so the registry is not what changes here.
Why it was counted: React unmounts in two steps. The mutation phase detaches the
host, and the passive cleanup that calls `dispose` runs after it — about 1 ms
later here, 20 to 35 ms later with the CPU throttled 20x, which is the runner
shape this failed on. A frame served in that gap runs with a detached container
while the document is still live and has not been asked to stop, and nothing
could have taken it back: `cancelDocumentFrames` had not been called yet. The
oracle judged by the captured container's connectedness, so it read the gap as a
leak. It now counts only what runs after the last statement of `dispose`, which
is the class coming off the host, observed on the element because React may have
detached it already.
The same reading fixes the other direction. The precondition is read at that
same moment, and the witness is a refit re-armed from a frame of the test's own,
so the document is owed a frame at the end of every frame the browser serves and
a dispose cannot land where nothing is owed. The single refit the case used
before bought one frame, and the retry loop commits on its first attempt
whenever the grid still measures.
Evidence: with the boundary removed the case reproduces CI's `Array(1)` in two
runs of three unthrottled, and in five of five with the CPU throttled 20x, where
the detach-to-dispose gap measures 20 to 35 ms; with it, five green runs; with
`cancelDocumentFrames` removed, five red runs, all on the leak read and none on
the precondition.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop a page mount that lost its claim before it writes the scope
Round 5 on #21809.
F1 (blocking). `buildTerminalWebDocument` had no token, so after its `await
import(...)` the whole body ran whatever had happened in the meantime: it
overwrote the six seams, called `startPageDocumentModules` and added the resize
listener, and only then did the caller's `.then` read the claim and throw the
result away. Everything after that await is shared — the seams are fields on a
module-singleton scope, and the start sequence resets that scope and installs
the document's listeners — so a mount disposed while its chunk was in flight was
writing over a mount that owns the page. The claim is now re-read the instant
the import lands, before any of it, and the build returns null.
`ready` for such a mount resolves rather than rejecting. Nothing failed: the
caller asked for the terminal and then asked for it to go away, and the chunk
arriving afterwards is not something for the error overlay to name. Before this
it rejected with a TypeError from `startSelectionMenuButtons` reaching for an
emptied host.
F2. The rejection handler called `release()` unconditionally, emptying a host the
mount may no longer own. It now releases only when the page is still its own.
Pins, both red first. In happy-dom: mount, dispose, then await ready — no
listener, timer or frame added while it resolves, the six seams unchanged,
`terminalGeneration` unmoved because the start sequence never ran, and the page
free for the next mount. Without the fix that case rejects with the
`startSelectionMenuButtons` TypeError. In the browser, the Reload-while-in-flight
case now reads the page's listeners with no terminal on it and compares them
against a page that mounted once and disposed once; without the fix the
abandoned mount leaves `window error` and `window resize` behind, because the
second mount's scope reset nulls the first mount's reporter undo.
The listener snapshot helper is shared with the mount-and-dispose case rather
than written twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(config): give the render fixture's server and scratch tree back when it cannot start
CodeRabbit on the render fixture, plus its note on `release`.
The fixture. `chromium.launch` is the last step of the setup and the one that
fails in practice — no Chromium on the machine, an
`ORCA_MOBILE_WEB_RENDER_BROWSER` pointing nowhere — and by then the bundle
server is listening and the scratch tree is on disk. Rejecting there left the
caller without a handle, so `afterAll` had nothing to close and both stayed
allocated; the listening socket is the one that bites, because an open server
handle keeps the vitest worker alive after its last test has reported. The setup
after `mkdtemp` is now wrapped, gives back whatever it managed to take, and
rethrows the original error rather than anything the cleanup raised. The normal
close path awaits the server-close callback instead of firing it.
`release` in the page mount. The ownership check covered the claim but not the
two lines that make the terminal disappear, so a release that skipped the claim
would still empty the host and drop its class. The check now guards the whole
function, and round 5's caller-side check is gone as a duplicate of it: one rule,
inside the thing it governs. Both existing callers are unchanged in behaviour —
the synchronous planting catch always owns the page, and the rejection handler
was already guarded.
Pinned red first. The new case points the launch at an executable that is not
there, then asks the port the fixture actually served on for a connection and
reads the scratch directories in the temp dir. Without the rollback the port
still accepts and the scratch tree is still there; with it, neither. The port is
recorded by wrapping the real `createBundleServer` rather than standing a double
in front of it, and the case asserts a server was created at all, or the refusal
would mean nothing.
Two oracles were discarded on the way. `rejects.toThrow()` with no argument
passes for a build that broke for its own reason, so the rejection is matched by
message. `process.getActiveResourcesInfo()` reports `TCPServerWrap`, not
`TCPSERVERWRAP`, so a count filtered on the upper-case spelling was zero in both
arms and agreed with everything; it also still lists the handle at the moment
the close callback runs.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(config): read the render fixture's rollback in a temp root of its own
Two defects in the case I committed in cb1833e675, both found by running it.
The anti-slop gate refuses module mocking, and it is right to: the case recorded
the served port by mocking the harness module around the real
`createBundleServer`. Gone, with no disable.
Its replacement read the shared temp directory for the fixture's scratch prefix,
which the render check next door writes to from a worker of its own. So the case
watched that tree appear and be swept up mid-run and called it a change: one red
in four alone, and red in the full suite, where the two run together. `TMPDIR`
now points at a directory this worker made, so the fixture's scratch tree lands
somewhere nothing else writes and what is left in there afterwards was left by
the setup under test. The failed launch also leaves Playwright artifacts and a
browser profile in there, which are Playwright's to clean, so the reading is
filtered to the name the fixture gives its own trees.
The listening-socket half is unchanged and was right: spelled `TCPServerWrap` as
Node spells it, and read a tick after the close callback, because the handle is
still listed while that callback runs.
Both halves now fail on their own without the thing they measure: with no
rollback at all the socket count is one above its baseline, twice out of twice;
with the rollback but no `rm`, the scratch tree is still there. Three green runs
with both.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hand the started document to the mount in the turn that started it
Round 6's two LOW items, and the pins for the owner-checked release.
LOW 1. `started` was assigned in the `.then` after the build, a microtask later
than the start sequence and the resize listener it installs. A dispose in that
window found nothing started, skipped the teardown and released the page with the
document still running on it. The build now takes an `adopt` callback and calls it
as its last statement, inside the guarded region, so whoever has to undo the
start is holding it before that turn ends. Pinned by queuing the dispose behind
the document import the build awaits, which lands in exactly that window: without
the change the started document's resize listener survives the dispose, five red
runs out of five.
The owner-checked release, which landed in 8b37221b57 without a pin of its own.
The one path that reaches a mount's cleanup holding someone else's page is a
rejected import: everywhere else the build re-reads the claim after its await and
stops, but a rejection never gets that far. So the pin drives that — the chunk
fails for the first mount only, the mount is disposed while pending, a second one
is built into the same element as Reload does, and then the first rejection
arrives. Without the guard inside `release` it empties the live mount's host:
three red runs out of three, on the markup. It also disposes the abandoned handle
a second time afterwards and asserts nothing moves, which is LOW 2's missing pin
for round 5's F2.
That case is its own file because the import has to fail before the mount module
loads, and the mocking the failure needs is only permitted in `.test.ts` — the
anti-slop override does not cover `.test.mjs`, which is what refused the port
recording in the render fixture's case. It fails once, so the mount that replaces
it gets real modules and is a live document worth protecting; its own resize
listener is the witness that it started.
Two oracles were dropped. Vitest reports its own message when a mock factory
throws, not the one thrown, so which import failed is read from the factory's
counter instead. And a counter of successful factory calls read zero even though
the second mount got a working document, which measures vitest's caching rather
than this code; the live mount's listener replaced it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): type the listener wrappers the mount pins install
The mobile tests-typecheck ratchet was red on 63eb8a40ae: six TS7006 implicit
`any` parameters in each of the two mount pins, from arrow functions assigned
over `window.addEventListener` and `window.removeEventListener`. An overloaded
method gives an assigned arrow no contextual parameter types, so each wrapper's
`type`, `listener` and `options` were implicitly `any` under
`tsconfig.test.json`, which the product typecheck does not read.
Both wrappers now take their parameters from the bound original as
`Parameters<typeof realAdd>` and spread them through, so the signature is the
real one rather than three widened parameters. No casts and no `any`.
Re-verified that the change did not quietly disarm either pin, because a recorder
that counted nothing would also go green: with `release` unguarded the rejection
case still fails on the live mount's markup, and with the adopt deferred by a
microtask the single-mount case still fails on the started document's resize
listener surviving its dispose.
The ratchet itself is the finding worth keeping. It is not part of the mobile
`tsc` the rest of my gate set runs, and it had dropped out of that set when these
folds began, so three reports listed the other ratchets and not this one. It is
back in, and stays in.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): drop what a disposed page mount adopted, and close the fixture's three resources apart
Round 7's five items.
1. The queued-dispose case's precondition was vacuous. It read the host for a
missing container, which dispose empties on every path, so a build that returned
straight after its ownership check satisfied it. The wrapper now counts resize
adds and the case asserts exactly one, which is the document having started. Red
under that mutation, on the count.
2. The render fixture's rollback awaited its cleanup unguarded, so a cleanup that
also refused replaced the error the caller needs — the reason the setup failed.
The rollback is best-effort now and the original error is what comes back.
3. That cleanup stopped at the first throw, so a browser refusing to close took
the socket and the scratch tree with it, which is the leak the rollback exists to
prevent. Each of the three is asked independently and the first failure is
rethrown after all three have been tried.
4. The rejection case restores its `window` patch in a `finally`, as its sibling
does, so a failure part way through no longer leaves the patched functions behind
for everything that runs after it.
5. `dispose` left `started` set. `send` reads it, and what it holds names the
page's one set of document modules, so a stale handle could route a host command
into whichever document is live next. Nulled, and pinned: the stale handle pings,
and with the old code the *live* mount's `receive` answers `pong`, because the
scope's seam belongs to it by then. The precondition is the live handle's own ping
being answered, so the silence is the stale handle declining rather than the
command doing nothing.
Items 2 and 3 have no pin of their own. Both are failure paths of the cleanup
itself, reachable only by making a browser or a socket refuse to close, and
standing something in front of Playwright to do it is what the anti-slop gate
refuses in this file's suffix. The rollback's own pin still covers the path that
matters, and both changes are read by it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the terminal WebView document byte for byte
The document is already pinned as a digest, which says whether the emitted
bytes moved and nothing about where. C7.1 moves the hand-written script inside
it into modules the web page can import and rebuilds the document from them,
and the claim that has to hold through every one of those commits is that the
native screen kept the document it had. A digest cannot be the instrument for
that: it fails as two hexadecimal strings.
So the document is also committed as itself. The fixture is generated by
`scripts/build-terminal-document-fixture.mjs`, never pasted, and the test
rebuilds the comparison through that script's own substitution rather than
restating it, so a fixture written by one rule and read by another cannot agree
with itself.
The generated xterm engine is stored as two placeholders. It is already covered
by the digest test, postinstall regenerates it from whatever xterm the lockfile
holds, and inlining it would put 612 KiB of vendored bytes into the file whose
job is to isolate hand-written changes. Two further cases keep that from
becoming a hole: the placeholders must each appear exactly once and the engine
must not appear at all, and the restored document must equal the real one.
Regenerating the fixture is a review event. It is only correct when the emitted
document was meant to change, and the diff in that commit is the evidence.
Red-first: flipping one character inside a comment in `write-queue.ts` fails
both identity cases with a one-line diff naming the comment, where the digest
test reports a hash.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compare two terminal documents as programs, not as bytes
The C7.1 flip commit moves the document's 57 reassigned variables onto a scope
object, because a variable assigned across ES modules is a syntax error, and
every read and write of them gains a qualifier. The ruling asks that the review
of that commit be a test rather than a 515-line read. This is that test's
instrument.
It cannot be a byte comparison. Once the script's source is modules, `oxfmt`
owns its style, and the repository's style has no semicolons where the
hand-written document has one on nearly every line. A byte diff would therefore
be dominated by changes that are not the refactor, which is the opposite of
what the reviewer needs.
So the comparison is over tokens: semicolons are excluded for the same reason
they moved, comments never reach the stream, and one difference is allowed —
`name` becoming `<qualifier>.name`, three tokens for one — which it counts and
reports. It is stricter than "it still runs": a reordered statement, a changed
literal, a dropped operator, a renamed local and a qualifier under the wrong
object name all diverge, each reported with the token index and both sides.
Acorn carries `value` on its tokens but does not declare it, so the field is
read through a narrowing check rather than asserted onto the declared type.
Red-first, by mutation: dropping the qualifier-name check fails the case that
names it; removing the leftover-token check fails the dropped- and
added-statement cases; treating semicolons as significant fails the three cases
that depend on ignoring them. The acceptance case runs on the real 2,758-line
script rather than on a fixture, so the instrument is known to survive
everything the document actually contains.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): count each normalisation the move makes, separately
Measured while extracting the first group: the document's ES5 style is not a
style this repository's own rules permit. `curly` braces 279 brace-less
if/else/for/while bodies, `no-unused-vars` unbinds 38 catch clauses, and 446
`var` declarators become `const`, `let` or a scope field. Those rewrites land
before the qualifier is considered at all, so "the qualifier and nothing else"
was never reachable once the source is a linted module.
The comparison now allows exactly four classes and counts each on its own: a
reference that gained the qualifier, a declaration that moved onto the scope
object, a `var` that only changed keyword, a body that gained braces, and a
catch clause that lost its binding. Separate counters rather than a total,
because the flip commit pins each number and a total would let one class absorb
another — which is the drift the pin exists to catch. The two `var` classes
partition the 446, and the qualifier's 641 sites partition into references that
kept their declaration and declarations that moved.
Two ordering facts the cases pin. The catch rule is tried before the brace rule,
or the inserted-brace rule eats the `{` that follows `catch` and the streams
never resynchronise. A body braced at the very end leaves its closing brace
after the baseline has run out, so trailing closes are absorbed after the walk
rather than reported as a length difference.
Everything outside the four classes still refuses with the token index and both
sides: a changed literal, a dropped operator, a reordered pair, a renamed local,
a qualifier under another object's name, a brace opened and never closed, and a
brace closed where none was opened.
Red-first, by mutation: disabling the catch rule, disabling the trailing-brace
absorption, folding scope-field declarations into plain references, and not
counting brace insertions each fail exactly the case that covers them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the mouse-report cell a module the page can import
The first of the twelve groups the document already names. `*-injected.ts` has
been splicing JS strings into the document for a while, and tests evaluate
those strings, so the one-source-two-consumers shape is already there; what is
missing is that a string cannot be imported by the web page, typechecked, or
linted. This turns one of them into a module and adds the generator that puts
it back into the document.
The generator is a transform, not a bundle: a bundler orders its output by the
dependency graph, and the document's order is part of what the equivalence test
holds fixed. Imports are dropped rather than resolved, because inside the
document every name is already in scope — that is what the single IIFE means —
and `document-externals.ts` declares the names whose groups have not moved yet
and emits nothing at all. esbuild prints an ESM module's exports as a trailing
block, so that block is dropped whole rather than by its keyword; leaving the
keyword behind would put a bare block statement in the document.
Both sides of the comparison now go through that same printer before being
read. Otherwise every choice the printer makes — semicolons, property
shorthand, quote style — reads as a difference in the program when it is a
difference in who typed it, and each would need its own rule. A script that
does not parse is reported as a refusal naming its side, not thrown.
`let` is contextual outside strict mode, so acorn reports it as a name and not
as a keyword; without that the var-to-let rewrite the linter performs would be
refused on every reassigned local.
The group's counts are pinned exactly: nine references gained the qualifier
(`term` seven times, `panX` and `panY` once each), nine locals became `const`
or `let`, thirteen one-statement `if` bodies gained braces, no declaration
moved onto the scope object and no catch clause lost a binding.
The document is untouched, so the byte pin from 3006d8dfdf is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* 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
* refactor(mobile): make reflow a module, and give the generator its own tests
The third group, and the defect it found: esbuild wraps a long import list
across lines, and the generator was skipping only the first of them, which left
the remaining names loose in the emitted script. The document did not parse, and
the equivalence check said so by name rather than throwing — which is what that
refusal path was added for. Both lists, import and export, are now skipped to
their closer instead of by their first line.
The generator's own tests cover what the per-group comparisons cannot say on
their own: an export is unmarked and indented into the document scope, a
one-line import is dropped, a wrapped import is dropped whole, the trailing
export block esbuild prints is dropped rather than left as a bare block
statement, and types are erased without touching the program.
Reflow's counts: eleven qualified references — the terminal ten times and the
settled row count once — six locals that became `const`, and the two early
returns braced. The row count is written from three groups, so unlike the
query-reply flag it is the document's state rather than one module's.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make the keyboard-avoidance metrics a module
The fourth group, and the first that needed a non-null assertion.
`lineHasVisibleContent` reads the terminal's column count with no guard of its
own; the guard is in `computeContentBottomRow`, which is its only caller. Adding
a guard would change the program, and optional chaining would change what
happens when there is no terminal — the document throws there today. TypeScript
erases a non-null assertion, so the emitted script is unchanged and the
invariant is written down where the reader needs it.
Reflow now imports the metrics call from this module rather than declaring it an
external, which is the shape every group takes as its neighbours arrive.
Counts: fourteen qualified references, nine locals rebound, ten one-statement
bodies braced, and the two `catch (e) {}` clauses — the row scan and the
alternate-screen probe — unbound.
The scope table's rule is stated more precisely with it: a variable is this
module's own only when the group both declares and assigns it. While the rest of
the document is still strings, one the main slice declares stays shared even if
every use is in one group, because emitting a second declaration beside the one
the slice still carries would not be the same program.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make WebGL loss recovery a module
The fifth group, and the first carrying a top-level statement rather than only
declarations: the visibility listener it registers. In the document that runs
when the IIFE reaches it; as a module it runs on import, which is the same
single registration.
The context-loss listener disposes the addon it is registered on, so it cannot
run before that addon exists, but the assignment is to a `let` a closure
captures and TypeScript will not carry the narrowing across it. A non-null
assertion, erased by the compiler, keeps the emitted script identical and puts
the invariant where the reader is.
Counts: twenty-three qualified references across the terminal, the addon, its
retry timer and the theme the host last sent; three locals rebound; twelve
one-statement bodies braced; five of the six catch clauses unbound, the sixth
keeping its binding because the attach failure reads the error into its
diagnostic.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): make indirect-pointer scroll a module, and count a fifth class
The sixth group found a rule the four classes do not cover, so I measured the
whole script rather than meeting them one at a time: linting all 2,757 lines as
a module trips `curly` 279 times and `no-unused-vars` 38, both already counted,
and then five further rules at 23 sites — `prefer-number-properties` 17,
`prefer-includes` 2, `no-useless-escape` 2, `prefer-exponentiation-operator` 1
and `no-unused-expressions` 1.
Seventeen of those 23 are one rewrite: a global numeric function moved onto
`Number`. It has the same token shape as the qualifier, so it is counted as its
own class rather than folded into anything, and only the four numeric globals
are admitted — anything else appearing under `Number` is refused, which a case
pins. Every site is already behind a `typeof … === 'number'` check or is parsing
a string, so the two forms are the same test.
The remaining six sites are each a different shape and too few to be worth
matching; they will surface as refusals in whichever group carries them, and I
will report each rather than widen this.
The scroll accumulator is the first declaration to move onto the scope: it is
declared in this group but a touch scroll in another slice resets it, so the
`var` becomes an assignment to the shared field and the class that exists for
exactly that counts one.
Counts: five qualified references, one declaration moved, four locals rebound,
eight bodies braced, one `Number` rewrite.
The document is untouched, so the byte pin is still green.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal surface-swap group into a module
The seventh named group. `surface` and the uncommitted terminal are read by
other slices, so both move onto the scope; the two committed handles and the
pending surface are declared and assigned only here and stay module locals.
Counts: qualified 7, scope declarations 1, rebindings 4, braced bodies 2,
unbound catches 2, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): substitute build-time constants into the emitted document
The document's script text is not all hand-written: parts of it are template
literals interpolating real values, starting with the theme background. A
module cannot interpolate and still be the same program, so the generator now
derives an esbuild `define` from `document-constants.ts` and substitutes after
the import lines are dropped, when the names are free again. The page imports
the very same bindings, so there is one source either way.
The fixture script's TypeScript loader moves beside it rather than being
written twice.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal theme group into a module
The eighth named group, and the first parameterised one: its background
fallback comes from the mobile theme through `document-constants.ts`.
Two sites carry a line-scoped lint disable rather than the rewrite the rule
asks for: `indexOf(',') >= 0` and `Math.pow`. Both rewrites are outside every
normalisation class the equivalence instrument counts, so taking them would
change the program the native document carries, which is the one thing this
branch holds fixed. The reason is on the disable line.
Counts: qualified 12, scope declarations 0, rebindings 28, braced bodies 13,
unbound catches 0, number properties 9.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal path-tap group into a module
The ninth named group, and a pure query: it reads no shared state, so it has
no qualifier sites at all.
Two things this group forced. The generator now drops lint directive lines
before the transform, because a directive inside an expression makes esbuild
parenthesise that expression to keep the comment where it was, and those
parentheses are tokens the document does not have. And the two regexes keep
their `no-useless-escape` escapes behind a line-scoped disable, for the same
reason the theme group keeps `Math.pow`.
One name the document declares twice in one function stays `var`. Two
block-scoped declarations would be two bindings where the document has one,
and esbuild renames the inner one to say so.
Counts: qualified 0, scope declarations 0, rebindings 31, braced bodies 20,
unbound catches 0, number properties 2.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal tap-dispatch group into a module
The tenth named group, and the heaviest reader of shared state: the selection,
its elements, its thresholds and both press origins are all declared by the
overlay slice, which is still document text, so all of them move onto the
scope with their declarations left where they are.
Counts: qualified 49, scope declarations 0, rebindings 15, braced bodies 11,
unbound catches 0, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal mouse-click-drag group into a module
The eleventh named group. The escape byte and both SGR mouse modes join the
scope from the runtime slice; the gesture itself is declared here and never
read outside, so it stays a module local.
Counts: qualified 17, scope declarations 0, rebindings 22, braced bodies 27,
unbound catches 1, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal url-tap group into three modules
The twelfth and last named group, and the second parameterised one: both
candidate patterns and the length bound come through `document-constants.ts`.
Three modules rather than one. At 303 lines it was over the file cap, and the
document's own order interleaves the OSC 8 lookup with the file-URL parsing,
so the split follows that order and the group's text is the three emissions
joined. The test does the joining.
Note for a later lane: `terminal-webview-url-tap.ts` and
`terminal-file-url-tap.ts` already hold TypeScript twins of some of this,
written for the React Native side and not identical to what the document
carries. Collapsing the two is a behaviour change and does not belong in a
branch whose whole claim is that the document did not move.
Counts: qualified 10, scope declarations 0, rebindings 41, braced bodies 25,
unbound catches 6, number properties 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the mouse-mode DECSET scan slice into a module
The first of the thirteen inline slices. Both control-sequence introducers,
the straddling scan tail and all three mode fields are declared by the
runtime-state slice, which is still document text, so they move onto the scope
with their declarations left where they are.
Counts: qualified 20, scope declarations 0, rebindings 10, braced bodies 9,
unbound catches 0, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal message-bridge slice into a module
The script and the document end in the same slice, so the slice splits in two
at the point where the IIFE closes: the script half becomes a module, the
document half stays text. The byte pin proves the join is unchanged.
The second catch keeps its binding: it names the error and reports it.
Counts: qualified 1, scope declarations 0, rebindings 1, braced bodies 0,
unbound catches 1, number properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): give the document close its own slice file
The previous commit put two exports in one slice file, which the slice-count
guard reads as a mismatch: it derives the slice list from the composer's
imports and cross-checks it against the composed entries, one per file. Five
suites failed to load.
Splitting the file rather than the constant is the better shape anyway. The
file was called `message-bridge-and-document-close` because it carried two
concerns; now each has its own.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the terminal term-observers slice into modules
This slice interpolates the already-extracted keyboard-avoidance group between
its own two halves, so its text is three emissions joined in that order and
the test does the joining.
A sixth normalisation class, measured here rather than assumed: the printer
writes `{ name: name }` back as shorthand, and qualifying the value makes the
property name unavoidable again, so one baseline token faces four. It is
counted on its own like the others, with its own acceptance case in the
instrument's test, and every existing group's pin now carries a zero for it.
Counts: qualified 36, scope declarations 1, rebindings 12, braced bodies 12,
unbound catches 6, number properties 0, shorthand properties 4.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the selection-state-and-eviction slice into a module
The slice that declares most of the shared selection state: every threshold,
every overlay element and the selection itself, twenty-two scope declarations
in one place. The eviction counter is declared and assigned only here, so it
stays a module local.
Counts: qualified 12, scope declarations 22, rebindings 2, braced bodies 3,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the smooth-scroll and cell-geometry slice
Two modules, not one: the slice carries the normal-buffer smooth scroll and
then the cell-to-pixel geometry, and the split follows that order so the
group's text is the two emissions joined. Four names stop being externals and
become real imports.
Counts: qualified 39, scope declarations 0, rebindings 15, braced bodies 16,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* 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
* refactor(mobile): extract the terminal fit-scale slice into a module
The slice opens with the already-extracted theme group, so its text is two
emissions joined. Four more names stop being externals.
Counts: qualified 47, scope declarations 0, rebindings 47, braced bodies 20,
unbound catches 0, number properties 9, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* 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
* refactor(mobile): extract the runtime-state and text-scaling slice
The document's declaration block, where almost everything it shares is
declared, with the query-reply and surface-swap groups interpolated inside it.
Three modules: the two declarations that come before the groups, the text
scaling, and the viewport transform with the scroll indicator. Seven more
names stop being externals.
Two things this slice forced.
The scope-declaration rule now counts each declarator of one `var`, because
`var panX = 0, panY = 0` becomes two assignments onto the scope. It has its
own acceptance case in the instrument's test.
The two halves are compared against their own text rather than as one joined
program. The declaration the slice opens with is shadowed by a parameter
inside one of the interpolated groups, and printing the baseline as one
program renames that parameter; qualifying the outer name removes the shadow,
so the rename has nothing to correspond to. Splitting the slice on the group
constants compares like with like, and those groups have their own tests.
Build-time constants are now substituted textually rather than through an
esbuild `define`: a `define` whose value is an object or an array is injected
as a helper binding instead of being inlined.
Counts, head: scope declarations 2. Tail: qualified 31, scope declarations 38,
rebindings 25, braced bodies 13, unbound catches 1.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): format the two test files the last commit left unformatted
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the mouse-report and scroll-routing slice
Two modules around the already-extracted mouse-report-cell group: the viewport
cell lookup that precedes it, and the mouse input encoding and scroll routing
that follow. Eight more names stop being externals, which leaves ten.
Counts: qualified 49, scope declarations 0, rebindings 49, braced bodies 42,
unbound catches 3, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the host-message-router slice into modules
Two modules after the already-extracted reflow group: the postMessage bridge
with the engine error reporting that rides on it, and the router itself.
`notify`, `handleMsg` and `reportEngineError` stop being externals, which
leaves seven.
The catch binding handed to the error reporter keeps a cast: a catch variable
is `unknown` under strict mode, and the reporter reads only `message` before
falling back to `String()`. The reason is on the line.
Counts: qualified 48, scope declarations 0, rebindings 20, braced bodies 12,
unbound catches 2, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the selection-overlay slice into modules
Two modules after the already-extracted path-tap and url-tap groups: the
selection range with the xterm mirror, and the overlay positioning with the
edge scroll. Six more names stop being externals, which leaves one.
Counts: qualified 77, scope declarations 0, rebindings 96, braced bodies 63,
unbound catches 9, number properties 6, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the surface-touch-gestures slice into modules
The last of the thirteen slices. Two modules after the three already-extracted
groups: the selection menu's buttons, and the touch gestures with the pinch
and the momentum scroll. `attachSurfaceEventHandlers` was the last external,
so `document-externals.ts` is gone: every name the document uses now resolves
to a module.
The instrument reads both sides strict. A loose script has to defend Annex B's
block-scoped function declarations, and the printer does that by hoisting a
`var` and renaming the function, so one side carried a rename the other could
not. Neither name escapes its block, so the two readings agree on behaviour
and only the strict one can be compared. It has its own acceptance case.
Counts: qualified 104, scope declarations 1, rebindings 69, braced bodies 57,
unbound catches 2, number properties 2, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): extract the document's opening declarations into a module
The document shell carried the IIFE opener and the eight declarations inside
it, so it splits the way the message-bridge slice did: the shell keeps the
HTML and the opener, a new slice file holds the declarations, and the byte pin
proves the join is unchanged.
With this every line of the document's script has a module behind it.
Counts: qualified 3, scope declarations 8, rebindings 0, braced bodies 0,
unbound catches 0, number properties 0, shorthand properties 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the whole document script against the modules
Every line of the script now has a module behind it, so the whole thing can be
compared at once. This is the review of the move, as one number per class:
qualifier 609 references + 73 declarations = 682 sites
var rebindings 373, the document's 446 declarators less those 73
curly braces 279, the number measured before any of this started
unbound catches 36 of 38; two name their error and report it
Number properties 17, also measured up front
shorthand properties 4, two SGR flags written twice each
unshadowed names 7
A seventh class was needed and is counted like the others: a binding that
shadowed a document variable stops being a shadow once that variable moves
onto the scope, so the printer stops disambiguating it. It has its own
acceptance case.
The module order lives in one file that both this test and the generator read,
so neither can drift from the other.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): keep only the lint directives that do something
Seventeen of the disables were inert: `typescript/no-non-null-assertion` is
not enabled here, and a directive naming two rules on one line is not parsed
at all, so the one rule that did apply was being ignored too. The changed-code
quality gate reports an inert directive as a finding.
The two that matter are back, one rule per line: the guard-as-expression in
the observer disposal, and the local the document declares and never reads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): generate the terminal document from its modules
The WebView document is no longer a hand-written IIFE pasted into a template
string. `scripts/build-terminal-document-script.mjs` reads `document-scope.ts`
and the 36 modules under `src/terminal/document/` in document order, strips
their imports, exports and line-scoped lint directives, substitutes the
`document-constants.ts` exports textually, reprints each with esbuild and wraps
the result in one IIFE. `terminal-webview-html.ts` composes the shell, that
generated script and the close fragment. The artifact is gitignored and built by
postinstall, like the two engine artifacts.
The emitted document is token-equivalent to the old one under eight counted
normalisation classes, each pinned as an exact number in
`document/terminal-document-flip.test.ts` against the pre-flip text:
qualifiedReferences 609
scopeFieldDeclarations 73
rebindings 373
bracedBodies 279
unboundCatches 36
numberProperties 17
shorthandProperties 4
unshadowedNames 7
Any other difference fails with the token index and both sides. The second case
pins that the new document adds the scope object and nothing else.
Ruling 17: the behavioural tests now grep the generated document through
`XTERM_HTML`, never a module source, so every assertion still speaks about what
the WebView runs. Every assertion stays and the `expect` count per file is
unchanged: scroll-routing 95, text-zoom 59, engine 49, url-tap 33, reflow 22,
keyboard-avoidance 18, query-reply 14. One control per file was run by deleting
the module line the updated pattern guards; all seven red, and the tree restores
green.
Pattern changes, old -> new.
terminal-webview-scroll-routing.test.ts
var deltaY = ts.lastY - y; -> const deltaY = ts.lastY - y;
smoothScrollOffsetY -= deltaY; -> scope.smoothScrollOffsetY -= deltaY;
var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
-> const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);
'touchmove' single-quoted, one line -> "touchmove" double-quoted, printer line break
}, { capture: true, passive: false }); -> { capture: true, passive: false }
function momentumStep() -> let momentumStep = function()
pendingNormalScrollDeltaY += deltaY; -> scope.pendingNormalScrollDeltaY += deltaY;
if (normalScrollFrameId !== null) return true; -> if (scope.normalScrollFrameId !== null) {
normalScrollFrameId = requestAnimationFrame( -> scope.normalScrollFrameId = requestAnimationFrame(
pendingNormalScrollDeltaY = 0; -> scope.pendingNormalScrollDeltaY = 0;
cancelAnimationFrame(normalScrollFrameId); -> cancelAnimationFrame(scope.normalScrollFrameId);
var writeQueueHead = 0; -> scope.writeQueueHead = 0;
writeQueueHead++; -> scope.writeQueueHead++;
writeQueue = writeQueue.slice(writeQueueHead); -> scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);
surface.style.transform = 'translate(' + panX -> scope.surface.style.transform = "translate(" + scope.panX
getVisualPanY() + 'px) scale(' -> getVisualPanY() + "px) scale("
var FRICTION = 0.972; -> const FRICTION = 0.972;
var MIN_VEL = 0.012; -> const MIN_VEL = 0.012;
edgeScrollDir = dir; -> scope.edgeScrollDir = dir;
term.scrollLines(edgeScrollDir); -> scope.term.scrollLines(scope.edgeScrollDir);
// Latching document-level touch dispatcher -> function attachSurfaceEventHandlers(
edgeScrollClientX = clientX; -> scope.edgeScrollClientX = clientX;
edgeScrollClientY = clientY; -> scope.edgeScrollClientY = clientY;
return mode !== 'none'; -> return mode !== "none";
var pixelX = cell.x; -> const pixelX = cell.x;
var pixelY = cell.y; -> const pixelY = cell.y;
...isSafeSgrMouseCoordinate(cell.y)) return -> ...isSafeSgrMouseCoordinate(cell.y)) {
...isSafeSgrMouseCoordinate(sgrRow)) return -> ...isSafeSgrMouseCoordinate(sgrRow)) {
if (mouseTrackingMode === 'x10') return pixelPress; -> if (mouseTrackingMode === "x10") { return pixelPress;
if (mouseTrackingMode === 'x10') return sgrPress; -> if (mouseTrackingMode === "x10") { return sgrPress;
if (mouseTrackingMode === 'x10') return press; -> if (mouseTrackingMode === "x10") { return press;
if (col > 126 || row > 126) return ''; -> if (col > 126 || row > 126) { return "";
document.addEventListener('touchend' -> document.addEventListener( "touchend"
}, { capture: true, passive: true }); -> { capture: true, passive: true }
notifyTerminalSurfaceTap(tapCandidate.x, ...) -> notifyTerminalSurfaceTap(scope.tapCandidate.x, ...)
document.addEventListener('touchstart' -> document.addEventListener( "touchstart"
var clickInput = buildMouseClickInput -> const clickInput = buildMouseClickInput
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
notify({ type: 'terminal-input', bytes: clickInput }); -> notify({ type: "terminal-input", bytes: clickInput });
terminal-webview-text-zoom.test.ts
var CLAUDE_STATUS_DOT = -> scope.CLAUDE_STATUS_DOT =
var PRIVATE_MODE_SCAN_TAIL_LIMIT -> scope.PRIVATE_MODE_SCAN_TAIL_LIMIT
\n\n function enqueueWrite -> \n function enqueueWrite
var terminalFontFamily = -> scope.terminalFontFamily =
output = terminalFontFamily; -> output = scope.terminalFontFamily;
String.fromCharCode(0x23fa) -> String.fromCharCode(9210)
TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e) -> scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)
EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f) -> scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)
data.replace(CLAUDE_STATUS_DOT_PATTERN, ...) -> data.replace( scope.CLAUDE_STATUS_DOT_PATTERN, scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR )
writeQueue.push(normalizeStatusDotPresentation(data)) -> scope.writeQueue.push(normalizeStatusDotPresentation(data))
var replayData = normalizeInitialData(initialData) -> const replayData = normalizeInitialData(initialData)
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
statusDotPendingSelector = false -> scope.statusDotPendingSelector = false (x2)
term.open(surface) -> scope.term.open(scope.surface)
term.unicode.activeVersion = '11' -> scope.term.unicode.activeVersion = "11"
enqueueWrite(ESC + '[0m' + replayData) -> enqueueWrite(scope.ESC + "[0m" + replayData)
fontFamily: terminalFontFamily -> fontFamily: scope.terminalFontFamily
fontWeight: '300' -> fontWeight: "300"
fontWeightBold: '500' -> fontWeightBold: "500"
terminal-webview-engine.test.ts
var webglAddon = null; .. var webglRecoveryTimer = null;
-> the refreshTerminalSurface()..init( block, with the scope preamble
window.addEventListener('resize' -> window.addEventListener("resize"
'terminal init failed' -> "terminal init failed"
'terminal message failed' -> "terminal message failed"
var everReady = false; -> scope.everReady = false;
everReady = true; -> scope.everReady = true;
fatal === undefined ? !everReady : !!fatal -> fatal === void 0 ? !scope.everReady : !!fatal
msg.type === 'init' && !everReady -> msg.type === "init" && !scope.everReady
/fatal === undefined \? !ready\b/ -> /fatal === void 0 \? !scope\.ready\b/
if (msg.type === 'ping') -> if (msg.type === "ping")
notify({ type: 'pong', pingId: msg.id }) -> notify({ type: "pong", pingId: msg.id })
terminal-webview-reflow.test.ts
} else if (msg.type === 'reflow') { -> } else if (msg.type === "reflow") { (x2)
var MIN_FIT_COLS = 20; -> scope.MIN_FIT_COLS = 20;
if (cols < MIN_FIT_COLS) return; -> if (cols < scope.MIN_FIT_COLS) {
flog('measure-skip-small-width' -> flog("measure-skip-small-width"
notify({ type: 'measure-result', ... }) -> notify({ type: "measure-result", ... })
var dispatch = { mode: 'idle' -> const dispatch = { mode: "idle"
window.addEventListener('message' -> window.addEventListener("message"
terminal-keyboard-avoidance-webview.test.ts
\n // reflow() -> \n function reflow(
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
\n var panX -> \n scope.panX
TERMINAL_REFLOW_JS fragment import -> the reflow(cols, rows)..notify( slice of the document
terminal-webview-query-reply.test.ts
attachTerminalQueryReplyBridge(term, gen) -> attachTerminalQueryReplyBridge(scope.term, gen) (x2)
term.attachCustomKeyEventHandler(function() { return false; })
-> term.attachCustomKeyEventHandler(function() { \n return false; \n });
term.textarea.readOnly = true -> term.textarea.readOnly = true;
} else if (msg.type === 'clear') { -> } else if (msg.type === "clear") {
} else if (msg.type === 'measure') -> } else if (msg.type === "measure")
terminal-webview-url-tap.test.ts
notify({ type: 'open-url', url: tappedUrl }); -> notify({ type: "open-url", url: tappedUrl });
terminal-webview-payload-hash.test.ts is the document byte pin; it moves to the
generated document's digest, 730472 -> 723480 bytes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): delete the slice constants and injected fragments
The document is generated from its modules now, so the strings it used to be
pasted together from are dead. Deleted: the fourteen slice constants under
`terminal-webview-html/` (host-message-router, message-bridge,
mouse-mode-decset-scan, mouse-report-and-scroll-routing, runtime-constants,
runtime-state-and-text-scaling, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-touch-gestures,
term-observers-and-mode-mirroring, terminal-fit-scale, terminal-init-and-write,
write-queue) and the eleven `*-injected.ts` files. `document-shell.ts`,
`document-close.ts` and `theme.ts` stay: the shell and close are still the
document's HTML, and `theme.ts` is where `document-constants.ts` reads the
palette from.
Ruling 17, second commit. Tests that asserted the extraction mechanism itself
went with it: they compared one module's emission against the slice text it was
extracted from, and the flip test now pins the whole document against the whole
pre-flip script with the same eight classes. Deleted, all under `document/`:
fit-scale, host-message-router, keyboard-avoidance-metrics, message-bridge,
mouse-click-drag, mouse-mode-decset-scan, mouse-report-and-scroll-routing,
mouse-report-cell, path-tap, query-reply, reflow, runtime-constants,
runtime-state, selection-overlay, selection-state-and-eviction,
smooth-scroll-and-cell-geometry, surface-swap, surface-touch-gestures,
tap-dispatch, term-observers, terminal-init, terminal-theme, webgl-recovery,
wheel-scroll. `document/url-tap.test.ts` stays: it pins against
`URL_TAP_WEBVIEW_JS`, which is neither a slice constant nor an injected file and
still has a consumer.
Tests that asserted behaviour through a deleted string now read the generated
document. `document/generated-document-region.test-support.ts` is the one way in:
`documentScopePreamble()` returns the scope object the document opens with, and
`generatedDocumentModule(name)` re-emits a module and refuses unless the document
carries that text verbatim, so an evaluated block is the WebView's own bytes. The
two local copies of the preamble in the engine and text-zoom tests were folded
into it.
Moved, with every assertion kept and the `expect` count per file unchanged:
terminal-webview-html/write-queue.test.ts -> document/write-queue.test.ts 34
terminal-webview-theme-injected.test.ts -> terminal-webview-theme.test.ts 14
terminal-webview-query-reply.test.ts 14
terminal-path-tap.test.ts 25
terminal-webview-url-tap.test.ts 33
terminal-keyboard-avoidance-webview.test.ts 18
terminal-webview-reflow.test.ts 22
terminal-webview-text-zoom.test.ts 59
terminal-webview-engine.test.ts 49
Pattern changes, old -> new.
terminal-webview-reflow.test.ts
if (!term || isAlternateBufferActive()) return;
-> if (!scope.term || isAlternateBufferActive()) {
term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows);
var wasAtBottom = buffer.viewportY >= buffer.baseY;
-> const wasAtBottom = buffer.viewportY >= buffer.baseY;
term.scrollToBottom(); -> scope.term.scrollToBottom();
if (nextCols === term.cols && nextRows === term.rows) return;
-> if (nextCols === scope.term.cols && nextRows === scope.term.rows) {
The other eight files kept their patterns; only the text they read changed, from
a deleted constant to the document block. The harnesses that evaluate a block now
build the document's scope object instead of declaring the vars it replaced, and
hand the terminal in as `scope.term`.
Controls, one per file: the module line an updated pattern guards was removed,
the document rebuilt, and the test run. All red, and the tree restores green.
query-reply terminalDataRepliesEnabled = true -> query-reply test, 2 failed
path-tap const parsed = parsePathLineCol(...) -> path-tap test, red
keyboard-avoidance-metrics contentBottomRow -> keyboard-avoidance test, 4 failed
reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
webgl-recovery new window.WebglAddon.WebglAddon() -> engine and text-zoom tests, 4 failed
osc-link-tap return parsePathLineCol(value) -> url-tap test, 1 failed
terminal-theme scope.term.options.minimumContrastRatio = ...
-> theme test, 4 failed
write-queue scope.writeQueue[scope.writeQueueHead] = undefined
-> write-queue test, 4 failed
`document-scope.ts` docstrings named the slice each field belonged to; they name
the owning module now. Three module comments pointed at deleted injected files
and point at the modules instead. Neither changes the document: esbuild drops
comments, and the byte pin is unmoved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name the right number of counted classes
The flip test's title still said seven; the table it asserts has eight.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): name the shape applyTerminalTheme writes through
The anti-slop gate refused `loadThemeApplier(term: object)` in the theme test.
`applyTerminalTheme` touches exactly two slots on the terminal it is handed, so
`terminal-theme.ts` now exports that shape as `TerminalDocumentThemeTarget` and
the test's parameter and both fixtures use it. The theme is optional on the way
in because `applyTerminalTheme` is what writes it.
No cast. The type is erased by the generator's transform, so the document is
unchanged and the flip test's class table and the byte pin both still hold.
Control: restoring the `object` parameter reproduces the finding at
terminal-webview-theme.test.ts:35:33 and the gate exits 1; with the named type
it exits 0.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the dead URL-tap constant and two stale reflow guards
Round 1 fixes, all three folded here.
1. `URL_TAP_WEBVIEW_JS` is gone from terminal-webview-url-tap.ts, with
`document/url-tap.test.ts` deleted alongside it. The document is generated
from its modules now, so that constant was a second copy of the URL-tap group
with no consumer but its own tests. terminal-webview-url-tap.test.ts's
resolver harness reads the document's own text instead, the path-tap,
url-tap, osc-link-tap and surface-tap modules in document order through
`generatedDocumentModule`, which refuses unless the document carries each
verbatim. Its 33 expects all stay. One mechanism-only assertion went with the
file: `document/url-tap.test.ts`'s single `compareTerminalDocumentScripts`
pin of the three emissions against the constant, which the flip test's
whole-document pin already covers. The file's other exports stay.
The deletion surfaced a third reader. terminal-webview-scroll-routing.test.ts
concatenated terminal-webview-url-tap.ts into its `source`, and its
`notify({ type: 'terminal-tap' });` assertion was matching the constant's
single-quoted text, not the document. The read is dropped, since nothing else
in that file needed it, and the assertion is the document's form:
notify({ type: 'terminal-tap' }); -> notify({ type: "terminal-tap" });
Its 95 expects stay. Leaving the read in place would let a document assertion
pass against a module source, which is the hazard this lane exists to remove.
2. terminal-webview-reflow.test.ts guarded a template placeholder that no longer
exists, so it could not fail:
expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}')
-> expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1)
Same intent against the generated document: the reflow module's emitted text
is in the document exactly once. The case is renamed to say so and the
comment above it describes the generator, not the deleted template.
3. Same file, the routine assertion still passed as a substring of the qualified
call; qualified as line 30 already was:
term.resize(nextCols, nextRows); -> scope.term.resize(nextCols, nextRows);
Its 22 expects stay.
Controls, each verified to have changed the file first, all red, tree green
after restore:
osc-link-tap return parsePathLineCol(value) -> url-tap test, 3 failed
surface-tap notify({ type: 'terminal-tap' }) -> scroll-routing, 1 failed
reflow scope.term.resize(nextCols, nextRows) -> reflow test, 2 failed
module order 'reflow' listed twice -> reflow test, expected 2 to be 1
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): retire the last module concatenator and guard the order list
Round 2 fixes, all five folded here.
1. Deleted terminal-webview-html-source.test-support.ts.
`readTerminalWebViewHtmlSource()` had no consumers left once the behavioural
tests moved to the generated document, and it was the last thing that built a
document-shaped string by concatenating module sources — its filter admitted
`.test-support.ts` files too, so it could have grown one. Confirmed by grep
that the only occurrence of either name in the repository was its own
declaration.
2. New document-module-order.test.ts asserts both directions: the non-test,
non-test-support `.ts` files under `document/` are exactly
`{document-scope} + TERMINAL_DOCUMENT_MODULE_ORDER + {document-constants}`,
and no name is listed twice. `document-constants` is the one exception
because it is never emitted: its exports are substituted into the modules
that import them as literals, so the document carries its values without
carrying the module. A module added here and forgotten there would be dead
code that reads as live; a name left after its file goes makes the generator
throw at build time rather than at review time.
3. terminal-document-flip.test.ts's docstring now carries the retirement policy
from ruling 18: the test is the proof of the flip and holds only while no
module changes, the first lane that must change one retires it together with
`terminal-document-pre-flip-script.txt`, and the standing pin from then on is
`terminal-document-identity.test.ts`, whose fixture regeneration is a review
event. Comment only.
4. terminal-document-equivalence.test-support.ts said 57 reassigned variables
and "Four classes and no others". It now says 73 declaration sites and eight
classes, with each class's measured figure named. Two doc comments sat above
the wrong declaration and were moved onto what they describe: the
`NUMBER_GLOBALS` one down to that constant, and the printing one down to
`significantTokens`, with `STRICT_DIRECTIVE` given its own line.
5. build-terminal-document-script.mjs substituted constants with
`replaceAll(regexp, literal)`, where `$&`, `` $` ``, `$'` and `$n` in a
constant's value are read as replacement patterns. The substitution is now
`substituteDocumentConstants`, exported so it can be tested directly, and
replaces with a function.
Controls, each verified to have changed its input first, all red, tree green
after restore:
plant document/zz-planted-module.ts -> order guard, "+ zz-planted-module"
drop 'wheel-scroll' from the order -> order guard, "+ wheel-scroll"
revert to the string replacer -> 4 failed, "a $& b" became "a marker b"
The `$n` case is deliberately absent from that table: the pattern has no capture
group, so `$1` is already literal under either form and a case for it could not
tell them apart.
The document did not move. The byte golden, the digest and the flip test's class
table are all unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): make the flip comparator refuse what it was accepting
Round 2 items 6 and 7, both in the equivalence instrument.
6. `isPrinterDisambiguation` accepted any `name2` facing `name` without proving
the two were the same binding, so an unrelated rename ending in a digit would
have been counted rather than refused. It is replaced by `UNSHADOWED_RENAMES`,
an explicit list of pre-flip name, generated name and declaring module. The
whole script has one entry: `term2` -> `term` in `query-reply`, which is the
`term` parameter of `attachTerminalQueryReplyBridge` and its six uses, seven
sites in all. That is stated in the docstring rather than encoded as a second
pin, since the flip test already pins the total.
7. Brace absorption treated every unexpected `{` as a linter-added body and
absorbed any later `}` while one was outstanding, so a bare block anywhere
would have been swallowed. `isBraceableHeadBody` now requires the open to be
the body of `if`, `for`, `while`, `else` or `do` — walking a `)` back to its
`(` and reading the keyword before it — and `matchingCloseIndex` records the
index the close must appear at, so the absorbed `}` is that body's own.
That check had to move ahead of the equality check. Wherever a braced body
ends a block, the baseline's next token is a `}` as well, so pairing them
would consume the wrong one and leave the counts right for the wrong reason.
Both refusals are tested over snippets:
function f() { return value2; } vs return value;
-> token 6: expected name value2, generated name value
let value = 1; use(value); vs { let value = 1; } use(value);
-> token 0: expected name let, generated {
and the braceable heads are tested one by one, `if`, `for`, `while`,
`if`/`else` and `do`, so the new rule is shown to accept every shape the `curly`
rule produces and not only the one the document happens to exercise.
Controls: restoring the shape rule fails the first refusal case and nothing
else; restoring the accept-any-brace rule fails the second and nothing else.
The eight counts did not move: 609, 73, 373, 279, 36, 17, 4, 7.
Splitting out `terminal-document-tokens.test-support.ts` is not cosmetic. The
tightened rules put the file over the 300-line cap, and a `max-lines` disable is
forbidden, so the token reader moved to its own module: that side answers what a
script says, and says nothing about which differences between two of them are
allowed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): move the last six reply-enum pins where tsc looks
mobile/tsconfig.json excludes *.test.ts, so a `Record<HostUnion, true>`
coverage record in a schema test is never typechecked: the two that existed
(SshConnectionStatus, GitHubProjectOwnerType) checked nothing, and the four
closed enums beside them had only a doc citation of the host type.
Each arm list moves into its schema module as hostUnionArms<Union>(), which
#21269 introduced for the same reason, and each test iterates the exported
list instead of holding its own copy:
- SSH_CONNECTION_STATUS to SshConnectionStatus
- PROJECT_OWNER_TYPE to GitHubProjectOwnerType
- DETAIL_FILE_STATUS to GitHubPRFile['status']
- PUSH_TEST_REFUSAL_REASONS and PUSH_REGISTER_REFUSAL_REASONS to the refusal
arms of MobilePushTestResult and MobilePushRegisterResult
- SETUP_RUN_POLICIES to SetupRunPolicy
openEnum's parameter widens from a non-empty tuple to `readonly string[]` so
a hostUnionArms list can feed it. z.enum already accepts the same, so the
tuple constraint only excluded callers zod itself takes; behaviour unchanged.
Twelve mutations prove the pins: dropping one arm and adding a bogus one
each fail mobile tsc in all six places. Zero goldens move, the schemas'
behaviour being unchanged, and the 21 recording suites pass at the existing
baseline.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): fix the type errors in eighteen test files
Found by typechecking the tests for the first time (see the config that
follows). All mechanical, none weakens a product type:
- 67 `act(() => vi.advanceTimersByTime(...))` callbacks return VitestUtils
where act wants void, so each becomes a block. The async ones await only a
genuinely promise-returning call, so no extra microtask tick is introduced.
- Four fixtures were stale against a product type that gained a required
member: MobileViewState.alwaysShowDefaultBranch, PrSidebarData.checksError,
the branch-compare summary's errorMessage, and SessionOptionDescriptor's
transport, which #20884 added precisely so a producer could not inherit the
wrong lane's rendering by omission.
- `getLastConnectedAt` on the shared relay fake was typed `() => null`, which
refused the timestamp two escalation suites assign to it.
- Two holders used before assignment take `!`, one `advance!.kind === ...`
becomes `advance?.kind`, one widened status arm takes `as const`, and the
Expo notification fixture keeps `data` required because the dismissal cases
assign through it.
631 test files pass, 6222 tests, unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): typecheck the test files, on a ratchet
mobile/tsconfig.json excludes *.test.ts so Metro never compiles tests into the
release bundle, and vitest transpiles without checking types. Nothing had ever
typechecked a mobile test, which is why a `Record<HostUnion, true>` pin written
in one proved nothing and why 144 of the 630 test files had drifted.
tsconfig.test.json is that program with the tests put back, behind
`typecheck:tests`. Four files stay out: they import the desktop main process or
src/shared/child-process, which are written against @types/node, and this
program's libs are React Native's, where setTimeout answers a number rather
than a NodeJS.Timeout. Pulling that graph in reports ~280 errors about the
desktop rather than about mobile; vitest runs those four under Node, which is
where they belong.
The CI gate is a ratchet rather than the raw typecheck, modelled on
check-ts-nocheck-ratchet.mjs: 126 files still fail, so the gate freezes that
set and fails when a file that checks today stops checking, or when a baseline
entry starts checking and was not pruned. The list may only shrink.
Why not zero: 180 of the remaining 510 errors are one seam — tests locate
mocked react-native components by string name, which `ElementType` does not
admit — and closing it means either 180 casts or a global JSX declaration for
the mocked names. That is a design decision, not a mechanical fix, so it is
left for a follow-up rather than made here. The rest are smaller clusters of
the same kind: vi.fn mocks assigned into typed slots, call-arg tuple indexing,
and createElement props fixtures.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile-recorder): correct the corpus counts and the salvage claim
The oracle section still quoted the corpus as 368 scenarios and 727 goldens;
it is 393 and 778, and the three replay suites report 781 tests. Each number
now names the command that measures it.
"No golden carries one" was the load-bearing error: 44 goldens carry a
recorded `reply-salvage` today, starting with the push-test unknown-reason
scenario #21176 added for exactly that purpose. The paragraph claimed the
observation pins an absence when on those families it pins a recorded drop.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the tests-typecheck ratchet's parser
The gate reads tsc's output, and tsc indents the "Overload 1 of 2, ..." detail
under an error. Counting those as filenames would write unparseable entries
into the baseline and leave the gate unprunable, so the parser is pinned on
that shape as well as on the added/stale diff.
Written against the gate itself: it flagged this file before the directive it
carried was removed, which is the end-to-end proof the spawn half works.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): await the timer advances the act() rewrite dropped
Rewriting `await act(async () => vi.advanceTimersByTimeAsync(n))` into a
braced body left the returned promise floating at 27 sites, so the advance
was no longer ordered before the assertions that follow it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): unshadow MobileHostCard's .tsx suite
A wildcard `include` keeps only the higher-priority extension, so
MobileHostCard.test.tsx sat outside every tsc program while
MobileHostCard.test.ts existed beside it. Its one error is the same
react-test-renderer seam its sibling is baselined for.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): census every test file into the typecheck program
The ratchet diffs only files that error, so a test excluded from
tsconfig.test.json or shadowed by a sibling extension left the gate
silently. Every *.test.ts(x) on disk must now be in the program or
named in TESTS_OUTSIDE_PROGRAM with its reason.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(shared): make the enum helpers refuse the ways they can prove nothing
openEnum takes a `const` T so a bare literal keeps its arms rather than
widening to string. hostUnionArms blocks inference of U with NoInfer and
defaults it to never, so a call that omits the host union — where the
record would only pin itself — no longer compiles.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): describe the census and correct the baseline count
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): give the push fixture cast its SAFETY rationale
Widening the pre-existing cast made the changed-code gate attribute it as
a new finding.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): build the push fixtures as typed notifications
Replaces the `as unknown as` cast with Expo's own types, filling
FirebaseRemoteMessage and its notification once in two builders, and
passes the data payload in rather than mutating through an optional
member. Typing the fixture showed one assertion comparing the scheduled
content against the whole arriving content, which only held while the
cast let the fixture omit the two members the presenter drops; it now
names the four members the presenter forwards.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): keep the grouped-question advance read non-optional
`advance?.kind` let an absent advance take the null-draft branch instead
of failing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): run the tests-typecheck ratchet on Windows
Spawns tsc's JS entry on this Node instead of the node_modules/.bin
shim, which is a POSIX shell script that Windows resolves to tsc.CMD and
then appends .exe to. Parsed paths are normalised to POSIX so a Windows
run does not read every baseline entry as both stale and added.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): close the ratchet's @ts-nocheck hole and read tsc once
tsc exits 0 on a @ts-nocheck file, so a baselined test could be "fixed"
with one line, pruned, and never checked again; the census now names any
program test file whose leading comment carries the directive.
`--noEmit --listFiles` answers both questions in one pass, so the gate
spawns tsc once rather than twice. Corrects the two stale counts, and
states hostUnionArms' real reason for living in the schema module now
that tests are typechecked.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): fail CI when the RPC recording pin leaves main's history
`mobile/rpc-foundation/pilot-scenarios.json` carries the commit every golden
claims it was recorded from, and `--record` refuses on any other tree. A
behaviour-change branch pins its own last fenced commit, which stops being
reachable the moment the branch squash-merges: nobody can record on main again
until a hand-made repin lands, and until now only a human noticed. #21123 was
that, and so was the repin after #20954.
`scripts/rpc-recording-pin-guard.mts ancestry` fails when the pin is not an
ancestor of the commit under test, and prints the repin recipe. It refuses to
answer on a shallow clone rather than trusting grafted history, so the job
checks out with `fetch-depth: 0`. Ordinary product drift past a reachable pin
is not a failure.
`reproduce` makes the other claim the corpus header makes, which the recording
suites do not: they replay the goldens against the CURRENT tree, so a golden
recorded somewhere other than the pin -- a merge that auto-merged golden JSON,
a refresh copied back from a scratch directory -- passes them and is what the
header exists to deny. It checks the pin out detached, lays this tree's
recorder and manifest over it, and lets the same suites compare in place, so
the comparison is `compareGolden` with lockfile and platform masked as ever.
It runs unconditionally on a push to main, which has no `verify` job and is
where a squash lands a spliced corpus. On a pull request it runs only when the
corpus, the manifest or the recorder moved: nothing else can move the verdict
away from the one the base commit published, and `verify` replays the corpus
against the branch tree meanwhile.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): judge the recording pin against the tree it was read from
Round-1 review of the pin guard.
The pull_request ancestry check read the pin out of the merge preview and judged
it against the branch head. Those differ whenever main repins after the branch
point, so ordinary stale branches failed, and the instruction told the author to
repin to their own head -- which creates the unreachable pin the guard exists to
catch. Judge the checked-out tree instead.
`git worktree prune` in the reproduce teardown was repository-wide. This git
directory is shared by every worktree on the machine (611 registered here), so
it could deregister an unrelated one whose directory was momentarily missing.
`worktree remove --force` alone is enough; a failure to remove is now reported
rather than papered over.
Also: the concurrency group is per commit on main, because GitHub cancels a
pending run in a group whatever `cancel-in-progress` says; the skip gate fails
closed when a provenance path stops matching instead of skipping forever; the
census-boundary comment states the rule the code uses; and five exports with no
consumer are now module-private.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): let an untracked golden and the guard itself buy a reproduction
Two bot findings on the skip gate.
`git diff` sees tracked paths only, but the reproduction's overlay copy and its
census both read the corpus directory as it sits on disk, so an untracked golden
or manifest is input to the verdict and used to skip the run that would judge it.
Enumerate untracked entries under the provenance paths the way the recorder
already does, and run rather than skip: an unjudged local addition is the case
the reproduction exists for.
The guard script is now a provenance path of its own, so a change to it re-runs
the reproduction it implements. Left alone deliberately: run-process.ts and the
workflow's `paths:` scope over src/shared, which is a pre-existing gap for the
whole mobile workflow rather than this job's.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): refuse to reproduce when the suite list has drifted from the files
Round-2 review.
The suite names reach vitest as positional filename filters, and vitest exits 0
when only some of them match. A renamed census suite therefore dropped out of the
reproduction silently and the guard still printed that the corpus reproduces:
three files and 761 tests instead of four and 762, exit 0. Resolve every name
under the recorder overlay before spawning, and throw naming the drifted entry.
The unit case walks the list and omits each name in turn, so no single rename can
slip past it. This is the same fail-open shape as the renamed-pathspec finding.
Also: pass an explicit directory type to `symlink`, since Windows needs one and a
junction needs no privilege where a real symlink does; and build the throwaway
test repositories with `symbolic-ref` rather than `--initial-branch`, which needs
git 2.28 against a declared baseline of 2.25.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): ratchet the 201 unchecked RPC reply readers
Step 4 moved every call-site cast into an RpcOperation's `read`, but 201 of those
readers still answer `compatible: true` for any payload: `rpcUncheckedPayloadReader`
(163), `rpcReadUnchecked` (26 outside its own module) and `rpcUncheckedMemberReader`
(12), across 42 files. The cast moved; it did not become true.
Held as data with an AST boundary test, shaped on the raw-request-port ratchet: a file
that is not listed fails, a listed file that no longer has one fails, and a count that
rises fails. Only a call counts, so an import is not a reader and prose never is.
No behaviour change: this commit adds a list and a test.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): validate the source-control domain's RPC replies at arrival
Replaces all 17 unchecked readers in mobile/src/source-control/ with
`rpcResultVariant(variant, schema)`, so a malformed reply is an
`RpcIncompatibleReplyError` naming the operation instead of a TypeError three
frames downstream. The inventory drops 201 -> 184 and the five source-control
operations files leave it entirely.
This is a behaviour change, scoped to malformed replies. Six reply-matrix
goldens move; every named-scenario golden and every `normal` partition is
byte-identical, which is the parity claim.
Schemas live one module per reply domain, beside the operations that read them:
git-status, git-compare, git-history, hosted-review and worktree-metadata. A
member is required only where a consumer reads it unguarded, and each schema
records the consumer line that justifies it. Nothing is `.strict()`; every
reply a consumer publishes verbatim keeps `z.looseObject` so an undeclared host
member still passes through. Six replies have no reader anywhere in mobile and
get `z.unknown()`, which is the honest schema for them, not a holdout.
Three readers stay total by construction, because their contract is that an
unreadable reply is a value rather than an error: the `git.status` projection
(a null status three screens route on), the `session.tabs.list` reveal (a null
list means poll again) and the generated commit message (a screen's copy, never
a decode error in a text field). They gain the salvage report, not a verdict.
Consumers take the schema's output type, so `MobileGitStatusResult` and the
branch-compare aliases now name what mobile reads rather than the desktop
aggregate, and seven call-site casts are gone.
Three requirements came from the goldens, not from the host types:
`git.history` sends `timestamp: null`, `hostedReview.getCreationEligibility`
sends a `reviewLookupOutcome` the shared union does not list, and the
`git.status` projection writes an absent member as a present `undefined`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the six source-control reply-matrix goldens step 7 moves
Six goldens, all on malformed partitions. Every named-scenario golden and every
`normal` partition is unchanged, which is the parity claim for this step.
git.history-read / git.history#1
result-absent, result-null, inner-ok-missing, inner-false-string-error,
inner-false-object-error: the load rejected with a TypeError reading 'items'
or 'map' off undefined/null; it now rejects with
`incompatible_reply: git.history-page (git.history)`.
hostedReview.eligibility + create-intent / hostedReview.getCreationEligibility
result-absent, result-null, inner-ok-*: the fetch fulfilled with the error
envelope itself, re-typed as an eligibility and published into the compose
prefill; it now rejects, and both callers already route that to the same
"eligibility unavailable" state a null answer produced.
hostedReview.create-chain + create-intent / hostedReview.create
result-absent, result-null, inner-ok-missing, inner-false-object-error: the
create form showed the raw TypeError text "Cannot read properties of
undefined (reading 'ok')"; it now shows the incompatible-reply message.
Every header digest is unchanged -- baseline, recorder, adapter, scenario and
lockfile all match -- so the diff is the behaviour and nothing else.
Recorded from this branch into a scratch directory and copied in, because there
is no scoped honest alternative: scripts/rpc-recording.mts refuses to run unless
the product tree equals the pinned baseline, and the README's remedy for an
intended behaviour change is to repin, which rewrites the `baseline` header of
all 667 goldens. So these six now carry a pin whose tree no longer produces
them. That is a real gap in the oracle's design for behaviour changes, not a
detail of this step, and it needs a decision before this lands.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the four reply-schema properties the goldens found
Each of these cost a reply-matrix golden while writing the source-control
schemas, and none of them follows from reading the consumers or the host types:
a newer host's undeclared members must still decode, `git.history` sends
`timestamp: null`, `hostedReview.getCreationEligibility` sends a
`reviewLookupOutcome` the shared union does not list, and the `git.status`
projection writes an absent member as a present `undefined`.
The `.strict()` case is the one worth stating twice: at the top level it rejects
the reply, and on the entry it drops the row, which shows a dirty worktree an
empty Changes list. The fifth test pins the salvage report that makes such a
drop visible instead of silent.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): give an unreadable reply a message a user can read
`RpcIncompatibleReplyError` put `incompatible_reply: <op> (<method>)` in
`message`, and `message` is what the screens hand to a toast. Step 7 is the
first change that can reach this error at all, so the token would have shipped
to users as its own error copy.
Fixed at the boundary rather than per site: `message` is now plain copy, and the
machine token moved to `code` (`incompatible_reply`) and `name`
(`RpcIncompatibleReplyError`), both readable by callers. The cross-bundle
fallback in `isRpcIncompatibleReplyError` matched on the old message prefix, so
it now matches on `name`, which a foreign copy of the module still carries.
No existing test pinned the old text. Two new ones pin the copy, the token and
the foreign-copy match.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the recording baseline to this branch and re-record
Commit adeb5f9531 recorded the six moved goldens into a scratch directory and
copied them back, which left them pinned to `e7206f62`, a tree that no longer
produces them. That is the one claim the `baseline` header exists to make, so
this replaces it with the README's remedy done in full.
`baseline` is now f741b2ea82, the last commit on
this branch that touches a fenced path, so the recording fence passes in place
and every golden is pinned to the tree that produced it. All 667 were
re-recorded through `scripts/rpc-recording.mts --record`; none were hand-edited.
Decoding every value pool against the branch point b8d4cde09f sorts the corpus
into 661 header-only moves where `baseline` is the only key that moved, 6 whose
body moved as well, 0 added and 0 deleted. The 6 are the disclosed step-7 delta,
unchanged at 69 moved observation fields across malformed reply partitions, plus
the readable incompatible-reply copy from f741b2ea82. No `normal` partition and
no named-scenario golden moved.
`scenarioSha256` hashes the derived scenarios, not the manifest, so the repin
moves no other header key; the README section this adds records that, the
scratch-copy failure mode, and the follow-up repin main needs after a squash
merge.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): narrow the incompatible-reply error by instanceof, not by cast
The two new tests in f741b2ea82 read the error through `as` casts, which the
changed-code casting gate rejects. An `instanceof` guard narrows the same value
and checks the class at the same time.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the recording baseline to the branch tip and re-record
71d8c6a1e2 touched a fenced path (`mobile/src`), so the pin from 5f3f184fdf no
longer named the tree that produces these goldens. The fence compares the whole
of `mobile/src`, and a test file is inside it, so the pin follows the last commit
that touches a fenced path rather than the commit whose behaviour moved.
Re-recorded all 667 in place through `scripts/rpc-recording.mts --record`.
Decoding every value pool against the branch point b8d4cde09f still gives 661
header-only moves with `baseline` the only moved key, 6 body moves, 0 added and
0 deleted; the six and their 69 moved observation fields are unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record the four source-control reads that had no oracle
git.status (host payload), git.branchCompare, git.commitCompare and
git.branchDiff were migrated to checked readers with no recording observing
them, so a required member a host omits would have surfaced only in production.
Three families mount the owners rather than the senders, because each reply is
only visible in what the owner then publishes: the Changes screen's loader hook
(git.status, and the base-ref chain and git.branchCompare it triggers), the
history list screen (git.history and the per-commit git.commitCompare), and the
committed-diff opener hook (git.branchDiff). Ten goldens: three pilot recordings
and seven reply matrices.
Two adapter capabilities this needed. An inert FlatList never calls `renderItem`,
so the history adapter renders one row through the screen's own callback, both to
reach the handler that expands a commit and to read the file list back; without
that the commit-compare reply changes nothing observable. And `lowlight` joins
`react` and `zod` as a real library rather than a refusing proxy, because the
branch diff highlights on its success arm before the preview reaches state, so
the shipped text arm was otherwise unrecordable. No golden recorded its absence,
so only `recorderSha256` moves.
Recording the same scenarios against 4b0009d414, the pre-refactor tree, is the
before column. Decoding every value pool across the two gives 11 body moves and
666 header-only, 0 added, 0 deleted: the 6 already disclosed, plus the 5 new
matrices at 63 moved observation fields. What moved is the point. A malformed
git.status used to leave Changes `ready` over the malformed payload and go on to
fetch a branch compare; it now says the host sent a reply it could not read. An
absent git.branchDiff result used to put "Cannot read properties of undefined
(reading 'kind')" on the screen. An unreadable git.commitCompare used to spin the
expanded commit forever; it now says "No file changes".
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the recording baseline to the merge commit and re-record
The merge is the last commit touching a fenced path, so it is the only tree
the recorder's fence can match. Every golden moves `baseline` and picks up
main's `recorderSha256` from #20920; the six the checked readers changed are
the only bodies that move against main.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): note the merge-commit pin and unwrap the recipe's record command
`format:check` from `mobile/` caught the wrapped inline command the recipe
had been carrying since it landed; pointing at the command above removes the
duplicate and the wrap together.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): open the source-control reply enums so a newer host's arm degrades
A closed `z.enum` in a reply schema is a version claim, and it refused replies
every declared reader could have rendered: a `git.branchCompare` summary status
of 'shallow-base' failed the whole Changes compare, a 'codeberg' provider failed
the whole eligibility, and a 'typechange' entry status dropped the row. Main
passed all three through.
`openEnum` in zod-salvage declares the arm set open: an unrecognised arm reads as
a member the consumers already handle, while absence and a non-string stay fatal.
Not `.catch()`, which would swallow those two as well.
`area` stays closed and says why: every arm grants stage, unstage or commit, so
there is no member to degrade to that would not offer an action against a row
this build cannot place. Main rendered such a row in no section either.
Also drops two claims the code does not back. Nothing reads the salvage report,
so the two comments promising a dropped entry "arrives as salvage.droppedPaths"
are gone, and `hostKind` on the non-text diff arm had no reader.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs: write down the open-enum rule and the header keys a branch moves
Rule 4 in the wire-compatibility page, beside the three rules it belongs with:
an enum arm set is a wire surface, unknown arms degrade rather than reject, and
leaving one closed is a decision to state where the schema is declared.
The recorder recipe's step 4 said `baseline` would be the only moved header key,
which is only true of a branch that never touched the recorder. It now names the
three digests a branch's own edits move, so a reader recognises a clean result.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop the recorder's own timeout killing a full re-record
The corpus records in ~110s warm and 160s under load, against a 120s budget, so
a full re-record was killed roughly half the time. A killed run wrote a partial
reporter banner and exited 1, which reads as a failing scenario rather than as a
run that never finished — it cost two investigations here. The budget is now ten
minutes, and a killed run says so.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the recording baseline to the open-enum commit and re-record
`baseline` is the only header key that moves and no golden body moves: no matrix
partition scripts an unknown enum arm, so the corpus cannot see this change. The
eight schema unit tests are its only oracle.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop an unresolvable eligibility claiming the branch is not ready
Both fallback prefills set `canCreate: false`, which is a determination nobody
made. It short-circuits getMobilePrCreateBlockMessage before reviewLookupOutcome
is read, so a malformed, refused or rejected eligibility told the user "This
branch is not ready for a pull request yet." instead of asking them to retry.
Dropping it leaves `canCreate` undefined, which is what "unproven" means here.
Only a host that determined `canCreate: false` still gets the blocked copy.
`area` now degrades to absent rather than staying closed. Dropping the row also
dropped it from the unresolved-conflict gate, which grants create on a conflicted
worktree; absent withholds stage, unstage and commit while keeping the row, since
every area reader is an equality check. Its four consumers narrow explicitly: the
diff-review queue filters unplaceable rows, the opener withholds the route, and
the commit-failure prompt pins 'staged' where its own filter already did.
`git.branchCompare` entries are nullish, matching the `?? []` its consumers use.
Deletions: `MobileGitStatusProjection` and `uncheckedReaderCount` lose `export`,
the boundary test drops its dead inventory self-file (the AST counter finds zero
calls there, only prose), and `isRpcIncompatibleReplyError` is gone — it had no
caller in mobile, desktop or e2e.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile): formatting and a thrown rejection in the round-2 tests
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the recording baseline to the round-2 tip and re-record
The round-2 eligibility fix is a behaviour change, so the corpus has to be
re-recorded at a pin that includes it. Four goldens move body: the two
create-intent eligibility matrices on every non-normal partition, and the two
prefill scenarios that lose the fallback's `canCreate: false`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the recording baseline to the main merge and re-record
The merge is now the last commit touching a fenced path, so the corpus has to
carry its sha. No body moves against the pre-merge corpus: main's engine change
shifts `recorderSha256` on every golden and nothing else, and main's fifteen
step-6 goldens re-record byte-identical.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): admit the three unchecked readers #20954 landed
The ratchet is a ceiling against this branch adding readers, not a claim about
what main may land. #20954 brought `notification-stream-closed`,
`native-chat-session-page` and `terminal-buffer-cleared`, so the merge has to
raise those lines and say where they came from.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): repin the recording baseline to the inventory commit and re-record
The ratchet inventory is a fenced path, so admitting #20954's three readers
moved the fence head again. Baseline only; no body moves against the merge
re-record.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): send the host's own provider token back instead of a fallback
`provider` is not a member mobile only reads. The eligibility reply names it and
the create call returns it, so `openEnum(..., 'unsupported')` did not soften a
reading — it rewrote the bytes, and a host that had just named `codeberg` refused
its own provider as unsupported. The action-sheet Create path has no provider
gate, so nothing caught it.
Passes the token through as a string from the reply to the create params. The
allow-list that decides whether mobile may create stays supportsHostedReviewCreation(),
which already answers no for a token this build does not know; its parameter
widens to `string`, since answering for an unknown token is the whole job. The
worktree-link switch gains a default, which also fixes an older hole: an
unrecognised provider used to fall out of the switch as `undefined` params.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the provider pass-through in the corpus
Repins to the provider fix and records `sc-create-intent-unlisted-provider`,
whose eligibility reply names `codeberg` and whose recorded `hostedReview.create`
params carry it back unchanged. Restoring the old enum fallback fails that
golden on `Request params mismatch: hostedReview.create#1` and nothing else.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): pin each RPC golden to its own mount adapter, not every domain's
`recorderSha256` covered the whole recorder directory, mount adapters included, so a domain PR
that adds its adapter module moved the header of all 153 goldens. #20568 did exactly that and its
merge with main conflicted on that one line in 153 files; every future domain PR would collide
with every other in flight the same way.
Split the directory at a real seam instead of a filename convention: `adapters/` holds one module
per domain, registered in `adapters/mounted-operation-modules.ts`, and `recorderSha256` now covers
the engine only. A new `adapterSha256` covers the source of the module that mounts each operation
a golden's scenarios drive, read off the same `mounts` calls that build the table the recording
runs against, so the pin cannot name a file the runner did not use.
Adding a domain's module now re-digests nothing already recorded; editing one fails exactly the
goldens mounted through it. `adapter-seam.test.ts` keeps the split from drifting: an engine file
inside `adapters/`, an adapter defined in an engine file, a register entry naming the wrong file,
and an adapter importing a sibling each fail.
The five adapters that were inline in `pilot-mount-adapters.ts` move into their own modules, which
leaves that file as the registry and nothing else. `GOLDEN_FORMAT_VERSION` goes to 5 for the new
header field; the goldens re-record in the next commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the RPC goldens under the split recorder/adapter digest
Header-only. Every changed line is `recorderSha256` (the engine digest no longer covers
`adapters/`), the new `adapterSha256`, or `goldenFormatVersion` 4 -> 5; `baseline` is unchanged and
recording ran against the same pinned product tree.
git diff -U0 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \
| grep -vE '^(\+\+\+|---)' \
| grep -vE '^[+-] "(recorderSha256|adapterSha256|goldenFormatVersion)":' | wc -l
0
The seven `adapterSha256` values partition the 153 goldens by the module each was recorded
through: 58 settings, 37 hosted review, 21 source control, 11 new-tab agents, 9 file inventory,
9 tasks, 8 workspace settings.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): stop pinning goldens to recorder inputs no recording can read
The adapter split left three per-domain edits still moving all 153 headers: the mutant table, the
per-family mutant registry beside it, and the probe-hole witness. None can change a recording --
the loader consults a mutant only when a mutant test asks for one, and no suite but the two
recording drivers writes a golden -- so pinning them claimed a provenance the goldens do not have
and charged every domain a full re-record for it.
`mutants/` now holds the table, the registry, the reference states, the mutant suites and the
probe-hole witness, and `recorderSha256` skips it. What makes that sound is that no recording can
reach it: `operationModuleLoader` takes a resolved mutation spec instead of importing a table by
name, so nothing on the recording path names `mutants/` at all. `mutants/mutant-seam.test.ts`
checks exactly that, and fails if an engine file names the directory or anything outside imports
from it.
`recorderSha256` also pins only the suites in `recording-drivers.ts`, which
`scripts/rpc-recording.mts` records from, so the two cannot drift. A suite that reads goldens, or
writes one to a scratch directory, is no longer provenance for a recorded file.
`OPERATION_EXPOSURES` went the other way, because it does change what a recording loads: withhold
the resume-metadata exposure and exactly four goldens fail. Each domain module now declares its own
exposures and gets its own loader, so `adapterSha256` pins the ones that reached each golden.
Two assertions in the digest boundary test were vacuous: `join(root, '.')` normalises back to
`root` and hit `recorderSha256`'s per-root cache, so the prose-is-ignored claim never recomputed
anything. Each call now spells the root differently.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the RPC goldens under the mutant and driver exclusions
Header-only, and no format bump: the header shape is unchanged. `recorderSha256` moves on all 153
because the engine set shrank, and `adapterSha256` moves on the 58 settings goldens because that
module now carries its own exposure declaration.
git diff -U0 HEAD~1 -- mobile/rpc-foundation/goldens | grep -E '^[+-]' \
| grep -vE '^(\+\+\+|---)' \
| grep -vE '^[+-] "(recorderSha256|adapterSha256)":' | wc -l
0
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): restore the preferences actions the merge resolution dropped
#20568 added `resume` and `trust` actions to the `settings.task-preferences`
adapter while it still lived in `pilot-mount-adapters.ts`. This branch had already
moved that adapter into `adapters/task-mount-adapters.ts`, so resolving the
`pilot-mount-adapters.ts` conflict in favour of the registry merge silently
discarded them and `tw-task-preferences-resume-write` failed to record at all
("Missing or completed request: ui.set#1").
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the RPC goldens at main's tip after the merge
All 208 goldens, header-only. `baseline` moves from 50e752fc66 to main's tip
c6a7216984, `goldenFormatVersion` from 4 to 5, `recorderSha256` to the value of
the engine with `adapters/` and `mutants/` carved out, and `adapterSha256` is new
on every file. Nine distinct adapter digests over 208 goldens: each golden now
pins only the module that mounts it.
No observation moved. The whole-diff census against origin/main reports exactly
four changed keys and nothing else:
208 "adapterSha256": 416 "baseline":
416 "goldenFormatVersion": 416 "recorderSha256":
Recorded in place rather than through the README's detached-baseline dance: this
branch changes no product file, so its tree at the merge is byte-identical to
c6a7216984 under mobile/src, src/shared and the lockfile, and the parity claim
stays non-circular. README says so now.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): hold the recording drivers to the engine's mutant-seam rule
The name scan exempted every `.test.ts` on the ground that a test cannot change a
recording. Two of them can: the recording drivers are the recording path. A driver
that read the mutant table by path rather than importing it passed both seam checks
— the import scan sees no import, and the name scan waved it through as a test:
const table = resolve(import.meta.dirname, 'mutants/operation-mutations.ts')
console.log(readFileSync(table, 'utf8').length)
at the top of `pilot-recordings.test.ts` gave 2 passed before, and after this change
fails with ["pilot-recordings.test.ts"].
Only non-driver tests are exempt now. This file lives in `mutants/`, which
`recorderSha256` skips, so no golden moves: the recorder suite is green on the
existing 208 with zero dirty.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile): drop the registry parameter no caller varies
`pilotMountAdapters` took `registered` so a caller could mount a different module
set; all six callers take the default. The header-digest tests vary the registry
through `goldenRecording`, which keeps its own parameter and is where the stub
roots need it. Engine source, so `recorderSha256` moves and the goldens follow in
the next commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the RPC goldens after the registry parameter came out
All 208, `recorderSha256` only. The re-record against the previous commit moves
416 lines, every one of them that field:
416 "recorderSha256":
Against origin/main the picture is unchanged from the merge: 208 goldens, 0 added
or deleted, 0 non-header lines, and exactly four keys differing —
208 "adapterSha256" 416 "baseline" 416 "goldenFormatVersion" 416 "recorderSha256"
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): wrap the recording README at the width the rest of it uses
Seven lines this branch added ran past 100 columns, worst 124. No wording changed.
Markdown is outside `recorderSha256`, so no golden moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): name the worktree overlay, not the archive that cannot work
`git archive` was offered alongside a detached checkout as a way to lay this
branch's recorder over the pinned baseline. It cannot work: the fence in
scripts/rpc-recording.mts runs `git diff --quiet <baseline>` and an untracked-file
check, both of which need a real `.git`. In an archive tree git exits non-zero for
lack of a repository and the script reports "Product sources or lockfile differ
from the pinned main baseline", which reads as a product mismatch that is not
there. The transport agent lost time to exactly that.
Names `git worktree add --detach` only, and says what the misleading failure looks
like if someone tries an archive anyway. Markdown is outside `recorderSha256`, so
no golden moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): close two ways an adapter module escapes its own digest
Two holes, one class: the seam was checked by how an import was spelled and by
what the register's values evaluated to, never by where they resolve or where they
were written.
Inward imports: the scan dropped every specifier starting with `..`, so
`'../adapters/settings-mount-adapters'` climbed out of the directory and back into
it unseen. A reviewer had `new-tab-agent-mount-adapters.ts` project a value read
from the settings module, edited that module, and watched the mounted state change
while the new-tab adapter digest held. Specifiers now resolve against the
directory and anything landing back inside it fails:
["new-tab-agent-mount-adapters.ts imports ../adapters/settings-mount-adapters"]
The register: `adapters/mounted-operation-modules.ts` is pinned by nothing —
`recorderSha256` skips the directory and `adapterSha256` reads each entry's
`source`. An `exposes` written inline there drives the mounted product module with
no digest covering it. The same reviewer replaced the new-tab entry's `exposes`
with a literal overriding `loadMobileNewTabAgentOptions`; twelve fence tests
passed. Both `mounts` and `exposes` must now be identifiers the register imports
from that entry's own module:
["new-tab-agent-mount-adapters.ts writes exposes inline instead of importing it"]
Checked on the register's syntax, not its values, because an inline literal and an
imported binding are indistinguishable once evaluated.
Pinning the register in the engine digest would also close it, and is the wrong
trade: every domain adding a register line would re-digest all 208 goldens, which
is the conflict this PR exists to remove. Keeping the register an index costs
nothing and keeps a domain's line local.
Both fixes live in a `.test.ts` outside the drivers, so no golden moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): prove the mutant seam from the drivers out, not by spelling
The seam rested on a grep for the literal `mutants`, which the exported
`MUTANT_DIRECTORY` spells without containing. A reviewer had
`pilot-mount-adapters.ts` read the mutant table through that constant and both
checks passed. The README's claim — that nothing on the recording path names the
directory — was false as written.
Three changes, in order of strength:
Reachability is now proved forward. The suite walks the static import graph from
the two recording drivers and fails if any module under `mutants/` is in it. That
answers the real question, what a golden's bytes can depend on, instead of the old
inward scan's question, who mentions this directory. Non-emptiness is asserted on
both sides so a graph that resolved nothing cannot pass by reaching nothing.
The name scan covers both spellings, for paths a module can be read by rather than
imported. The reviewer's probe now fails as ["pilot-mount-adapters.ts"].
`MUTANT_DIRECTORY` is no longer exported. Its two consumers were both tests of the
digest, and they now spell the path instead, which is strictly better for them: a
test that imports the constant follows a rename silently, while one that spells it
fails on a rename — and that specific directory name is the whole soundness
argument. This edits `recorder-digest.ts`, so the goldens re-record in the next
commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): re-record the RPC goldens after MUTANT_DIRECTORY stopped being exported
All 208, `recorderSha256` only. Against the previous commit the diff is 416 lines
and every one of them is that field:
416 "recorderSha256":
Against origin/main, unchanged: 208 goldens, 0 added or deleted, 0 non-header
lines, four keys differing —
208 "adapterSha256" 416 "baseline" 416 "goldenFormatVersion" 416 "recorderSha256"
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): state the mutant seam's actual argument, and its edge
The README claimed nothing on the recording path names `mutants/`. That was the
old inward scan's claim and a reviewer falsified it with the exported constant. It
now describes what the check does: a forward walk of the import graph from the two
recording drivers, plus a name scan in both spellings for read-by-path, plus the
constant no longer being exported. It also names the case neither closes — a path
assembled from fragments at runtime.
Markdown is outside `recorderSha256`, so no golden moves.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): prove the engine/adapter seam in both directions
The inward scan only held adapters to the seam. An engine file importing an
adapter executes code its own digest skips and that every golden recorded
through another domain leaves out of `adapterSha256`, so the register is now
the only crossing allowed from the engine side.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): name what the driver walk missed instead of counting it
Seeding `seen` with the drivers made the driver-presence check true by
construction, and the size bound compared a graph inflated by `typeof import`
product modules against a recorder-sized number. Both go; the walk now reports
the recording files it failed to reach, which is empty today and names an
orphan engine file the moment one appears.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): reflow four paragraphs left ragged by the rewrap
Orphan fragments only, no wording change: the golden-schema field list, the
mutant-evidence paragraph, the probe-witness sentence and the re-anchor note.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record main RPC hooks and regression schedules
Add scripted sender recordings, guarded main goldens, reply matrices, lifecycle schedules, settings caller fixtures, and targeted B-seed mutants.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): flush recording user actions through React act
Keep lifecycle updates in separate act boundaries while wrapping direct stateful user actions.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): compile recorded modules with the Node VM API
Use the same trusted-source execution boundary as existing mobile VM test harnesses.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pool golden values and hoist pre-divergence checkpoints
Golden format version 2 stores each distinct observation field value once in
a `values` map keyed by a 12-hex sha256 of its sorted-key JSON, and a
checkpoint references five hashes. Output stays pretty-printed; the reader
rejects any other format version, resolves hashes back to values, and reports
the scenario, checkpoint, field and JSON path on a mismatch.
Generated variants now declare where their distinguishing input lands, so
checkpoints observed before that point are recorded once in a `.prelude`
scenario instead of once per reply partition. Reply matrices, interruption
schedules and lifecycle schedules share the primitive, which asserts each
variant's pre-divergence prefix matches the base. Equal-but-differently-reached
checkpoints are untouched.
17.71 MB / 3,599 checkpoints / 58.4% intra-file duplicates becomes
4.21 MB / 1,961 checkpoints / 23.6%, with every file's set of distinct
observations unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): make the recordings sense deadlines, the recorder, and every family
The goldens carried no temporal information, so a request deadline could be cut
to a third and all 61 files stayed byte-identical. Every threshold is now
straddled by two advances with a checkpoint between them: the 30 s request
deadline in both schedule drivers, the 120 ms search debounce in b1, and the 60 s
repo-metadata cache TTL. Shortening any of them moves an observation.
The record fence pinned product sources but excluded the whole recorder, so
--record could rewrite every golden from a modified runner and report the
baseline intact. Goldens now pin recorderSha256 over every non-markdown file in
the runner plus pilot-scenarios.json, and the fence exemption shrinks to the one
directory that digest covers.
Mutation evidence covered 3 of 13 mounted operations. There is now one anchored
mutant per adapter family, covering 11 operations and 51 of the 61 goldens; the
two omitted are the pure async loaders whose entire output is their settlement.
Anchors are asserted to match exactly one site, which caught the acceptance
mutant silently half-applying against three identical guards.
The archived-tree assertion pins each seed's visible state instead of merely
differing from main, and error observations carry code and cause when present.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record settlement times instead of straddling deadlines
The previous commit made the reviewer's divide-by-three deadline mutant fail by
placing checkpoints on each side of the 30 s deadline. That is a patch: a timing
change that does not cross a hand-placed boundary stays invisible. Those
scenario edits are reverted, and pilot-scenarios.json and schedule-driver.ts are
byte-identical to what they were before them.
The real defect was that the projection had no temporal dimension, so every
settlement now carries startedAt and settledAt in virtual milliseconds on the
pinned fake clock. Any transition the product schedules for itself is recorded
at the time it actually fires, so a deadline or debounce change of any size, in
either direction, moves a recorded number.
A checkpoint's own clock is not recorded. It is always the sum of the scripted
advances, so it is a function of the scenario rather than of the code under
test; run-recording.ts asserts that equality at every checkpoint instead, which
costs no bytes and fails loudly if it ever drifts.
projectionVersion is 2 and all 61 goldens are re-recorded. With the added
timestamps stripped, the distinct-observation set is identical to the previous
recording, so the change is purely additive.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): probe the repo-metadata cache inside its TTL window
Recorded settlement times cover thresholds the product schedules for itself, but
not one it only consults when something else makes it act. The repo-metadata TTL
is the single such case: with probes only at 0 s and 60 s, a 20 s TTL and a 60 s
TTL are both expired at 60 s and record identically, so a 3x cache-lifetime
regression was invisible.
settings-repo-cache-expiry now probes the cache at 59 s as well. This is
coverage, not a substitute for recorded time: it bounds how small a TTL
reduction is visible rather than making the reduction itself observable, and the
README says so.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record the reply shapes a host can send, not a cross product
The reply matrix froze ~26 malformed envelopes crossed against every consumed
field and three boundary kinds, which is 163,925 lines of JSON pinning accidents
on inputs no desktop produces. `successResponse` always sets `result`, so a JSON
wire has no explicit-undefined slot, and no mounted handler returns a number, a
string, an array, a bare `{}` or a boolean: `settings.get` returns
`{settings: ...}`, and the seed methods return an object or nothing.
Each family now runs nine witnessed partitions once, with no field cross: a
normal result, an absent result, `null`, an inner `{ok: false}` envelope with a
string or an object error, an inner envelope missing `ok`, an outer refusal,
`method_not_found`, and a transport rejection. `null` stays because
`linear.getIssue` returns it for a missing issue and b2 is a shipped null-result
bug; it is also what carries the one named delta these goldens record.
`run-step1-exit.ts` had zero callers and shelled out to the same two Vitest
files as `rpc-recording.mts`, so it and its README paragraph go, along with
`MUTATION_NAMES`, which only it read.
In the module loader, the `rpc-delivery-ambiguity` escape is measured dead: over
every scenario, mutant and reference run it was taken once, by the test that
existed to take it. Golden comparison already fails loudly if a mounted module
ever imports the marker, so both go. The history-panel exposure moves into a
declarative table beside the mutation anchors, leaving the loader with one
source-text mechanism and no per-file branch.
The VM stays. Mount adapters load product sources from an arbitrary `root`, and
the archived bcba08b3e4 tree is bare `mobile/src` and `src` with no package.json
and no node_modules, so no bundler-resolved import can reach it and the seed
rejection gate cannot run without it. Direct import also swaps a 38-module lazy
graph for a 338-module eager one behind 20 native mocks, because
`mobile-tasks-dependencies.ts` re-exports from `react-native` and four other
native packages and `export *` enumerates.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): treat the recorded lockfile hash as provenance, not an oracle
Every golden pinned `lockfileSha256`, so any dependency bump on main failed all
61 comparisons on the merge commit while the traces were identical. A dependency
that changes behaviour changes the trace itself; one that does not must not fail
a candidate. `platform` already had this exemption — `lockfileSha256` joins it.
Recording still refuses to run unless the lockfile matches the pinned baseline,
so goldens are still produced under frozen conditions.
Verified against main's lockfile: 85 passed, previously 61 failed. The declared
mutation set still reports every mutant killed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(rpc): cover repeat queries and settings refresh boundaries
Add three scenarios, preserve existing traces, remove unreachable archived checks, and document observed mutation kills and remaining adapter limitations.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(rpc): keep the known-open holes, drop the review transcript
The audit file was mostly a point-in-time record of mutation runs that had already
happened, in an artifacts directory, where it would go stale on the next scenario
change. The durable part is which holes are still open and why they cannot be
reached, which belongs beside the runner it describes.
Markdown is outside recorderSha256, so no re-record; 88 passed | 3 skipped.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* chore(mobile): stage the nine RPC probe scenarios and goldens
These existed only on one machine's /tmp. Landing them verbatim first so a
reboot cannot lose them; a follow-up commit moves them into the suite.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): fold the nine probe scenarios into the recording oracle
The probes were env-var invocations over loose /tmp manifests. They now live in
pilot-scenarios.json and rpc-foundation/goldens, so `pnpm --dir mobile test` runs
them with no flag to remember.
Re-records every golden against main (22f56f7c2a). Two causes:
- #20280 gave LogicalClientCutoverError the delivery-unknown mark and its cause,
so nine cutover/interruption goldens now record `isRpcDeliveryUnknown: true`
plus a `Connection closed` cause. The other 55 are byte-identical after 260
commits of main.
- #20499 replaced the five anchored raw-envelope reads with typed operations, so
those mutation anchors matched zero sites. Each is re-anchored at the same
defect's new home; bot-overrides moves to the shared reader that now owns it.
probe-hole-witness.test.ts pins hole and closure together: a probe must kill its
mutation and every pre-probe scenario of the same operation must still survive
it, so a redundant probe fails instead of accumulating.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): list the recording harness in the raw-port inventory
main's #20026 boundary test fails on any non-test file that reaches the raw
request port and is not inventoried. The oracle's scripted transport is exactly
that — it drives the real tracker and logical client — so it belongs in OWNERS
beside the supervisor fakes, not in the step-4 pending backlog.
Also states what the oracle covers, the two holes it was blind to until the
probes, and the step-4 runbook.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the goldens to the tree that recorded them
The inventory entry is a fenced product-tree edit, so --record refused against
main's sha. Baseline now names the branch commit the goldens were recorded from;
the next re-record after this lands bumps it to the merge commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): carry a SAFETY rationale on every recorder cast
main added a changed-code casting gate after this branch was cut, so 45 `as`
sites in the recorder read as new findings. Each now states why the assertion
holds; they cluster into five reasons — recorded observations are RecordedValue
by construction, parsed manifests and goldens are validated on the next lines,
interned pools resolve their own hashes, a VM-evaluated module has no static
type, and the mount adapters supply only the members each hook reads.
Re-records the goldens: the comments move recorderSha256.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(rpc): state the measured blindness, not the assumed one
Applying each mutation to real product source shows the two holes are not equal.
The reorder is invisible to 83 of 84 tests and only a probe sees it. The refusal
blanking is also caught by the family reply matrix, because a refusal from cold
publishes null over a non-null initial value — an observational gap, not a
detection gap. Says so rather than letting the stronger claim carry both.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): close three ways the oracle could pass without checking
All four review findings were real; three let the oracle report green while
verifying less than it claimed.
- The baseline guard used `git diff --quiet`, which ignores untracked files, so
an untracked module under mobile/src or src/shared could change resolution
while a golden still recorded a pinned baseline header. Adds a
`git ls-files --others` check over the same paths, recorder still exempt.
- The determinism loop read `Number(env ?? 2)` unvalidated, so
RPC_FOUNDATION_DETERMINISM_RUNS=0 skipped the body and 57 tests passed having
recorded and compared nothing. Now requires an integer >= 2.
- Cleanup-time observations were dropped: every checkpoint clones the effects
array, so anything appended during dispose or the final flush never reached a
golden. Warns and documents the six scenarios that hit it today; recording
them changes every golden and is its own change.
- Two SAFETY rationales described each other's assertion. Swapped.
Goldens re-recorded for the recorder-digest change: 73 files, one header line
each, no recorded observation moved.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): record teardown observations as a cleanup checkpoint
Each checkpoint clones the effects array, so a rejection or state write produced
by dispose, the transport teardown or the final flush landed after the recording
was built and never reached a golden. An unmount leak is exactly what this
oracle exists to catch, so teardown now runs on the recorded path and anything
it observes becomes a checkpoint with id `cleanup`. State is captured before
dispose, since the operation is gone afterwards.
Six scenarios were dropping observations, across five goldens: projectRowDetailError,
projectMutating, hostLabelById, hostPlatform, workspaceAgent, workspaceAgentOverridden,
creatingKey, selectedAgent, agentOverridden and error. Those five gain a cleanup
checkpoint; the other 68 goldens change by their header line only, so no existing
observation moved.
Also fixes the README's own formatting, which failed `format:check`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.
Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.
2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.
Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:
- Modules inside `src/shared` import the barrel as `./types`, not
`shared/types`. A pre-filter on the latter string skipped 176 of them and
left imports dangling at a deleted file, which surfaced as confusing
`Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
errors rather than "module not found".
- The barrel RENAMED one type on the way through
(`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
TypeScript parses that `;` as the import statement's terminator, so
replacing through `statement.getEnd()` deletes it and breaks ASI. The
rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
from it, because the barrel re-exported those same names — which trips
`import/no-duplicates` under `--deny-warnings`. A post-pass merges
declarations sharing a specifier and type-only-ness; the `import type` plus
`import` pair from one module is left alone, since that form is allowed.
Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.
Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
* Revert "test(ime): restore coverage the composition-ownership change removed (#13168)"
This reverts commit 25a8c517e1.
* Revert "refactor(terminal): return IME composition ownership to xterm (#13128)"
This reverts commit 17b3dff3c4.
* test(ime): keep the architecture-neutral Korean trace coverage
The recorded IBus/fcitx5 and Windows MS-Korean traces from #13168 assert PTY
byte order, not composition ownership, so they still hold once the terminal
composition layer is restored. The mobile accessory-order test pinned the new
handleLiveInputChange signature and does not.
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): keep the macOS Backslash bypass through the revert
The restored native-text forwarder only claims keys for input sources in its
hardcoded CJK allowlist, so third-party IMEs off that list (Qingg, #10896) still
get a raw backslash. #13128 added this bypass as a partial replacement; keep it
rather than trade the open issue back.
Scoped to the bare backslash key. The rest of shouldBypassXtermForMacNativeText
bypassed all unmodified non-ASCII text, which would race the restored forwarder.
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): move the mirror-step ref write out of render
The restored hook assigned runMirrorStepRef during render, which is not
replay-safe — React can discard render work, so the mutation can leak from UI
that never commits. Its only read is inside the held-commit timer, which fires
long after commit, and the ref has a safe default, so an effect is soon enough.
Surfaced by the changed-lines React Doctor gate: the rule postdates this code,
so restoring the file re-introduced it as a new violation.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(terminal): return IME composition ownership to xterm
* fix(mobile): derive terminal input from native replacement ranges
* test(mobile): record iOS Japanese IME traces
* fix(mobile): preserve native IME replacement ranges
* fix(xterm): flush queued application input after IME commit
* test(terminal): pin Korean intermediate commit
* test: pin Windows IME shortcut ownership
* test: replay IBus number candidate commit
* fix: preserve native macOS input-method punctuation
* refactor(terminal): remove stale mac focus override
* fix(mobile): preserve soft keyboard deletion ranges
* fix: keep IME-owned palette chords in renderer
* fix: stop carried IME shortcuts at renderer owner
* fix: preserve carried IME shortcut dispatch
* fix: narrow main-owned shortcut actions
* test(mobile): pin Japanese IME replacement traces
* test(terminal): retain paired native IME trace
* fix(chat): preserve browser IME composition ownership
* fix(chat): retain macOS IME confirm gesture
* fix(chat): expire unmatched IME confirm carry
* fix(chat): isolate IME confirmation expiry
* fix(chat): retain active IME confirmation
* refactor(terminal): remove dead composition handler
* feat(ime): add shared Enter-ownership seams for CJK composition
The confirming Enter of a CJK composition arrives as two keydowns and the
orderings differ by platform: Windows/Linux redispatch the unmarked Enter/13
before keyup, macOS delivers keyup first. A guard reading only isComposing or
keyCode 229 misses the redispatch, so surfaces submitted on a confirm.
Adds useImeEnterGestureOwnership (carry token, next-frame expiry), a shared
ImeEnterGuardedForm for native implicit submission, and the cmdk seam covering
18 CommandInput surfaces at one site.
A chorded Enter arms the carry but is never swallowed — the reverse would eat a
user's deliberate Cmd/Ctrl+Enter. Both failure modes are pinned by
ime-enter-gesture-ownership-contract.test.ts.
Co-authored-by: Orca <help@stably.ai>
* refactor(terminal): consolidate native input listeners and parked-screen owner
Extracts the shared native-input listener installer and renames the parked-screen
detector for what it actually does, replacing per-call-site duplication. The
listener installer keeps a forgetOptionKeyLocationOnBlur flag so per-window
semantics are preserved rather than flattened.
Net deletion; no behaviour change intended.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): pin recorded IME shapes as regression tests
Nine regression tests built from hashed affected-platform captures, each with a
paired ordinary negative and a discriminating mutation verified to take the file
from all-passing to exactly one failure.
Covers the Windows MS-Korean Shift family (#12179, #11878, #12151, #11946,
#12152) and the Korean TUI line-break rows (STA-3237, STA-3222, STA-3129).
STA-3237 pins the empirical 3-Shift / 2-active-composition / 2-newline ratio the
device run established — the third Shift produces nothing because Space has
already committed. That ratio is not derivable from a static capture.
Co-authored-by: Orca <help@stably.ai>
* fix(ime): guard Enter-commit surfaces against CJK confirm
Applies the Enter-ownership guards across the surfaces whose Enter commits
something: publishes, clones, pairs, installs, posts, or persists.
Tiered deliberately rather than uniformly. Irreversible and remote-effect sites
take the carry token, which also blocks the unmarked redispatch. Locally
reversible sites take the oracle check with a one-line comment naming the
residual, because a spurious commit there costs one undo.
Three numeric fields are left unguarded with the reason in-code: Chromium blanks
number inputs at compositionstart, so a confirm-Enter only ever reaches an
empty-draft reset. Measured with a CDP probe rather than assumed — a guard that
cannot fire is noise.
Co-authored-by: Orca <help@stably.ai>
* test(ime): teeth-check the Enter guards on every guarded surface
One suite per guarded surface, each verified by deleting the guard and
confirming the test fails. A green guard test without that check is unverified,
not verified.
Two shapes pass vacuously in happy-dom and are avoided here: native implicit
form submission never fires, and blur() is inert on an unfocused element. Both
made "the commit did not happen" assertions pass with the guard removed, so the
suites assert the guard's contract directly instead.
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): keep iOS Korean commits whole through the live-input path
iOS Korean reports isComposing: false on every event, so it bypasses the
composition guard entirely. The strict owner rejected UIKit's transformed
post-change field and sent only the leading jamo — the reported symptom.
Prefers the authoritative same-event field text over the predicted text when the
supplied operation cannot produce it. Generic: no Korean special-case, no locale
classifier, no normalization. Adds the RN-target-keyed submit carry alongside it.
Co-authored-by: Orca <help@stably.ai>
* test(e2e): make IME capture harnesses fail loudly instead of silently
Four instruments recorded silence as success, so a void run scored as a clean
one:
- readTerminalImeBoundaryTrace returned an empty trace when the probe never
installed, making every "nothing leaked" negative pass vacuously
- summarizeLatencies([]) returned a perfect zero distribution that passed all
three latency thresholds
- the macOS Vietnamese spec pinned an input-source ID that does not exist, and
failed as though the operator had chosen the wrong source
- the expectedLineCount=1 prefix property was undocumented and one edit from
silently downgrading a PTY assertion
Input sources now resolve by enumeration and name the near-matches on failure.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): cover Cangjie cancellation and fix a cross-namespace assertion
Adds #11951's recorded Cangjie cancel shape to the existing cancellation suite,
which covered Pinyin and Sogou but not Cangjie. One keystroke then Backspace
arriving as deleteContentBackward with data: null, so the stale preedit is the
only thing a fallback could replay.
Verified against the historical pre-6cd944c62b3 bundle: the positive fails with
['尸'] where [] is expected, while the ordinary negative stays green.
Also fixes the Vietnamese spec, which asserted a TIS-space input-source ID
against getKeyboardInputSourceId(). Those two Orca APIs report the same source
in different namespaces — TIS nests it under VietnameseIM, the app API does not.
The resolver stays as an installation precondition; the assertion matches the
leaf.
Co-authored-by: Orca <help@stably.ai>
* test(e2e): add a real-IME macOS arm for the Korean chord commit
The existing korean-ime-terminal-shift-enter-commit spec synthesizes composition
over CDP: Input.imeSetComposition sets the preedit directly and Input.insertText
performs the commit. Asserting the IME produced events you injected yourself is
circular, so that spec cannot certify real-IME behaviour.
This arm selects 2-Set Korean via TIS, reads it back live, and injects through
System Events key codes, so the OS owns the preedit, the commit instant, and
isComposing. PTY byte expectations are preserved verbatim.
Covers 2 of the original 4 cases by design. The other two are the Windows/Linux
redispatch-before-keyup ordering, which macOS cannot produce and which cannot be
selected -- the OS decides it. Reintroducing synthesis to "restore coverage"
would reintroduce the circularity.
Co-authored-by: Orca <help@stably.ai>
* test(e2e): assert the macOS chord arm at the PTY boundary, not the renderer
The byte expectations were transcribed from korean-ime-terminal-shift-enter-commit
:364/:383, which assert against onData -- a renderer boundary where the terminator
is CR. This spec reads the PTY child, where the tty has already converted CR to LF.
Names both forms per row rather than swapping the constant, so the conversion reads
as evidence that the capture reached past the renderer, as #11936 and #11951 record.
Ctrl+Enter's CSI-u sequence is unaffected and is identical at both boundaries.
Co-authored-by: Orca <help@stably.ai>
* test(e2e): measure composer-to-onData latency and stop dropping IME keystrokes
Two defects in the echo latency probe.
It hooked onWriteParsed and onRender but never onData, so it measured
key->parse->render echo rather than the composer-vs-onData delta the latency rows
need. Adds a third hook feeding its own sample set.
And `event.key.length !== 1` silently dropped IME keystrokes: Pinyin and Cangjie
keydowns arrive as key:'Process' (length 7). Replayed over the captured corpus,
the old filter accepted 580 of 4137 Chinese IME keydowns -- it was discarding 80%
of them. The new filter matches the shape the owner itself branches on.
Attribution charges each onData to the latest keydown rather than a FIFO head,
because composing jamo emit no onData at all and a queue would credit a whole
composition to its first keystroke. The consumer now asserts sample count before
any percentile, so a zero-sample run cannot render as a flawless distribution.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): pin the WSL shifted-jamo newline shape for #11919
In Korean 2-set, Shift types ordinary letters -- the double consonants and the
compound vowels. Each such keystroke reaches Chromium as key='Process',
keyCode=229, shiftKey=true.
The v1.4.163 classifier matched exactly that pattern with no code guard, so it
called those keystrokes Enter, rewrote them to a synthetic Shift+Enter, and
injected a newline into the middle of the word -- with no Enter key pressed.
That is why the reporters said "no modifier key pressed": they had not chorded
Shift+Enter, but they had pressed Shift, to type the double consonant.
Asserts the row's own recorded capture: 40 immediate keydowns, exactly 3 of them
Shift-carrying inside a single syllable, and an onData stream with one newline
per Enter press and none mid-word. Two ordinary negatives keep it from being a
blanket mute -- the same session's non-IME keydowns still reach shortcut policy,
and an ordinary Shift+Enter still resolves through the real policy.
Co-authored-by: Orca <help@stably.ai>
* test(terminal): pin the composition commit lag that made Korean type one behind
macOS Korean 2-Set commits syllable N only when the first jamo of N+1 arrives, so
compositionend and compositionstart land in the same task. A composition-start
handler cancelled the pending finalizer that was the only path to triggerDataEvent
and ended the session without emitting bytes, so every committed syllable reached
onData exactly one syllable late and the backlog cleared only at a Space or Enter.
Types continuously with no Enter and no Space -- either would flush the backlog and
hide it -- and samples onData at every syllable boundary. Paired with a
length-matched ASCII arm that stays green throughout, so the positive is a fact
about composition rather than about timing in general.
Bisected to a single call site across five builds: pristine, 1.4.155 and 1.4.162
pass, 1.4.163 fails, removing the one call repairs it, restoring it fails
identically. That window is exactly the reporter's "started immediately after
updating".
Co-authored-by: Orca <help@stably.ai>
* test(mobile): cover the send-queue abort that silently drops queued keystrokes
One failed send in use-terminal-live-input-commit aborts every keystroke
queued behind it, with the error swallowed by .catch(() => false). The
existing test resolves(true) on every send, so the failure branch was
uncovered.
Four arms: the abort itself, an ordinary negative on the healthy path, a
throwing sender, and a liveness control proving the queue recovers once
the chain settles. Deleting the abort takes 4 passed to 3 failed, with the
ordinary negative correctly surviving.
Scope is stated in the docblock: this is a transport send-queue abort,
reachable only via a real disconnect or RPC error. REQUEST_TIMEOUT_MS is
30s, so latency alone cannot reach the branch — consistent with #7094's
symptom class, not proven to be its cause.
* test(terminal): pin that daemon snapshot/restore cannot disturb a composition
Two independent reporters attributed broken Korean composition to the
always-on PTY daemon repainting terminal state over the preedit. The
attribution is wrong on ancestry — the daemon shipped three months before
the version both call good — but the boundary was never actually tested.
Runs the real applyMainBufferSnapshot choreography against a live
composition, including the full 2J/3J/H wipe plus the resize and
alt-screen branches. textarea.value, selectionStart/End,
compositionView.textContent and .active all survive byte-identical, and
interleaving a restore between every jamo of 문제 still commits 문제 at
onData. Also pins that the uncommitted preedit is absent from the captured
snapshot: it lives in the textarea, never the buffer, so a restore has
nothing stale to echo back.
Injecting one textarea.value = '' into the restore fails exactly the three
restore-boundary tests.
* test(terminal): pin that Cmd tears down a composition where Ctrl and Shift do not
xterm's composition keydown exempts only keyCode 16/17/18 (Shift/Ctrl/Alt)
plus 20/229. macOS Meta — 91/93/224 — is absent, so a Cmd press mid-composition
takes _finalizeComposition(false): the overlay goes dark and never recovers,
because compositionstart is not re-fired. The user composes the rest of the
word blind. Linux and Windows users press Ctrl and are exempt.
xterm already has a Meta-aware modifier predicate in wasModifierKeyOnlyEvent,
so this is an internal inconsistency rather than a deliberate choice.
Owns no reported row and is version-neutral: 5/5 on both 1.4.162 and 1.4.163.
The branch is unexercised in all 328 recorded traces, so this is a hazard pin,
not a regression guard. Only the teardown is asserted; the likely duplicated
commit needs a compositionend the IME kept alive across the Cmd, which no
capture contains.
Deleting the exemption fails exactly the three paired negatives; adding Meta
to it fails exactly the two Cmd arms.
* test(native-chat): characterize preedit loss when a question card replaces the composer
An AskUserQuestion card fully replaces the composer by design, but the
in-flight composition goes with it: the composer unmounts before
compositionend reaches it, so the preedit is never committed to the draft.
The committed text survives only because the draft is cached and restored
via defaultValue. Node identity changes, value 'abc' is preserved, the 가
is gone.
Drives the real NativeChatView -> SessionGate -> InteractiveCard ->
questionActive swap -> Composer -> ComposerField, flipped by writing the
same store field an AskUserQuestion hook event writes. Flipping
questionActive to false fails exactly this test and nothing else across
639 native-chat tests, so the path was entirely unguarded.
CHARACTERIZATION TEST: it asserts the loss. Fixing the defect — committing
the preedit before the swap, or keeping the composer mounted — will make
this file fail. Update the expectations to the new contract rather than
working around them.
Owns no reported row. #12118/STA-3219 flicker is keyed to token counters,
which provably do not remount, and a question card arrives once per
question.
* test(terminal): pin the duplicated commit when Meta interrupts a composition
_finalizeComposition(false) sends textarea.value.substring(start, end) but
cannot clear the IME-owned textarea, so a later compositionend re-sends the
same range. Meta reaches that path because CompositionHelper exempts only
Shift/Ctrl/Alt; xterm's own wasModifierKeyOnlyEvent covers Meta four ways,
so the omission is an internal inconsistency rather than a choice.
Companion to the modifier-exemption guard, which deliberately pins only the
overlay teardown. This pins the data consequence.
HAZARD PIN: owns no reported row. The trigger is unverified on hardware —
no capture in the corpus contains a Meta-during-composition gesture, and
whether macOS keeps the composition alive across it is unmeasured. The
duplication follows from the code given that sequence; whether users reach
the sequence is the open half.
An earlier premise that Space (keyCode 32) reaches this path was refuted by
a corpus scan: 0 of 731 evidence files carry a keyCode-32 Space while
composing, against 171 at 229, and 229 returns early.
* test(terminal): characterize the syllable lost when the textarea blurs mid-composition
CoreBrowserTerminal._handleTextAreaBlur clears the helper textarea
unconditionally — "Text can safely be removed on blur" — while
CompositionHelper._finalizeComposition reads the committed text back out of
that same value from a deferred timeout. By the time it runs the value is
empty, the substring is '', and triggerDataEvent never sees the syllable.
xterm checks composition state in _syncTextArea and omits the same check
here.
Six cases. Blurring mid-composition loses the syllable in every ordering,
including compositionend-before-blur, which is Chromium's real order — so
it is not an ordering artifact. A bare textarea.blur() with no Orca code
loses it too, which places the owner upstream: Orca's unguarded release on
outside pointerdown is one trigger, not the cause. Committing 한 then
blurring mid-가 yields ['한'] where ['한','가'] is correct: one syllable
gone, surrounding text intact.
Teeth checked by inverting — adding an Orca-side composition guard flips
exactly the three cases that route through the release path and leaves the
bare-blur and no-blur cases green, which is the scope split: a fix in
regular-terminal-focus-ownership alone would not close this.
HAZARD PIN, but unlike the others this one has a real production injector —
clicking outside the terminal mid-composition. Owns no reported row. The
shape matches #9738's report; the injector does not, and a shape match with
a mismatched injector is not an owner.
* test(terminal): say which arm the STA-3237 fixture came from
The recorded keydowns are wave 4's A-shift-unmarked-only — the arm that
emits no PTY bytes. Nothing in the file said so, so two readers concluded
the row's events fail the owner's predicate and that STA-3237 and STA-3222
were different defects. They share an owner; the arm that fires is
Process/229+Shift, absent from this bubble-phase trace because the owner
claims it in the capture phase.
Also corrects "code-blind": the v1.4.163 policy emits \x1b\r only for a
shift-only key:'Enter', and a jamo keydown reaches that branch solely via
the isTerminalImeProcessEnter rewrite. The mock is deliberately wider so
the ownership guard stays under test if that rewrite moves.
Comments only — no assertion, fixture value, or mock behaviour changed.
* test(e2e): track the input-source selector the macOS specs shell out to
Five tracked macOS IME specs ran `swift .tmp/select-input-source.swift`, a
file that is gitignored and existed only on one machine. Anyone else
checking out the repo — or the same machine after .tmp is cleaned — could
not run them, and they are the capture drivers for the macOS rows that are
blocked waiting for exactly those runs.
Moves it to tests/e2e/ beside its callers. The chord spec now resolves it
from __dirname rather than reaching two levels up into .tmp.
* test(terminal): pin the CJK repaint decision against the reporter's own output
#12164 comment 1 and #5921 report agent output with double-width glyphs
rendering duplicated character-by-character while ASCII in the same line
stays clean. No IME, no composition, no keystroke — the user never types
the CJK.
Segmenting all three verbatim samples into maximal same-risk-class runs
gives 33 runs and zero violations of "this run is corrupted iff the
production detector flags it": 17 wide runs all corrupted, 16 narrow runs
all byte-identical. The paired negative is co-located in the same line
rather than in a separate run — the reporter supplied it without knowing.
Doubling is asserted as present, not uniform: 자바스크립트 and 시스템 each
leave a jamo undoubled, which is a repaint-region boundary artifact rather
than a per-character transform.
The discriminating arm is in the test rather than a source mutation:
be3f30e2f8 (#6890) elects a repaint for all 17 corrupted runs when the
agent types nothing, and reverting its disjunct elects none. Both
predicates agree once the user has recently typed, which is the pre-#6890
condition.
Samples inlined with per-sample SHA-256 because .tmp is gitignored and
cannot back a landed test.
* test(terminal): pin macOS period substitution landing after the composition
#11504's reporter published a DOM trace showing insertText ". " arriving
149ms after compositionend, when two spaces are typed with a CJK input
source and NSAutomaticPeriodSubstitutionEnabled is on. This replays that
trace against a real Terminal and asserts what reaches onData — bytes to
the PTY, not anything visual.
The owner is stock upstream CoreBrowserTerminal._inputEvent, not an Orca
module, confirmed at the resolved install and in the shipped bundle Vite
loads rather than in the TypeScript source.
Three mutations against that install, predictions written before the runs,
each failing exactly the arms predicted: dropping the composed/keyDownSeen
guard fails two, dropping Orca's intercept fails the one arm where the
payload arrives before the send window drains, and flipping || to && —
the candidate-fix shape — fails the arm that pins the defect itself.
CHARACTERIZATION: arm 1 asserts the broken behaviour and will fail the
moment #11504 is fixed. Update it to the new contract rather than working
around it.
composed is absent from every recorded bundle, so composed: true is the
spec-required value rather than a captured one; the test asserts it before
dispatching so a harness that dropped the field fails loudly.
* test(terminal): replay the recorded Windows Shift sessions through the IME guard
STA-3179 reports a Shift release sending Enter; #12171 reports delayed
Hangul plus doubled newlines. Both replay their own recorded Windows
MS-Korean keydowns through resolveTerminalKeyboardShortcutAction with the
shortcut policy mocked, so the assertions are about which events reach the
policy and what reaches terminal input.
STA-3179's held-Shift gesture yields exactly one newline, from the unmarked
Enter alone; its release arms nothing for the next composition, asserted
after a precondition check that the release really is keyups with shiftKey
already dropped; and an ordinary Shift press-and-release still routes every
keydown, which is the paired non-IME negative.
Teeth, verified by mutation: bypassing the isImeOwnedKeyboardEvent guard in
keyboard-handlers takes STA-3179 from 3 passed to 2 failed / 1 passed — the
survivor being the ordinary-session negative, which is correct, since a
non-IME session should not depend on that guard — and #12171 from 2 passed
to 2 failed. Source restored byte-identical.
Recorded shapes are inlined and the bundles cited in comments; nothing is
imported from .tmp, which is gitignored.
* test(native-chat): correct 61977d4517 — the preedit survives the question card
61977d4517 claimed a composed syllable vanishes silently when an
AskUserQuestion card replaces the composer, and characterized that loss.
The claim was false. Its premise was an artifact of the harness: the test
simulated a preedit with a silent textarea.value assignment and no input
event, which no IME does.
Real composition fires input with insertCompositionText on every keystroke
— the shape this repo already records in its own observed-event capture —
and React's change handler returns on input/change with no composition
gate, so onChange runs for each frame. The draft cache is written
synchronously inside the updater, so the preedit is already committed
before the card can arrive. Driven that way, it survives.
Renamed to match the contract that actually holds, and extended: Hangul
jamo-per-frame, Japanese kana accumulation followed by per-segment
conversion asserting the candidate the user was looking at survives, and a
pin on the mechanism itself — the draft cache holds the preedit while the
card is up.
Teeth: there is no fix to revert, so the mutation is the plausible wrong
one — gating onChange on isComposing(). That takes 4 passed to 3 failed,
with the English negative correctly surviving, since it has no composition
to gate.
Two consequences remain, recorded rather than fixed: the OS aborts the
composition when the field disappears, so a lone jamo returns as a
compatibility jamo the user cannot compose onto, and the remounted
composer is unfocused because the card owned focus.
* docs(native-chat): name the corrected commit and the degraded-jamo consequence
Records in the file itself that 61977d4517 is pushed and wrong, quoting
the two claims that are false, so a reader who finds it in git log reaches
the correction from the file that replaced it.
Also states the residual as a consequence rather than a curiosity: a lone
leading jamo returns as a standalone compatibility jamo (U+3131), which is
not a composable state — the user cannot resume the syllable, only delete
and retype. Preserved, but degraded into something unusable. That is the
note to find if a reporter ever describes exactly that.
The invariant these tests pin is not "the composer commits on unmount" but
"composition input events must reach React" — which is what a future IME
change would break, and is not visible from the swap site at all.
* test(terminal): replay the recorded macOS Telex commit boundaries
#6905 reports Vietnamese composed characters breaking in the terminal.
Replays the retained macOS built-in Simple Telex capture — recorded
selection and value set before each dispatch, since that is what the
commit range reads — and asserts what reaches onData: the first commit
alone, then through the real Enter, then the ASCII tail of the same run.
A code-point count would catch NFD normalisation.
ENGINE CAVEAT, stated first in the docblock: this is macOS built-in Simple
Telex, Telex only. The reporter's three named engines cannot run on the
platform they declared, and which macOS Vietnamese engine they used is
unconfirmed. This file certifies no engine, and does not imply VNI.
The owner is upstream's — CompositionHelper._finalizeComposition's
waitForPropagation branch — so the arms are copies under .tmp aliased by a
scratch config, with node_modules verified unchanged by shasum after every
run. Collapsing the range end onto its start fails all three; collapsing
the start to zero re-emits the first word into the second commit, which is
the reporter's "duplicated" direction. Different failure sets, so the
mutants are distinguishable rather than merely detectable, and the ASCII
assertion passes under both.
Falsifiability here is by mutation, not by a defective build: #6905 does
not reproduce at HEAD, so this has never been watched going red on a real
reproduction.
* docs(terminal): lead the #6905 test with its engine caveat
Comment-only. Moves the caveat above the source line so a reader meets what
the file does NOT establish before what it does — the capture is macOS
built-in Simple Telex, the reporter's named engines cannot run on the
platform they declared, and which engine they used is what gates this row.
Co-authored-by: Orca <help@stably.ai>
* docs(terminal): record that the swallow eats a keystroke after Japanese conversion
This pin framed the swallowed insertText around Cmd interrupting a
composition. A differential through Japanese multi-segment conversion shows
it is broader: type a segment, convert, then press `a`, and the `a` is
lost. No modifier, no exotic gesture. Korean surfaced it first only because
2-Set composes on nearly every keystroke.
Also records why it cannot simply be fixed. The suppression de-duplicates
IMEs that deliver their commit a task after compositionend, which a sibling
test pins; this swallow is that dedup's false positive, and the two events
differ only in payload, so no flag-timing change separates them. Both a
smaller redesign and a content-aware variant were built and measured — the
first duplicates on IBus, the second costs a reported row's test and is
blocked while the patch cannot be regenerated.
The Japanese arrays behind this are authored, not observed: no Japanese DOM
composition trace exists in the corpus.
* docs(terminal): a Japanese capture does exist — correcting dbeecb11be
That commit said no Japanese DOM composition trace exists in the corpus.
False. One does, filed under the Linux bundles rather than the bundle named
for Japanese: 30 DOM events, two にほんご->日本語 conversions, with full
selection state per event. It is retained byte-identically in three further
bundles — one capture copied four times, not four observations, checked by
hash rather than by counting files.
The claim came from checking the bundle named for Japanese, finding nothing,
and generalising to the corpus without querying the rest of it.
Replaying it emits 日本語日本語 under both sequencing extremes on all four
arms, matching its own recorded onData. So "repeated conversion is
undisturbed" is now captured rather than authored. It carries no
post-compositionend insertText, so it cannot speak to the swallow: the
a-after-conversion figure stays authored and unobserved.
Also rewords the paragraph opener. It claimed to broaden a Cmd framing, but
hazard 2 was never Cmd-framed — the lines above already say Cmd does not
reach it. The real gap was that hazard 2 named no trigger at all, which
reads as exotic when it is ordinary.
* build(xterm): land the patch regeneration harness
The five dependency patches under config/patches/ shipped with no tracked
way to regenerate any of them. The xterm one is the hard case: it is derived
from an upstream build, so no fix could be made without rebuilding, and the
tooling to rebuild lived only in one machine's scratch directory. That
blocked a measured fix for a live keystroke-loss bug, and the EditContext
reduction an OSS survey identified as the only real one available.
Adds the regenerator, the upstream pin, the hand-written source patch the
bundle hunks derive from, tests, docs, and a PR job that verifies the
shipped patches still match the pinned build. The job caches the shallow
clone keyed on the manifest, so a cold run is minutes and a warm one under
one. Round-trip verified: regenerating from a clean checkout reproduces the
shipped patch byte-for-byte.
Marks the emitted patch -diff -text. pnpm hashes it byte-for-byte, so a
CRLF checkout would break install on Windows, and its minified bundle lines
make a diff nobody can read — review the source patch instead.
Also rejects unknown flags. --check was the fallback for any unrecognised
argument, so a typo, or --help, silently triggered a full upstream build
instead of what the caller asked for.
* fix(xterm): stop swallowing a keystroke typed after an IME commit
Type a Japanese segment, convert it, then press a key one macrotask later
and that key was lost. No modifier, nothing exotic — every user who keeps
typing straight after converting. Korean surfaced it first only because
2-Set composes on nearly every keystroke.
handleCompositionInput discarded the payload unconditionally in the window
after the deferred send: _isSendingComposition stays true for one macrotask
after the timer cleared _pendingCompositionStart, and the branch substituted
'' for whatever arrived. The suppression is not itself wrong — it
de-duplicates IMEs that deliver their commit an event-loop turn late, which
terminal-stock-composition.test.ts pins. It just could not tell a duplicate
from new input, because the two events are identical apart from payload.
Now it compares against _sentComposition, the text the deferred send
actually emitted, and discards only a match. A flag-timing redesign was
measured first and rejected: it fixed this and duplicated on IBus, because
no timing change can separate events that differ only in content.
Edited in config/patches/xterm-src/ and regenerated through the harness, so
the emitted patch and the lockfile hash are derived, not hand-written.
The commit-overlap pin's swallow arm now asserts the repaired contract —
the value its own comment already named as correct and as what stock
beta.287 emits. #11504's arm at :184 flips too; it never covered that
report, as its own prior note recorded, and the reporter's +149ms arm is
untouched and still asserting the defect. Provenance hashes in three test
docblocks are updated, since regenerating changes the patch hash and with it
the resolved install directory.
* docs(terminal): re-measure the #6905 mutation citations against the new bundle
Regenerating the patch moved the resolved install, so this docblock's
patch_hash, line count, two line numbers and three mutation outcomes all
described a bundle that no longer exists. The deferred branch is one the
fix writes into, so the outcomes could not be re-pointed on reasoning.
Line numbers read off both files by diffing anchors rather than derived by
arithmetic: 201 to 205, 159 to 163. Outcomes re-run through the retained
rig, which re-resolves through the module loader and re-derives each arm
from a unique minified anchor: pristine 3 passed, m1 3 failed, m2 2 failed,
m3 3 passed — identical to the old bundle. Guard controls in both
directions exit 1, so the counts are falsifiable.
Comment-only; the assertions and expectations are unchanged.
* fix(xterm): size the preedit overlay to the cells its text will occupy
updateCompositionElements computed the overlay's left edge from the grid
but never its width, so the preedit rendered at the font's natural advance
while the committed text takes two cells per wide glyph. Measured in
Chromium 150: 가나다라 drew 48.45px as a preedit and 69.20px once committed
— the same characters, same font, 30% narrower, and drifting further with
each syllable. Every macOS mono font carrying Hangul measured 0.49–0.72 of
two cells; never 1.0.
Deriving the width from wcwidth and the cell measure moves Korean, Japanese
and Chinese to 1.000 and leaves ASCII at 1.000, which it already was:
한 12.125 -> 17.297 (17.30 expected)
가나다라 48.453 -> 69.188 (69.20)
안녕하세요 60.563 -> 86.500 (86.50)
日本語 42.000 -> 51.906 (51.90)
abcdefgh 69.234 -> 69.203 (69.20, unchanged)
Edited in config/patches/xterm-src/ and regenerated through the harness, so
the emitted patch and lockfile hash are derived rather than hand-written.
The unit test asserts the arithmetic, which is what CI can run. The pixel
consequence was measured on macOS with SF Mono in an Electron harness, not
on the Windows font stack STA-3232 reports from — so this demonstrates the
mechanism and does not stand as that row's platform evidence.
* test(e2e): pin the macOS Korean preedit as visible only while composing
#11914 reports the composing text invisible until Space. Its c3 was recorded
as unobtainable, and the reason on file was wrong: the boundary IS
assertable, but not in happy-dom, which reports display:block in BOTH the
active and inactive states and zeros for every rect. A test there passes
with the defect present.
Captured on real hardware instead: hidden and 0x0 before, .active with
display:block, a 15.84x16 rect and checkVisibility() true while composing
그, hidden again after. 39 DOM events, 2 composition starts, onData
["한","그","\r"].
Two mechanism findings are carried in the setup because both are invisible
in the result and fatal if removed. The input source must be selected AFTER
the app takes focus — focusing resets it to ABC. And the IME must be warmed
until an observed keyCode 229; typed cold it emits raw QWERTY (g k s r m)
with no composition at all, which is indistinguishable from an IME that is
not installed. Two runs were voided on exactly that signature before the
warm-up was found.
The has229 and compositionStarts assertions exist to make such a run fail
loudly rather than pass as a clean negative.
Gated on darwin plus ORCA_E2E_NATIVE_MACOS_KOREAN, like its siblings. The
final spec form has not itself been executed — the machine became
unavailable — so it carries the probe's measured values as literals rather
than a run of its own.
* docs(e2e): correct 19a8d133db — the Korean preedit spec has been executed
That commit said the landed form had never run and carried the probe's
values as literals. It has now run on real hardware: 1 passed, 9.1s, rc=0,
with the capture and log sealed under a verified hash manifest.
The teeth check was also run rather than reasoned about, and it changes
which assertion matters. Forcing the active overlay to max-width:0 with
overflow:hidden — invisible on screen — leaves the active class, the
textContent, display:block AND checkVisibility() all passing. Only
during.rect.width fails. checkVisibility() is not sufficient against this
defect; the bounding rect is the single load-bearing assertion, which the
docblock already said and this run confirms.
An earlier teeth attempt injected the CSS mid-run and tripped the
hasActiveClass poll instead, failing at the wrong assertion. It is
inconclusive and excluded from the seal rather than counted.
* test(terminal): add #12171's ordinary-English arm from a real Windows capture
c4 was recorded as unmet and the ledger sourced its control to
evidence/windows-current/, which holds 12 captures and not one English one.
The arm here comes from windows-9803-final instead — same probe, same host
geometry, same injector, en-US with no IME, replayed keydown for keydown.
Two limits are stated in the file rather than left for a reader to find. It
is a different bundle and a different run about 3.6 hours later, so it is
not a same-run arm. And it is #9803's range-active MUTANT arm: ordinary
English stays byte-exact even with that saved-range mutation live, which is
why it reads as a negative rather than as a baseline.
Bundle cited by directory with its file SHA-256; MANIFEST.sha256 verifies
21/21, rc=0. Nothing imported from .tmp.
* docs(terminal): correct #12164's grounds — the cited comments say no such thing
The rejection of #12164 from this file's family was recorded as resting on its
comment 1 (output doubling) and comment 2 (filed against 1.4.163). Checked
against the API: the issue has exactly two comments, neither of which says
that, and the string 1.4.163 appears nowhere in the thread.
The conclusion survives on better grounds. The issue BODY's repro is "Run any
CLI agent (Codex, AGY, Claude, etc.) that outputs Korean text into the Orca
terminal" — untyped output, no keystrokes, no composition — so excluding
CompositionHelper is right, and the input-path hunt was looking in the wrong
place. The body is also LLM-authored (it still contains a literal
"## 5. GitHub Submission Draft (Ready to Post)") and its Root Cause section
blames a CJK IME preedit buffer its own repro never engages, so it should not
be read as observation.
Comment-only; suite unchanged at 5/5.
* test(native-chat): make composition frames carry isComposing, not just inputType
This suite's comment claimed "Gating onChange on `isComposing` breaks here."
It did not. composeFrame() fired `input` with `inputType` but never set
`isComposing`, so a gate on `isComposing` passed all four tests untouched —
the suite asserted a discriminator it did not exercise.
Composition frames now carry both, so neither gate is exempt. Verified by
pointing the mutant at it: with an `isComposing` gate on the composer's
onChange, this suite now fails 3 of 4 (it passed 4 of 4 before), and the
ordinary-English arm correctly survives, since a composition gate should not
touch it. Production code is unchanged and stays gate-free; the mutation was
applied, measured, and reverted.
Found while excluding NativeChatView's question-card remount as the owner of
#12118 / STA-3219: the remount is real, but the preedit survives it precisely
because this write path has no composition gate.
* fix(mobile): ship the patched xterm build, matching desktop
mobile pinned @xterm/xterm 6.1.0-beta.285 while the patch is keyed to
6.1.0-beta.287, so mobile shipped stock xterm and neither IME defect fix
reached it: the swallowed keystroke after an IME commit (9506039de7) and
the preedit sized to the font rather than the grid (e04e0c88da).
Bumps the three xterm packages to the desktop versions and adds the patch
to mobile's own pnpm.patchedDependencies. No copy of the patch: pnpm
accepts the parent-relative path and records it in the lockfile against
hash 8d63166272e9040a…, byte-identical to what desktop resolves, so the
two stay in step by construction rather than by a drift check.
The workspace separation is untouched — root pnpm-workspace.yaml still
declares `packages: []` and mobile keeps its own lockfile, which is what
keeps the root's patches from failing as ERR_PNPM_UNUSED_PATCH.
Verified in the generated webview bundle rather than at the install:
alignPreeditToGrid 0->2, sentComposition 0->3, pendingInput 0->11, and the
stock-only _handleAnyTextareaChanges 2->0 and dataAlreadySent 4->0. pnpm
applies patches during linking before postinstall regenerates the bundle,
confirmed by a revert/reinstall/re-apply cycle in both directions.
Mobile suite 2971 passed, 3 skipped — identical before and after. Bundle
+1,514 B (+0.24%). Lockfile churn is xterm-only; --frozen-lockfile passes.
mobile/src/ime/ime-submit-carry.ts is NOT made redundant and is untouched:
it handles iOS firing onSubmitEditing on a React Native native TextInput
after unmarking a composition, which is outside the WebView entirely.
Known divergence left alone: desktop also patches @xterm/addon-webgl and
mobile now runs that version unpatched. That patch is glyph/texture-atlas
rendering with nothing IME-related, so it affects neither fix.
* test(terminal): pin the preedit overlay against already-committed cells
STA-3132 (arm A), STA-3170 and STA-3232 report a Korean preedit painted on
top of text already on screen. Builds v1.4.163-v1.4.166 cancel the pending
finalizer in compositionstart, so a committed syllable reaches onData one
syllable late and buffer.x is stale — the overlay lands on the cell the
flushed syllable is about to occupy.
Replays a recorded hardware trace rather than an authored one: the ordered
DOM event stream captured on Windows + MS Korean (wave5-r2 evidence, 64
events), echoing onData back as PTY output.
The load-bearing assertion is deliberately not the obvious one. Comparing
overlay style.left against cursorX is tautological — left is computed from
buffer.x. This counts committed syllables from the compositionend events
the IME fired, so the two sides are independently derived.
Discriminated by a historical re-add across seven real bundles, since the
owner is deletion-shaped: pristine beta287, v1.4.155 and v1.4.162 pass;
v1.4.163 fails; v1.4.163 with that single call removed passes; the byte
identical baseline restored fails again; head passes. Every failing arm
fails only this case — the ordinary negative stays green in all seven.
The negative asserts its own category rather than claiming it: zero
composition events, zero isComposing, zero keyCode 229, exactly 16 events,
paired against the Korean arm's 4 starts / 3 ends / 11 updates / 64 events.
Scope: cell indices, not pixels. happy-dom has no layout, so the recorded
8x16 cell metrics are supplied to the render service. This makes no claim
about pixels visually overlapping; that is affected-OS confirmation and
stays open. Covers the overlap arm only — STA-3132's auto-line-break arm
and STA-3232's half-line-capacity and a11y arms are untouched.
* test(e2e): matrix macOS period substitution against the OS preference
#11504 reports macOS inserting ". " after a Hangul Space commit. This
sweeps six arms across both states of NSAutomaticPeriodSubstitutionEnabled,
reading the preference live per run rather than asserting a literal.
Two results worth having on record.
The reporter's stated trigger did not reproduce. Their words are "There is
no second press at all. One space is enough", but korean-single-space emits
zero insertText with the preference on or off. So does word-space-word-space.
Their timing does reproduce, with different content. korean-double-space and
korean-longer-word-double-space emit a delayed insertText at +122.5-122.7ms
after compositionend — squarely the reported +149ms — but the payload is a
space, never ". ". Consistent with the double-space rule seeing two slots
under ABC and only one under Korean, where the IME commit consumes the first.
The substitution itself is real and preference-bound: latin-double-space
gives "ab . " with the preference on and "ab " with it off, on one build
with the preference as the sole variable, reproduced across two runs.
That also refutes a claim in PR #11506, which states the substitution "is
enforced outside the renderer and never reproduces in dev builds, so changes
here must be verified against a packaged app". It reproduced in the dev build
twice and did not reproduce on the signed packaged app. That claim should not
be used as a verification gate.
Gated @headful behind ORCA_E2E_NATIVE_MACOS_PERIOD, same shape as the Korean
preedit spec, so it does not run in ordinary CI. Evidence is onData and DOM
only — the PTY-child reader aborted and no packaged-app arm was stable.
* test(terminal): actually enforce the recorded jamo progression
The preedit assertion compared sample.overlayText against sample.overlayText
— the same expression on both sides. A lane proved it by mutation: corrupting
seven of the eight recorded preedit values left the suite fully green. So the
docblock's ㄱ→가→간→나→낟→다→달→라, which the matrix also cites as this row's
recorded shape, was cited and unenforced.
The first attempt at a fix was insufficient and is worth recording. Threading
stroke.preedit through to the expectation still passed on a corrupted fixture,
because that value both drives the rig and was the expectation — corrupting it
moved both sides together. Same tautology, one level down.
The expectation is now an independent literal. Verified by mutation rather
than by reading: corrupting two recorded values fails one arm; restoring them
passes 3/3.
overlayCell was never affected — it is compared against a count derived from
the compositionend events, not from the buffer, and remains the load-bearing
assertion for the overlap.
* fix(e2e): select the selectable input source, not the first match
TISCreateInputSourceList can return several entries for one input source
id. A third-party IME publishes a non-selectable parent alongside the
selectable mode, and taking sources.first can return the parent — after
which TISSelectInputSource fails with paramErr (-50) while the caller
reports success from the enable step.
Found with Qingg (com.aodaren.inputmethod.Qingg), which exposes exactly
that pair under one id. Its mode id equals the bundle id, so filtering by
name would not have helped; selectability is the discriminator.
Now filters on kTISPropertyInputSourceIsSelectCapable and falls back to
the old behaviour when nothing advertises it, so single-entry sources are
unaffected. Also enables every entry for the id rather than only the one
being selected: selecting a mode whose parent is still disabled fails the
same way.
Compile-checked, and selecting com.apple.keylayout.ABC still exits 0.
Unrelated to the enable path: on macOS 26.5.2 third-party IMEs are gated
behind a consent sheet in System Settings. TISEnableInputSource returns
noErr immediately regardless, and the enable only lands if that sheet is
answered while the requesting process is still alive.
* test(terminal): discriminate #12171 against the real shortcut policy
The prior candidate mutation for this row was correctly refused: its suite
mocked shortcut policy so Process/229 became actionable, while the real
resolveTerminalShortcutAction has no Process branch — so the kill measured
the mock. This does not mock it.
Replays a capture of this row's own gesture (d, l, Shift+T, e, k, Space,
Enter under MS Korean, committing 있다) taken on Orca 1.4.164, through the
real useTerminalKeyboardShortcuts hook, capturing bytes at terminal.input.
The earlier capture could not discriminate at all because it recorded no
shiftKey; this one records it on 10 of 10 keydowns with code populated.
One physical Shift+T produces two shifted Process/229 keydowns. Under the
pre-#12265 classifier each synthesizes {key:'Enter', shiftKey:true}, which
the real policy resolves to sendInput '\x1b\r' — twice, giving 1b0d1b0d,
the two escapes the known-bad ed96881b0d1b0d contains.
Mutation is the retained pre-12265-process-shift.patch applied to HEAD, not
an authored one: patch -p1 applies clean and diffs identical to the mutant
copy. Arms are copies; shared source hashes the same before and after.
The English arm stays clean under both modules, so the mutation
discriminates by language rather than by harness — and a real Shift+Enter
through the same rig yields exactly ['\x1b\r'] in every arm, so a silent
pristine result means the code is quiet rather than the harness dead.
Scope: 1b0d1b0d is measured at the renderer boundary. The capture recorded
no PTY bytes — window.api.pty is frozen on shipped builds and the onData
channel needs a build-time flag — so this shows the renderer producing the
two escapes that payload contains, not a re-observation of the payload.
* docs(native-chat): narrow this file's disclaimer to what is now true
It said "THIS OWNS NO REPORTED ROW". Half of that is stale: the remount site
is now the attributed owner of #12118 and STA-3219. On real Windows TSF the
questionActive swap aborts a live composition — the old node gets only a
blur and no compositionend, the text returns as committed, and the next jamo
yields 아ㄴ rather than 안.
The other half holds. This file pins the opposite property, that the text
survives, which is the half those reporters already agree with. Mutation
shows the gap rather than asserting it: deleting the unmount entirely leaves
three of four tests green, because every substantive assertion is
after.value === … and a composer that never unmounts keeps its value.
Also records why the abort cannot be asserted here. The DOM exposes no
observable separating committed text from a live preedit — value is the same
string either way, there is no EditContext, and the only composing-ness
state is a per-instance ref discarded with the node. A test pinning "no
compositionend fires" would be an anti-guard: red the day it is fixed.
The cadence objection is kept, since it is now the open question rather than
the reason for exclusion.
* refactor(terminal): drop the unread isComposing field from XtermBypassEvent
Added by #6396 for terminal IME candidate handling that this branch has since
removed. No production or test code reads it, and the policy is safe without it:
during composition `key` is 'Process', so the non-ASCII printable checks that
would care never match.
Co-authored-by: Orca <help@stably.ai>
* fix(native-chat): keep the composer mounted through an in-flight IME composition
A question card replaced the composer outright
(`{questionActive ? null : <NativeChatComposer/>}`). Unmounting the field
mid-composition aborts the composition in the OS: the node is detached before
`compositionend` can fire, the preedit returns as committed text, and a resumed
Hangul syllable degrades — 아 then ㄴ yields `아ㄴ`, never `안`.
Confirmed in rasterised pixels on Windows with a real MS Korean IME, at both
v1.4.171 and the reporter-era v1.4.164 (the swap block is byte-identical
across them): the preedit underline present before the swap, the composer
visibly absent during it, and the same glyph back afterwards WITHOUT the
underline — committed, not composing.
The swap is now deferred while a composition is in flight, which is what
editors that survive IME do: ProseMirror gates DOM work on `view.composing`,
CodeMirror protects the composing subtree from redraws. Hiding instead of
unmounting does not work — `display:none` and `visibility:hidden` both blur the
focused element and abort the composition the same way.
The hold releases on `compositionend`, which browsers also fire on blur, so
clicking into the card's own answer input yields the input region immediately;
with nothing composing the card still replaces the composer at once, so no
stray "Send a message" appears beside a question.
The existing characterization test flips to a regression guard: it pinned the
node being destroyed, which was the defect. Node identity is the load-bearing
assertion — value-only checks are trivially satisfied by a composer that never
unmounts and cannot tell a held composition from a destroyed one.
The typing-redirect handler moves to its own hook. That is not cosmetic: both
touched files sat at the 400-line cap, and `max-lines` suppressions are
forbidden, so the room had to come from a real extraction.
* fix(macos): opt Orca out of AppKit automatic period substitution
macOS "Add period with double-space" (`NSAutomaticPeriodSubstitutionEnabled`,
on by default) is applied by AppKit's text input system. Native terminals never
join that system; Chromium text fields do, so xterm's helper textarea inherits
it and a double space arrives as `". "` — a period nobody typed, handed straight
to the PTY (#11504).
Chromium answers AppKit for quote and dash substitution and defaults both off,
but declares no period accessor at all, so AppKit applies that one without
asking. This user default is the only lever: there is no per-field or
per-webContents opt-out to prefer over it. Writing the key into Orca's own
defaults domain overrides the global value for this app alone and leaves the
user's system-wide setting untouched. It necessarily covers every Orca text
field, not only terminals — AppKit offers no narrower scope, and that tradeoff
is deliberate rather than accidental.
Measured on the reporter's own build v1.4.161: with the preference ON, typing
a,b,space,space yields `onData ["a","b"," ",". "]`; with it OFF the same arm
yields two spaces.
Note the issue's causal model is wrong and this fix does not follow it. It
claims the substitution only fires with a CJK input source and never with ABC.
The measurement is the inverse — every Korean arm is clean and the ABC arm is
the one that fires — so the fix is not conditioned on input source.
NOT YET VERIFIED ON HARDWARE. The unit tests inject the writer, so they prove
the call is made on darwin and skipped elsewhere; they do not prove AppKit
honours an app-domain override for this key. That check is outstanding.
* fix(xterm): keep a live composition across a lone Cmd press on macOS
CompositionHelper.keydown exempted keyCodes 16/17/18 from tearing a composition
down, which covers Shift/Ctrl/Alt but not macOS Meta — 91/93 in Chromium, 224 in
Firefox. A lone Cmd press mid-composition therefore reached
_finalizeComposition(false), which dropped the preedit overlay's `active` class
and committed the live syllable early. macOS keeps the marked text alive across
that press, so no later compositionstart re-arms the overlay and the rest of the
word composes invisibly.
Measured on hardware (m4air, macOS 26.5.2, Apple M4, 2-Set Korean) with the Cmd
posted as a CGEventType.flagsChanged, which is what a physical modifier emits.
AppleScript `key code 55` posts nothing a browser can see — a bare `key code 56`
for Shift is equally silent — which is why no capture in the corpus ever reached
this branch. Three arms, same build otherwise: overlay live throughout with the
exemption, dark and prematurely committed without it, live again with it
restored. Evidence under
.tmp/ime-handoff/swarm-scratch/wave31-cmd-preedit/evidence/.
The fix cannot widen past a lone modifier: only a standalone press reports these
keyCodes, and a Cmd chord during composition is reported by Chromium as 229,
which was already exempt. Cmd+A still ends the composition, via the IME's own
compositionend. Ghostty draws the same line, returning early from flagsChanged
under hasMarkedText() for every modifier including Super.
Orca's terminal pane was never affected — shouldSuppressTerminalModifierKeyboardEvent
drops a standalone Meta keydown before xterm sees it, and deleting only 'Meta'
from that set is what flipped the hardware arm to broken. The popout preview
terminal and mobile's webview install no such guard and did reach the teardown.
terminal-ime-xterm-composition-commit-overlap.test.ts asked its fixer to update
the two Cmd arms to the values it named as correct; both now emit a single ['한'].
* test(native-chat): drop two byte-identical duplicate cases
`4632b86919d` copy-pasted two cases twice into the same describe block:
`retains carry across a same-frame non-Enter keyup before redispatch` and
`expires carry before a deliberate Enter after the next frame`. Each pair is
byte-identical — same title, same body — so the copies asserted nothing the
originals did not.
This is what has been failing `static analysis` on this branch since 2026-08-06:
`oxlint vitest(no-identical-title)` reports both under `--deny-warnings`, and
`verify` fails solely because it requires static analysis to pass. Every other
gate in `verify` was already green, including typecheck, xterm patch sync, the
full test shard set, and both package jobs.
12 cases still pass in the file.
* test(e2e): skip the WebGL arm when no WebGL renderer exists
The #12164 probe runs two arms, webgl and dom, and closes by asserting the
active renderer is the requested one. That assertion is right for the dom arm —
it is what proves the pane actually left WebGL, without which the arm is
meaningless — but headless CI has no GPU, xterm falls back to DOM silently, and
the webgl arm then fails.
The failure reads as a Korean rendering defect and is not one, so the webgl arm
now skips with the active renderer named. The dom arm keeps the assertion
unchanged.
This is the third of three checks that have been red on this branch since
2026-08-06. `static analysis` and `verify` were fixed in c51c6b5837e; the CI log
shows this job as 1 failed / 1 passed, the pass being the dom arm.
* chore(lint): drop five unused no-console disable directives
`check-changed-code-quality` reports unused eslint-disable directives as errors,
and these five sat above diagnostic `console.log` calls in IME test and spec
files where `no-console` is not enabled — so each suppressed nothing.
This is the second of the two static-analysis steps. `c51c6b5837e` fixed
"Enforce focused code-quality plugins" (duplicate test titles); this fixes
"Enforce changed-code quality". Both had been red on this branch since
2026-08-06, and I mistook the first for the whole job.
The diagnostic logs themselves are kept — they are what a failing IME arm prints
for a reader to inspect.
* test(e2e): cover #12164 under fractional device scale factor
Fractional display scaling was #12164's last unexplored branch, and the reason
is worth recording: earlier attempts were BLOCKED, correctly, because they
proposed mutating the Windows display scale on a remote physical machine with no
console recovery. `--force-device-scale-factor` reaches the same renderer state
per process, so nothing outside the Electron instance changes and there is
nothing to restore.
The hypothesis was specific: `프프로로젝젝트트` is what a half-pixel cell boundary
could produce on a 2-column glyph, and nothing else in the suite varies dpr.
Measured at 1.25 and 1.5, both under WebGL: ink extents 25/21/16 with identical
ink groups, matching the scale-1 run. No doubling.
The arm self-certifies before asserting — if the flag does not take, the test
fails rather than silently measuring at dpr 1. That matters here: the sibling
spec's WebGL arm went two days reporting a missing GPU as a Korean rendering
defect precisely because a silent fallback looked like a result.
* fix(terminal): match Mod+letter shortcuts by physical key, not IME-rewritten key
With a CJK input source active, macOS and Windows report the physical key through
`code` but rewrite `key` to the layout's character: Korean 2-Set turns Cmd+C into
`{ key: "ㅊ", code: "KeyC", metaKey: true }`. Every `key.toLowerCase() === 'c'`
match misses it, so the shortcut is not recognised and xterm encodes the chord as
PTY input instead — issue #13033 reports `ESC[12618;9u` and a terminal that jumps
to the bottom, because user input scrolls the viewport.
This is the same key-vs-code confusion that owned #12171, where a `Shift+T`
typing ㅆ was read as Enter for want of a `code` guard, so the fix is the same
shape: trust `code` when it is present, fall back to `key` and then the legacy
`keyCode` when it is not (Chromium omits `code` on synthetic and some keypress
events, and `keyCode` keeps its US value even when `key` is rewritten).
Applied to the four terminal-side sites, including the dashboard pop-out, which
#13033 called out specifically as having its own key handler:
pty-connection.ts Cmd/Ctrl+C copy guard
keyboard-handlers.ts Cmd+G search navigation
agent-interrupt-inference.ts interrupt inference
preview-terminal-key-handler.ts pop-out paste
Nine further `key.toLowerCase()` letter matches exist outside the terminal
(TaskPage, editor, GitHub composer, browser markup). They have the same defect
and are deliberately left for a separate change rather than widening this one.
An existing case, `matchSearchNavigate > returns null for wrong key`, overrode
only `key` and left `code: 'KeyG'`, so it began passing for the wrong reason. It
now overrides both — which is what "wrong key" means once matching is physical —
and a companion case pins the Korean-rewritten chord still matching.
#13033 was closed NOT_PLANNED; the reporter's event shapes drive the new test.
* fix(renderer): match every Mod+letter shortcut by physical key, not IME-rewritten key
Completes the previous commit. A CJK input source rewrites `event.key` while
`event.code` keeps the physical key, so `key.toLowerCase() === 'z'` and friends
silently stop matching — the shortcut is not recognised and the keystroke falls
through to whatever handles unclaimed input.
The helper moves to `@/lib/ime-latin-shortcut-key` first: it now serves the
editor, GitHub composer and browser markup, and importing terminal-pane
internals into those would be the wrong direction. `lib/` already hosts
`ime-composition-keyboard-event` for the same reason.
Nine remaining sites, all previously unreachable under Korean/Japanese/Chinese/
Vietnamese input:
TaskPage, ActivityPrototypePage, ProjectViewWrapper Cmd+F search
useMarkupKeyboardShortcuts Cmd+Z undo
GitHubMarkdownComposer, RichMarkdownLinkBubble,
rich-markdown-link-shortcut Cmd+K link
native-chat-shortcut Cmd+J
rich-markdown-key-handler Cmd+Shift+X
Six of the nine test `!== 'letter'` as early-return guards and three test
`=== 'letter'`; the negation is applied per site, since a blind substitution
would have inverted six of them.
Full suite: 4249 files pass. Three files fail locally and none is caused by this
change — the branch touches no file under `src/main/` or `src/relay/`, all four
failures reproduce on an unmodified tree or pass in isolation (the worktree
poller passes 21/21 alone, so it is full-suite parallelism), and all 16 CI test
shards are green.
* docs(ime): scope the IME composition rules to the terminal-pane directory
#11893 proposed adding these to the root `AGENTS.md`, which every agent loads on
every task regardless of what it is doing. They only bind keyboard handling, the
composer and the terminal input path, so they belong next to that code —
`tests/e2e/AGENTS.md` already establishes the nested convention here.
Kept from #11893: range-derived commits, guarding above the key dispatch,
the `attachCustomKeyEventHandler` / `CompositionHelper` interaction, no
normalization at commit, and the recorded-trace evidence bar.
Added from defects found since it was written:
- match shortcuts on `event.code`, not `event.key` (#12171, #13033)
- `keyCode === 229` means an IME owns the press
- do not unmount a field mid-composition, and hiding is not a fix because
`display:none` blurs and aborts it too (#12118, STA-3219, #11332)
The evidence bar now also names the mutation check, since a test that survives
deleting the code it guards is guarding nothing — a failure this effort hit more
than once.
* fix(terminal): gate Ctrl+Enter CSI-u on a negotiated pane, porting #12462
Found while scoping the rebase onto `main`: #12462 landed on 2026-08-06 and
fixes a real defect this branch does not carry. Ctrl+Enter emitted
`\x1b[13;5u` unconditionally, so a pane that never negotiated the kitty
keyboard protocol — local Windows ConPTY, plain shell — printed the escape
verbatim into the prompt.
This branch deletes `terminal-ime-deferred-newline.ts`, which is one of the
files #12462 touched, so a rebase resolving those conflicts by taking our side
wholesale would silently reintroduce the defect. Porting it forward now means
the fix survives the rebase however the conflicts are resolved.
Mirrors the Shift+Enter guard already here: local ConPTY falls back to the
legacy CR every emulator sends, and a negotiated pane keeps the chord, so the
fallback is scoped to panes that cannot receive CSI-u rather than to Windows.
NARROWER THAN #12462 BY ONE CONDITION, deliberately. `main` also allows CSI-u
via `hasCtrlEnterCsiUAuthority()` (trusted consumer evidence, #12329); that
helper and its plumbing do not exist on this branch. Omitting it is the
conservative direction — an authorised pane gets `\r` instead of the chord,
rather than an unnegotiated pane printing an escape — but it should be restored
when the two histories are reconciled.
Test covers both directions and is mutation-checked: forcing the gate open
fails it, so it cannot pass by construction.
* fix: reconcile two more fixtures main moved while the stack waited
Both caught by CI, not locally, and the reason the local run missed one is
worth recording:
1. `browser-toolbar-profile-dialogs.ime-enter.test.tsx` did not pass
`useNativeUserAgent` / `onUseNativeUserAgentChange`, which `main` added to
`BrowserToolbarProfileDialogsProps`.
Local `pnpm typecheck` reported 0 errors on the same commit CI failed. The
cause was a stale `config/*.tsbuildinfo` — tsc reused an incremental cache
from before the merge. Deleting it reproduced CI's error exactly. Any
"typecheck clean" during this merge should be treated as unverified unless
the cache was cleared first.
2. Localization keys for `SshDisconnectedDialog` were absent from `en.json`:
the merge took this branch's component alongside `main`'s catalog.
Regenerated with `pnpm run sync:localization-catalog` rather than hand-added.
* fix(mobile): regenerate the lockfile the merge resolved by taking one side
CI's `verify` failed with `ERR_PNPM_OUTDATED_LOCKFILE` on `mermaid (lockfile:
11.16.0, manifest: 11.16.1)`. The mismatch was in `mobile/`, not the root — the
root lockfile was consistent throughout, which is why inspecting it (and even
GitHub's merge ref) found nothing wrong.
Cause: during the merge I resolved `mobile/pnpm-lock.yaml` by taking this
branch's side wholesale rather than merging it, so it kept `mermaid 11.16.0`
while `mobile/package.json` came from `main` at `11.16.1`. Taking one side of a
lockfile is only safe when the corresponding manifest comes from the same side.
Regenerated with `pnpm install --lockfile-only`; `--frozen-lockfile` now passes
in `mobile/`. Verified the xterm patch entry survives intact — same hash
`4f1b42d268f3964d…` and the parent-relative path into `config/patches/`, which
is the desktop/mobile coupling that would silently break the mobile build.
Two earlier diagnoses of this failure were wrong and are worth recording: it was
not the root lockfile, and it was not a stale merge ref (a rerun reproduced it
exactly).
---------
Co-authored-by: Orca <help@stably.ai>
* perf(runtime): gate terminal.list visual layouts and stop the false writable claim
visualLayouts is ~31% of a large terminal.list payload (44,208 B of 137,412 B on a live 134-terminal remote runtime) and has exactly one consumer: the human-readable CLI formatter. Gate it behind an includeVisualLayouts request param that defaults to included, so pre-flag clients are unaffected, and have every --json/internal caller opt out.
Also drop the record-backed builder's writable, which was a verbatim copy of connected. terminal.show now states writability explicitly as exactly what terminal.send's PTY gate enforces.
* test(runtime): type the payload-size fixture arrays for tsc
* fix(runtime): preserve terminal list compatibility
* test(runtime): guard terminal list optimization
* fix(cli): preserve agent access to terminal layouts
* fix(mobile): render Mermaid diagrams in MobileMarkdown (#11141)
Co-Authored-By: Grok Companion <noreply@x.ai>
* fix(mobile): keep streaming mermaid fences as raw code until the fence closes
* perf(mobile): memoize MermaidDiagram and add a CDN load watchdog
* fix(mobile): escape mermaid source before embedding in WebView script
JSON.stringify leaves </script>, &, and U+2028/U+2029 raw, so a diagram
source containing </script> broke out of the inline script and ran
arbitrary WebView JS. Diagram source is untrusted (agent output, PR/chat
content), and this component now renders from chat and markdown preview,
not just the PR sidebar. Escape those chars to \uXXXX; the literal still
parses back to the exact source. Adds an adversarial buildHtml test.
* fix(mobile): embed the mermaid engine instead of fetching it from a CDN
The diagram WebView loaded mermaid from jsdelivr at runtime: offline and
constrained-network renders always fell back, the stalled-load watchdog
existed only to paper over that, and an unpinned floating-major CDN script
with no integrity check ran inside the WebView. Embed the lockfile-pinned
package's prebuilt bundle via a postinstall generator (same mechanism as
the terminal WebView engine) so the document loads nothing external; the
watchdog is removed as obsolete and a no-external-URL gate pins it.
* chore(deps): align mermaid at 11.16.0 across desktop and mobile
Desktop floated ^11.15.0 while the mobile embedded engine resolved 11.16.0.
Raise the desktop floor so both lockfiles resolve the same version, and pin
mobile exact: the generated WebView engine embeds the package bytes, so an
implicit range bump would silently change what ships.
* fix(mobile): block Mermaid diagram network requests
Mermaid image-node URLs can initiate subresource requests even with the engine embedded. Keep the WebView offline by restricting resource types through its document CSP.
* style(mobile): format Mermaid routing test
* fix(mobile): use stable keys for Mermaid diagrams
* fix(mobile): keep duplicate Mermaid keys distinct
Combine each diagram source with its sibling occurrence so identical diagrams remain unique while source edits still remount the WebView and later streaming prose does not.
* fix(mobile): keep Mermaid transitive within release-age policy
---------
Co-authored-by: Grok Companion <noreply@x.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
* fix(mobile): route external mouse click and drag to the terminal
The terminal WebView suppresses mousedown/click at capture so xterm's own
mouse handling stays inert (its onData bytes are dropped by the mobile
bridge). That left hardware mouse clicks and drags with no path at all:
touch taps reached mouse-aware TUIs and drove selection, while a Bluetooth
mouse or trackpad click did nothing (#8818; wheel half landed in #11247).
Add a pointer-event router on the terminal surface (pointerType 'mouse',
left button only) that mirrors touch semantics:
- plain click: same pipeline as a touch tap (links/file paths first, then
tracking-mode press+release reports, else keyboard focus), and a click
on an active selection dismisses it like touch does
- drag with mouse tracking: press at the anchor, per-cell motion reports
(drag/any modes), release on pointerup or pointercancel
- drag without tracking: character-anchored selection reusing the touch
handle-drag plumbing (edge scroll, handles, copy pill)
Widen the RN gesture-input grammar to pass left-drag motion reports
(SGR button 32, default-encoding byte 64) through the existing
validation and rate limiting.
Mock server: echo the subscribe viewport and serialize scrollback so the
session screen leaves the resubscribe loop, serve the session-tabs
subscribe stream, and add a MOCK_TUI=1 mouse-tracking scenario plus a
[SEND] byte log - the rig used to reproduce and verify this fix on an
Android emulator.
Fixes#8818
* fix(mobile): capture the mouse pointer and clear stale gestures on pointerdown
A drag leaving the terminal surface dropped pointermove/pointerup without
pointer capture, stranding the gesture; a pointerup lost outside the
WebView could leave a tracked press latched until the next gesture.
* fix(mobile): end mouse gestures whose pointerup never reached the surface
Capture the mouse pointer on pointerdown so a drag that leaves the surface
keeps delivering pointermove/pointerup; when capture is unavailable and the
release is lost anyway, synthesize the release from the next buttons==0
pointermove or the next pointerdown, so a tracking TUI is never left with
the left button latched down.
* fix(mock-server): clear the terminal stream interval on resubscribe and unsubscribe
* fix(mobile): synthesize the lost-pointerup release at the pointer's current cell
* test(mobile): split terminal mouse click and drag coverage
* test(mobile): satisfy changed-line quality checks
* fix(mobile): cancel stale mock terminal callbacks
* refactor(mobile): extract mouse report cell mapping
* feat(mobile): add session.tabs.list handler to mock server
The mock WebSocket server had no handler for session.tabs.list, so the
session screen of a paired dev client hung on 'Loading tabs' forever —
the terminal pane, live input, and command input could never be
exercised against the mock. Respond with a single ready terminal tab
wired to the existing term-1 fixture so the whole session surface works
offline.
* fix(mobile): complete the session.tabs.list mock contract
The new mock response omitted four non-optional fields of
RuntimeMobileSessionTabsResult: publicationEpoch and activeGroupId on the
result, and parentTabId and leafId on the terminal tab. Nothing caught it —
the object literal had no type annotation, and MobileSessionTabsStreamHealth
is generic over both result and tab. A shape-incomplete mock yields
untrustworthy repros for exactly the bugs it gets used for (session tabs,
split panes, pane-to-tab attribution).
Fill the fields with host-realistic values: a per-process publisher epoch, a
layout UUID leaf id, and the `${parentTabId}::${leafId}` surface id
mobileTerminalSurfaceId actually emits. Pin the shape with an explicit return
type so a future required field fails typecheck instead of silently drifting.
Move the fixture into its own module: inlining it pushed
mock-server-rpc-handlers.ts to 317 lines against a 300-line max-lines cap,
which broke `pnpm lint` on the parent commit. It registers through the file's
existing delegation chain, after the native-chat scenario so MOCK_NATIVE_CHAT=1
keeps ownership of the method.
Co-authored-by: Hanjoon Choe <hanjoonchoe@gmail.com>
* test(mobile): pin session tabs mock fidelity
Normalize the selector-backed worktree ID like the real runtime and cover the complete terminal surface response so future contract drift fails the mobile suite.
* fix(mobile): share terminal.list worktree resolution with session tabs
Main added `terminalListWorktreeId`, which the rebased session-tabs fixture
duplicated with a different no-selector fallback — `terminal.list` resolved to
the active fake worktree while `session.tabs.list` returned a literal 'mock',
so a session repro saw two different worktree ids for one screen.
* test(mobile): cover the bare session-tabs worktree selector
Answers the review note that only the `id:`-prefixed path was exercised.
* fix(mobile): make the mock publication epoch unique per process
Date.now() can repeat across a sub-millisecond restart, so the epoch did not
actually guarantee the fresh-publisher identity its comment claimed.
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
* feat(codex): backfill managed-home sessions into the real Codex home once per host
Orca-launched Codex sessions currently land only in the Orca-managed
runtime home, so the user's own `codex resume` picker and app history
never see them (#4444, #8612). Backfill the managed sessions tree into
the real ~/.codex/sessions/YYYY/MM/DD layout once per host:
- hardlink first (one physical rollout log), copy as the cross-volume
fallback; existing target files are always skipped, nothing in either
home is deleted or moved
- idempotent; per-file failures leave the completion marker unset so the
next startup retries cheaply
- JSONL audit log of every link/copy/failure under
<userData>/codex-session-backfill/
- honors the custom Codex session source home override, mirroring the
existing system->managed bridge
WSL managed homes are distro-local and need an in-distro variant; that
is a follow-up.
* feat(codex): flag-gated system-default real-home routing scaffolding
Staged internal flag (default OFF, no settings UI): route the SYSTEM-DEFAULT
Codex account at the user's real ~/.codex instead of Orca's managed runtime
home. Flag OFF is byte-identical to today; managed (multi-account) selections
are unchanged in either state.
Routing (flag ON + host system default = no managed account):
- CodexRuntimeHomeService.prepareForCodexLaunch / prepareForRateLimitFetch
return null so the PTY/env layer injects no managed CODEX_HOME and the
rate-limit fetcher + auth-presence gate fall back to ~/.codex (the background
poller stops spawning Codex against the managed home — the #5370 auth war).
- buildPtyHostEnv strips only a nested-Orca-inherited Orca-owned override
(CODEX_HOME matching the private ORCA_CODEX_HOME marker), preserving a
user-set CODEX_HOME. Shell-ready re-exports already no-op without the marker.
- The headless commit-message Codex path strips the same inherited override.
Hook install for the real-home lane (append-last into ~/.codex/hooks.json,
trust via the app-server client) lands with the trust plumbing; the managed
hook install is skipped for this lane meanwhile.
Credit @jellychoco (#8606) for the native-home routing direction.
Depends on the codex trust-rpc-grant plumbing for the real-home hook installer.
* fix(codex): strip the daemon-inherited Orca CODEX_HOME override for real-home routing
The daemon spawns PTYs from its own inherited environment and honors only
spawnOptions.envToDelete, so mutating the sparse env object was not enough to
strip an Orca-owned CODEX_HOME the daemon already carries. Add the strip to
envToDelete for both daemon host-spawn paths, preserving a user-set CODEX_HOME.
Verified live via CDP against a sandboxed dev instance (flag ON): an
Orca-spawned pane reports empty CODEX_HOME/ORCA_CODEX_HOME, so Codex resolves
its own ~/.codex. Adds daemon-path unit coverage (strip Orca-owned, preserve
user-owned, no-op when flag OFF).
* fix(codex): harden one-time session backfill
* test(codex): cover staged cross-volume install
* feat(codex): app-server trust-grant client, capability cache, and grant ledger
Short-lived codex app-server JSON-RPC client (hooks/list + config/batchWrite,
the same pair the Codex TUI 'Trust all' flow calls), run in a bundled
ELECTRON_RUN_AS_NODE entry so synchronous launch prep can block on it with a
hard deadline and guaranteed child reap. Capability cache modeled on
GitCapabilityCache, scoped per execution host (native vs each WSL distro),
with a narrow unknown-method/missing-subcommand unsupported predicate. The
grant ledger records verified grants so steady-state launches skip the RPC.
* fix(codex): grant managed hook trust via codex app-server RPCs in install/refresh
Host and WSL installs now grant trust for Orca's managed status hooks through
codex's own hooks/list -> config/batchWrite -> re-list verify, scoped to
exactly the managed entries; the previous computeTrustedHash lane is the
unchanged fallback for incapable/erroring CLIs. getStatus and the removal
paths recognize ledger-recorded codex hashes so drift between codex's real
algorithm and the replica no longer misreports or strands trust. SSH remote
install is untouched by design.
* test(codex): cover app-server trust grant client, cache, ledger, and lanes
* test(codex): cover commit-message real-home override strip/preserve
Adds the two cases for the headless commit-message Codex env under real-home
routing: a nested-Orca-inherited Orca-owned CODEX_HOME is stripped, and a
user-owned CODEX_HOME is preserved.
* test(codex): WSL grant-lane coverage — in-distro invocation and fallback parity
* feat(codex): real-home hook installer trusted via the codex app-server grant client
With the real-home flag ON and the system-default selection, install Orca's
status hook into the user's real ~/.codex before any pane spawns:
- entry APPENDED LAST per managed event: codex hook trust keys are positional
(source:event:group:handler), so appending keeps every user entry's position
and trust record intact; user entries and unknown top-level hooks.json fields
are preserved verbatim
- trust is granted exclusively through the codex app-server client
(hooks/list + config/batchWrite, verified by re-list); Orca never writes
[hooks.state] into the user's real config.toml itself
- if the grant lane is unavailable (old binary, unsupported RPC, verify
failure), the appended entry is rolled back byte-exactly and the host keeps
the managed-home lane end to end (PTY env, rate limits, commit messages)
via a lane gate on the runtime-home service
- one-time pristine backup of the user's hooks.json under Orca's userData;
a rolling .bak sits next to the file (existing atomic writer)
- hook opt-out sweeps Orca entries from the real home and drops Orca-owned
trust records; flag-off downgrade re-arms the existing legacy system-home
sweep, which removes the entry and its trust keys cleanly
- the legacy system-home sweep is suppressed only while the real-home lane
owns ~/.codex/hooks.json, so managed installs cannot delete the entry
* fix(codex): resolve the trust-grant entry without requiring electron
The grant bridge is reachable from plain-Node CLI entries, where the
plain-node entry guard rejects any chunk containing require("electron").
Resolve the bundled session entry from __dirname (root chunk and chunks/
layouts) with an app.asar -> app.asar.unpacked rewrite for packaged runs,
instead of electron's app path APIs.
* fix(codex): keep session backfill off main thread
Use asynchronous, sequential filesystem operations for the one-time rollout backfill, and avoid repeated target-directory probes. Treat inaccessible managed session roots as retryable failures instead of writing a false completion marker.
* fix(codex): harden app-server trust grant fallback
* fix(codex): install cross-volume session backfill copies atomically
On a real Codex home whose filesystem supports no hardlinks (exFAT/FAT,
some network mounts), the staged cross-volume copy was installed with a
non-atomic copyFile(..., COPYFILE_EXCL) straight into the final
rollout-*.jsonl name. An install interrupted mid-copy (app quit, crash,
ENOSPC during the deferred run) could strand a truncated rollout that the
next run then skips as already-present, defeating the staging design's own
guarantee that a failed copy never leaves a partial session behind.
Install the fully-staged copy with an atomic rename instead, guarded by an
existence re-check so it keeps the never-overwrite contract (and the rename
source is the same immutable managed rollout, so any clobber would be
byte-identical). Cover the no-hardlink-support target and an interrupted
install that must leave no partial in the user's sessions tree.
* fix(codex): resolve grant entry from __dirname so plain-node CLI entries stay electron-free
The build guard rejects any electron require reachable from plain-node
entries; the bridge now maps app.asar to app.asar.unpacked by string
replacement instead of consulting electron app paths. CLI typecheck project
lists the new trust-grant module graph.
* fix(codex): harden trust grant reconciliation
* fix(codex): restore trust config permissions on rollback
* fix(codex): harden real-home routing cleanup and retries
* fix(codex): preserve unicode trust RPC responses
* fix(codex): preserve remote env and complete real-home cleanup
* fix(codex): preserve real-home lane invariants
* test(terminal): isolate replacement idle reset assertion
* fix(codex): preserve real-home dotfile links
* fix(codex): preserve verified trust grants across launch prep
* fix(codex): preserve dangling config symlinks on rollback
* fix(codex): don't revoke a just-granted WSL home on a false 'missing' probe
The async wsl.exe canonical-path settlement could report the runtime home
'missing' immediately after a verified RPC grant (a false negative — codex
had just written and re-listed trust there), which drove the reconciliation
'remove' branch to delete all six granted [hooks.state] tables, leaving a bare
[hooks.state] the launching pane read as 'hooks need review'. A 'missing'
settlement now revokes only when no successful install ran this generation; a
genuinely moved home still resolves to a different path and reinstalls.
* test(codex): model codex config/batchWrite faithfully on Windows
The grant-lane stub simulated codex by calling Orca's upsertHookTrustEntries,
which writes both separator variants for a Windows key (a fallback-lane compat
shim real codex never does) — fabricating duplicate tables and whitespace the
RPC path never produces, so the byte-stable and no-duplicate assertions failed
on win32. Replace it with a single-variant, blank-line-separated writer that
matches the real 0.144.x binary's output.
* feat(codex): collapse duplicate session listings across Codex roots
Backfilled/bridged rollouts are hardlinked into both the real ~/.codex and
Orca's managed runtime home, so AI Vault listed each session once per root
(#7521). Dedup candidates by rollout file name pre-parse and parsed sessions
by session id post-parse, keeping the canonical root: host real home first
(unprefixed resume), then the managed runtime home, then other homes. Applies
to local, WSL, and SSH-remote scans.
* feat(codex): background sqlite index heal for backfilled sessions
Codex's own state-DB metadata backfill is one-shot, so rollouts hardlinked in
by Orca's session backfill never become visible to Codex's DB-driven surfaces.
Extract the app-server stdio JSONL transport into codex-app-server-session
(shared with the trust-grant client) and add a bounded, resumable background
pass that drives Codex's lazy indexing via thread/read per backfilled session:
recent-first, batched onto one short-lived server per batch with small
concurrency, ledger + marker so steady-state startups are a no-op, stop-aware
on quit, and capability-aware on CLIs without the app-server surface.
* fix(codex): preserve session identity during dedup heal
* fix(codex): preserve user trust during real-home cleanup
* fix(codex): harden real-home heal boundaries
* fix(codex): fail closed on unsafe backfill install
* fix: harden real-home hook cleanup
* fix(ai-vault): preserve execution boundaries and reap children
* fix(codex): narrow app-server unsupported detection
* fix(codex): bound user hook trust rebase retries per host
The rebase lane ran a codex app-server session on every launch prep while a
host was stuck (CLI without app-server support, or keys hooks/list cannot
match). Gate the transaction on the shared capability cache and add the same
5-minute transient cooldown the grant lane uses, so sweep and legacy-cleanup
retries cost plain fs reads instead of a codex session per pane spawn.
* fix(codex): enforce real-home resume and heal boundaries
* fix(codex): establish real-home lane before cleanup
* fix(codex): stop index heal before delayed spawn
* fix(codex): protect symlinked rolling backups
* fix(ai-vault): preserve resume env deletion through drag
* fix(codex): strip inherited Codex homes on mobile real-home resume
The mobile resume surface types a bare real-home codex resume into a
freshly created pane, but never asked for CODEX_HOME/ORCA_CODEX_HOME
deletion at pane spawn, so an agentDefaultEnv-pinned or daemon-inherited
Codex home rerouted the resume away from the user's real ~/.codex while
the same session resumed correctly on desktop. Share the deletion helper
from the AI Vault resume builders and forward it through the mobile
launch and session.tabs.createTerminal call.
* fix(codex): gate session migration on real-home lane
* fix(codex): stop session backfill after opt-out
* fix(codex): keep session heal failures retryable
* fix(codex): keep session migration state recoverable
* fix(codex): retry republished missing session heals
* fix(codex): preserve hook symlink trust path
* fix(codex): disambiguate POSIX trust paths
* fix(codex): align hook trust source paths
* fix(codex): harden trust grant lifecycle
* fix(codex): restore envToDelete on client invocation type after base reconcile
* test(codex): type child.stdout as PassThrough for oversized-output write
* Assemble RC: reconcile app-server transport API across PRs
Unify on the object RPC surface from the index-heal transport (#8921) while
preserving the default-home env strip (#8828) and the narrowed missing-app-server
capability signal (#8847): adapt the user-hook-trust-rebase consumer + tests,
port envToDelete stripping into the shared session, and route stderr
classification through the canonical capability-signal module.
* RC: enable system-default real-home routing by default (flag ON)
Flip codexSystemDefaultRealHomeEnabled to default ON for this RC's staged
rollout (a user can still opt out by setting it false, which stays byte-identical
to managed-home behavior). This is the only intended behavior difference between
the RC branch and the individual PRs. Updates the two tests that assumed the
prior OFF default.
* fix(codex): snapshot hooks.json bytes+parse in one read to close real-home clobber race
The install/sweep/legacy-cleanup paths parsed hooks.json, then did a separate
later read to capture the previous bytes for the pre-write generation guard.
A concurrent save (second Orca instance or the user editing the file) could
land between the parse and that second read and be silently overwritten.
readHooksJsonWithRaw returns the raw bytes and parse from a single read so the
guard compares against exactly what it parsed. Adds a regression test that
mutates hooks.json mid-RPC and asserts the sweep aborts without clobbering.
* fix(codex): sanitize managed account config trust
* fix(codex): guard OAuth add for custom providers
* fix(codex): persist outgoing managed tokens before real-home lane takeover (PR-C)
prepareForCodexLaunch returns null early for the real-home / system-default
lane before syncForCurrentSelection runs. If a managed account is still
recorded as synced when the selection has dropped to the system default
(nulled without a sync pass, or auto-deselect on missing managed auth), a
Codex-refreshed token stranded in the shared runtime home is never persisted
to its canonical per-account home -> token loss.
Read the outgoing managed account's refreshed token back before the real home
takes over. The real-home lane implies host === null, so running the
managed->system-default transition restores only Orca's runtime mirror from
~/.codex and never writes the real ~/.codex. It is a no-op once the selection
has already been reconciled, so the normal select path does not double-write.
* fix(codex): preserve refreshes across all default transitions
* feat(codex): show system-default/real-home account identity in switcher (PR-B)
The account switcher modeled the system-default Codex account as
activeAccountId:null with no identity fields, so the null row rendered
blank ("System default" / generic subtitle) even though its effective
login is whatever ~/.codex/auth.json currently is.
Add a CodexSystemDefaultIdentity descriptor {hasAuth, authKind, email,
providerAccountId, workspaceLabel} to CodexRateLimitAccountsState,
resolved live and READ-ONLY from ~/.codex by the accounts service and
returned from listAccounts()/getSnapshot(). The settings switcher now
renders the null (system-default) row as that real identity: the OAuth
email when signed in, "Custom provider — no usage tracked." for
env-key/custom-provider logins (auth.json with OPENAI_API_KEY, or an
OPENAI_API_KEY env with no auth.json), and the generic fallback when
signed out. Identity is host-scoped (per-distro WSL keeps the generic
label). Orca never writes ~/.codex; managed-account switches only touch
Orca-owned homes, so the system-default identity stays a stable,
displayed source of truth. Usage already routes to the real home via
getSystemCodexHomePath, so the switcher now attributes it to a real face.
Tests (sandboxed temp homes only): OAuth email/provider resolution,
api-key auth.json and env-key (no auth.json) as custom-provider,
signed-out, and select/deselect of a managed account never mutating
~/.codex/auth.json.
* fix(codex): parse multiline provider pins in OAuth guard
* fix(codex): harden managed trust sanitization
* fix(codex): harden system-default identity rendering
* feat(codex): give each managed account a self-contained CODEX_HOME; retire shared mirror (PR-E)
With the real-home flag ON, a host managed account now launches directly
against its own codex-accounts/<id>/home instead of the shared runtime
mirror + auth.json hot-swap:
- codex-home-paths: syncSystemCodexResourcesIntoManagedHome links system
resources into any managed home (ownership-marker discipline; never
symlinks into / mutates ~/.codex).
- runtime-home-service: prepareForCodexLaunch / prepareForRateLimitFetch /
syncForCurrentSelection route the per-account home directly and skip the
shared-home hot-swap + token read-back; each home keeps its own auth in
place (fixes GAP-5 concurrent auth race). Session discovery scans every
per-account home.
- hook-service / hook-trust-promotion: install/getStatus/refresh accept a
runtimeHomePath so hooks + RPC-granted trust land in the per-account home.
- service: config mirror into a self-contained home uses the trust-
preserving merge so granted hook/project trust survives account switches.
- codex-session-root-dedup: rank codex-accounts/<id>/home as canonical
managed alongside the shared runtime home.
Flag-OFF and the system-default real-home (null) lane are unchanged; the
nested-Orca CODEX_HOME===ORCA_CODEX_HOME daemon strip (#5370) is preserved.
Sandboxed tests only; ~/.codex is never mutated.
* fix(codex): validate per-account home ownership
* fix(codex): keep managed rollouts discoverable across real-home opt-out
WI-4 lossless migration/rollback validation for pre-E shared-mirror managed
accounts. Session discovery gated the per-account home scan on the real-home
flag, so opting back out (flag OFF) hid every rollout an account accumulated
while the flag was ON — the data stayed on disk but vanished from the AI Vault
until the flag flipped back on.
Scan a managed host home whenever it holds a sessions/ tree, independent of the
flag; a never-enabled install keeps its homes credential-only so opt-out stays
byte-identical to today. Forward migration was already lossless (the shared
mirror is always scanned) and the opt-out credential read-back already refuses
to overwrite a fresher per-account token; add tests locking all three
invariants. Sandboxed tests only; ~/.codex is never touched.
* fix(codex): migrate stranded shared auth on E takeover
* test(e2e): isolate Electron from developer Codex home
* test(codex): add real-account validation harness
* fix(codex): finish C and E matcher composition
* fix(codex): bound validation harness shutdown
* test(codex): isolate hook lifecycle user data
* test(codex): cover realistic account-home migration
* fix(codex): keep standalone home tripwire active
* test(codex): fingerprint system auth in validation reports
* fix(codex): bind managed homes to account ownership
* fix(codex): normalize Windows trust source identity
* fix(codex): make Windows trust upgrade transactional
* test(codex): use TypeScript pipeline for validation scripts
* test(codex): run validation modules through native node
* test(codex): allow slow Windows tripwire startup
* fix(codex): survive lingering Windows codex login processes in add-account
On Windows, codex login can keep running (with descendants) after it has
written auth.json, holding OS handles on the per-account managed home
(log/codex-login.log). That made doAddAccount's post-login cleanup fail
with ENOTEMPTY (rmSync) and left an orphaned codex-accounts/<id>/home.
- runCodexLogin now watches for auth.json on Windows and force-kills the
login process tree (taskkill /t) if it lingers past a short grace
period; the forced exit is treated as a successful login. The 120s
timeout path also kills the whole tree instead of only the direct
child. macOS/Linux behavior is unchanged.
- safeRemoveManagedHome now removes homes with rmSync maxRetries /
retryDelay (mirroring the local-worktree-filesystem Windows policy)
and no longer lets a cleanup failure mask the original add error.
- run-codex-real-account-validation.mjs accepts --temp-parent /
ORCA_CODEX_VALIDATION_TEMP_PARENT so the disposable root can live
outside %USERPROFILE% on Windows, and fails with an actionable message
before creating anything when the temp parent is inside the primary
home. The real-home guard is unchanged.
* fix(codex): preserve managed-account MCP .credentials.json on per-account-home migration (#8440)
Codex file-mode MCP OAuth tokens live in $CODEX_HOME/.credentials.json,
keyed by MCP server URL with no account identity of their own. The legacy
shared-mirror -> per-account-home migration only carried auth.json, so an
existing managed account with authed MCP servers had its tokens stranded on
upgrade and silently needed re-auth.
Carry the shared mirror's .credentials.json into the same identity-proven
per-account home alongside auth.json: only into the single uniquely-matched
active account (no cross-account leak), only when the destination has none yet
(never clobber a newer file the account authed in its own home), atomic 0600,
absent-source no-op. New MCP auth already lands in the per-account home since
that home is CODEX_HOME.
* fix(codex): preserve Windows reauthentication login flow
* test(codex): build real-account validation harness cross-platform on Windows
The harness built its app with execFileSync('npx', ['electron-vite', ...]),
but npx resolves to a .cmd shim on Windows that execFileSync cannot launch
(ENOENT), so the harness could not build its own app there and required
--skip-build with a prebuilt out/main/index.js.
Extract resolveElectronViteBuildCommand(repoRoot): it runs the repository-local
electron-vite JS entry (node_modules/electron-vite/bin/electron-vite.js) with
the current Node binary (process.execPath), which resolves identically on
macOS, Linux, and Windows with no shell. It throws a clear error if the local
entry is missing (install deps or pass --skip-build). --skip-build behavior is
unchanged.
Add regression coverage asserting the build command uses process.execPath and
the repo-local JS entry (not npx), and that a missing entry fails clearly.
* fix(codex): version the MCP creds migration independently of the auth marker
The auth carry and the MCP .credentials.json carry (#8440) shared one
existence-only v1 marker, so any build that stamped the auth-only marker
first would strand the MCP store forever. The MCP carry now concludes via
its own per-account-mcp-creds-migration-v1.json marker and runs even when
the auth marker is already present; ordering is code-enforced instead of
landing-discipline-enforced.
Also isolate per-account read failures: one stale or deleted account home
no longer aborts the whole migration. The broken account stays in the
unique-identity ambiguity gate via its stored fields but is never read or
written, so the active account still migrates.
* fix(codex): fail corrupt managed auth.json without echoing credential bytes
A raw JSON.parse SyntaxError from loadOAuthCredentials could carry auth
file fragments into logs and the add/reauth error surface. Throw a
sanitized error instead; filesystem errors still propagate unchanged.
* fix(mobile): give the pairing runtime a disposable home for the E2E boot guard
The main-process guard now refuses to start with ORCA_E2E_USER_DATA_DIR
set but the real user home, and this was the one caller not updated —
the temporary pairing runtime crashed before emitting its pairing URL.
* test(codex): canonicalize harness containment guards and retry cleanup
Resolve symlinks before the disposable-root containment checks so a
symlinked temp parent cannot smuggle the throwaway home inside the
primary home, and give the final cleanup rm Windows retry/force so a
briefly lingering codex handle cannot strand the credential-bearing
root.
* test(codex): add lane-aware containment mode to the real-account harness
The Windows gate-D run proved strict zero-event whole-profile containment
is structurally unreachable with the real-home flag ON: system-default
spawn sites deliberately delete CODEX_HOME so native codex resolves the
real ~/.codex, and on Windows the binary ignores the USERPROFILE sandbox.
Its own volatile runtime churn (root sqlite/WAL/SHM, tmp/, log/) is the
shipped Phase-1 design, not a candidate defect.
--lane-aware-containment records those designed events without aborting
while every other real-home write — auth.json, config.toml,
.credentials.json, hooks.json, sessions/, anything unknown — remains a
hard violation and still aborts the run. Default behavior is unchanged
(strict); the absolute zero-event claim stays carried by macOS runs,
where HOME does sandbox native codex.
* test(codex): allow the real-account harness to pin the real-home flag off
--system-default-real-home off seeds and env-pins the flag OFF so every
codex spawn gets an explicit managed CODEX_HOME and native codex never
resolves the OS profile. This is the only Windows configuration where the
strict zero-event whole-profile tripwire is reachable, and it matches the
stable-rollout default; flag-ON runs keep lane-aware classification.
* test(codex): correct the flag-off harness comment to kill-switch rationale
The rollout ships all codex-home changes at once (no phased rollout), so
flag OFF is the emergency kill-switch lane, not the stable default.
* test(e2e): canonicalize the isolated E2E home path
The disposable HOME lives under os.tmpdir(), whose spelling is an alias
on CI (macOS /var symlink, Windows 8.3 RUNNER~1). Git canonicalizes
worktree paths, so worktrees created under the aliased home never
matched the app's listing — golden core flows and the packaged
crash-survival harness failed with 'worktree created but not found in
listing'. Resolve the home to its canonical spelling at creation in
both the e2e helper and the packaged-app driver.
* fix(codex): address CodeRabbit review on the landing PR
- carry envToDelete through the mobile agent-resume startup plan so a
real-home Codex resume cannot inherit an ambient CODEX_HOME
- strip Orca-owned Codex overrides in the commit-message WSL fallback,
matching the host fallback
- strip ELECTRON_RUN_AS_NODE in the computer-e2e driver like every other
home-isolation caller
- drop the unused hooksEnabled parameter from isRealHomeCodexHookLaneUsable
* feat(codex): ship real-home routing unconditionally, remove the rollout flag
The codexSystemDefaultRealHomeEnabled setting is gone from types and
constants and the helper no longer consults settings — the system-default
real-home lane and per-account homes ship for everyone in one release.
This also un-strands profiles that rc-era builds stamped with false (the
setting had no UI, so every stored false was a seeded artifact that would
have silently kept those users on the legacy mirror forever).
The ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME env override survives strictly as
a test-rig control: the containment harness pins the legacy lane for
strict zero-event Windows runs, e2e home isolation pins lanes inside
disposable homes, and the legacy-lane test suites now route their
per-test lane selection through it.
---------
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
* feat(mobile): add Quick Commands (terminal + agent-prompt presets)
Brings the desktop Terminal Quick Commands feature to mobile: saved
agent-prompt or terminal-command presets that launch a new terminal tab.
Entry point sits in the session tab strip next to the "+" new-terminal
button (with a divider) — quick commands spawn a tab, so they live with
tab creation, mirroring desktop's tab-bar split button.
- Launcher button + Quick Commands bottom sheet (search, This project /
Global groups, run/edit/delete rows, add row).
- Add/Edit sheet mirroring desktop TerminalQuickCommandDialog: Label,
Action toggle (Terminal Command | Agent Prompt), Agent select, Prompt /
Command Text, Advanced (Append Enter, Scope Global/Project), validation
and save-failure feedback.
- Launch reuses handleCreateTerminal (extended with enter + toast copy):
agent prompts launch the agent then deliver the prompt; terminal
commands run the (Enter-appended) command text.
- Expose terminalQuickCommands over the remote/mobile RPC surface
(getClientSettings/updateClientSettings allowlists, RuntimeStore type,
and the strict SettingsUpdate zod schema).
- Mirror the agent-prompt support predicate mobile-side (stdin-after-start
agents are unsupported) with a parity test guarding drift from desktop.
- Mock server: sample quick commands + settings.update handler for QA.
* fix(mobile): harden quick command execution
* fix(mobile): harden quick command persistence and launch
* test(mobile): preserve unexpected quick command errors
* fix(mobile): harden quick command launch performance
* fix(runtime): reject malformed quick command updates
* refactor(mobile): reuse shared quick-command logic instead of mirroring
The mobile quick-commands mirror was built on a false premise — that
runtime-importing src/shared/terminal-quick-commands breaks the RN bundle
/ Vitest. It doesn't: tui-agent-config → orca-cli-command-name is a pure
leaf with no module-load Node APIs (verified via probe + bundle-graph).
- Mobile now reuses the canonical desktop helpers (action/agent/scope/
matchesRepo/support/flatten) directly from src/shared; only genuinely
mobile-specific pieces (agent-branded labels, native row truncation,
the launch plan) stay local.
- Multiline runnable terminal commands now flatten via the shared
flattenTerminalQuickCommand (";"-join) — unity with desktop, so a
command saved on one runs identically on the other.
- Drop the MOBILE_TUI_AGENT_PROMPT_COMMAND_UNSUPPORTED mirror + its parity
test; use the shared supportsTerminalAgentQuickCommand predicate.
- Export the shared MAX_QUICK_COMMAND_* length caps for reuse.
* fix(mobile): protect quick command data boundaries
* fix(mobile): enforce quick command limits
* fix(mobile): make quick command updates atomic
* fix(mobile): keep quick command filters recoverable
* fix(mobile): use filled play icon for quick commands
* Revert "fix(mobile): use filled play icon for quick commands"
This reverts commit 169bf053b0.
* fix(mobile): gate quick commands on host capability
* feat(mobile): show usage reset countdown on accounts screen
Surface the rate-limit reset time ("5h resets in 3h 54m · 7d resets in
6d 7h") under the usage bars on the mobile accounts screen, matching the
desktop status-bar tooltip copy. The resetsAt timestamps already arrive
in the accounts.subscribe snapshot; this only adds the presentation.
Claude-Session: https://claude.ai/code/session_01FvjvCsc9QoyQALqvxkvDqQ
* docs(mobile): JSDoc for new usage reset selectors
Claude-Session: https://claude.ai/code/session_01FvjvCsc9QoyQALqvxkvDqQ
* refactor(mobile): per-bar reset countdown instead of combined line
Drop the redundant "5h/7d" prefixes — each countdown now renders under
its own bar ("Resets in 3h 54m"), matching the desktop tooltip copy
exactly.
Claude-Session: https://claude.ai/code/session_01FvjvCsc9QoyQALqvxkvDqQ
* Extract shared reset-countdown formatter for desktop and mobile
- Move duration/countdown formatting out of tooltip.tsx into
src/shared/rate-limit-reset-format.ts so mobile's account-usage-state
can reuse it instead of a duplicated copy (with tests).
- Re-export formatResetCountdown from tooltip.tsx to avoid touching
existing import paths.
- Resend the pairing deep link once more in start-emulator.mjs since
the first can arrive before the Expo app's JS router is ready.
---------
Co-authored-by: kaynan <kaynan.camargo@terceiro-sky.com.br>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
* fix(mobile): bundle terminal engine and show load errors instead of a blank pane
The mobile terminal WebView loaded xterm.js from cdn.jsdelivr.net at
runtime; old WebViews (< Chrome 85) fail to parse the modern bundle and
blocked-CDN networks fail to fetch it, and the resulting error was
silently dropped, leaving the pane permanently blank (#7030).
Bundle the engine into the app via exact-pinned npm deps + a postinstall
esbuild step (chrome74 target, guarded WeakRef/structuredClone/
replaceChildren shims) emitting a gitignored generated module, inline it
into the terminal document, and surface fatal engine failures as a
visible overlay with diagnostics and a Reload wired into the existing
resubscribe path. Non-fatal errors log without covering a live terminal.
Co-authored-by: Orca <help@stably.ai>
* fix(mobile): add a native watchdog so a dead terminal document can't stay silently blank
CodeRabbit round: if the webview document dies before the glue can post
anything (or the RN message bridge never comes up), no error message and
no native handler fires. Arm a 15s foreground-gated watchdog per document
generation that paints the fatal overlay when web-ready never arrives;
first fatal diagnostics win over later cascades. Extract the watchdog and
the public contract types to keep TerminalWebView under the line cap, and
document the SVG xmlns percent-encoding transform.
Co-authored-by: Orca <help@stably.ai>
* test(mobile): unmount TerminalWebView renderers so watchdog timers can't leak across tests
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
Migrate fileURLToPath(import.meta.url) / dirname(...) boilerplate to the
native import.meta.dirname / import.meta.filename, then enable the rule
at error so new code stays on the native form.
The oxlint autofix rewrites the expression but leaves the now-unused
node:url / node:path imports behind (which the already-enabled
no-unused-vars=error would then flag), so this commit also removes those
34 orphaned imports — trimming the named import where other names are
still used, deleting the line where it was the sole import.
Scope is build scripts + Node-env tests only (config/scripts, tools/
benchmarks, *.test.{ts,mjs}, vitest configs); zero shipped runtime code.
The native properties are exact equivalents (Node >= 20.11; repo is on
24), so behavior is unchanged.
Verified: oxlint 0 errors tree-wide (root + mobile), oxfmt clean,
typecheck (node+cli+web) + mobile tsc pass, root vitest 22825 passed /
0 failed, mobile vitest 1018 passed. Exercised the rewritten scripts
directly: build:relay (6 targets), ensure-native-runtime,
verify-macos-entitlements all run correctly with import.meta.dirname.
* feat(mobile): add commit failure recovery panel with AI fix action
- Surfaces a "Commit failed" panel with a one-tap AI fix button when a
git commit fails in the source control view or PR creation flow
- Detects commit failures specifically during the committing progress
step and captures staged entries and commit message for context
- Extracts commit failure summary and prompt logic into
`src/shared/source-control-commit-failure.ts` and PR checks prompt
into `src/shared/pr-checks-fix-prompt.ts` so both desktop and mobile
share the same implementations
- Adds auto-find of an available Metro port starting from 8081 and
extracts expo CLI bootstrap into `mobile-expo-cli.mjs` shared by
`start-emulator` and a new `start-expo.mjs` wrapper
* Share source-control AI prompts and simplify mobile PR actions
- Extract conflict, check-fixing, and commit-failure prompt builders
to shared modules for reuse by both desktop and mobile.
- Configure Metro in the mobile package to watch and bundle modules
from the repository-root shared directory.
- Remove the desktop-style merge method picker from the mobile PR
actions panel, opting to use repository defaults automatically.
- Refactor mobile hosted review creation and git preparation logic
into dedicated helper files.
* Implement automated git preparation workflow for mobile PR creation
Introduce a structured hosted review intent preparation workflow to handle
staging, AI commit message generation, committing, and pushing changes
automatically before displaying the pull request composer on mobile.
- Map creation block reasons to descriptive user-facing validation errors
(e.g., dirty working tree, default branch, detached head) to match desktop.
- Decouple hosted-review business logic into a dedicated service helper.
- Update source control runner hooks to handle the new preparation flow.
* Refactor mobile PR creation to run intent and open URL directly
Remove MobilePrComposeSheet and the local compose form, moving instead
to a direct PR creation workflow that matches the desktop experience.
- Add runMobileHostedReviewCreateIntent to handle the full prepare,
push, and create sequence.
- Replace useMobileOpenPrSheetRunner with useMobileCreatePrRunner to
trigger the creation workflow and directly open the created PR URL.
- Simplify state management by removing showPrSheet, prPrefill, and
associated local compose sheets.
* Propagate git status and commit state on PR creation failure
Update `MobileHostedReviewCreateIntentOutcome` and the local change
commit helper to include optional `committed` and `status` fields in
their failure results.
This ensures that if PR preparation fails, callers still receive the
current repository status and know if their local changes have already
been committed.
* Add tests for mobile hosted review creation flow
Introduce unit tests for runMobileHostedReviewCreateIntent to verify
different scenarios of creating a hosted review on mobile, including:
- Successful flow including staging, committing, pushing, and creating
- Eligibility block handling (e.g., authentication requirements)
- Error reporting when creation fails after an automatic commit
* Block mobile PR creation on unresolved conflicts and refresh status
Prevent creating a hosted review on mobile when there are unresolved
merge conflicts. Also, return the latest git status on failures and
reload it in the UI to keep the source control screen in sync.
* Prefer fetched PR head SHA over cached status SHA for PR checks
On mobile, a create command can commit before opening the review,
meaning the fetched PR's head SHA is fresher than the route's cached
status SHA. Prioritizing the fetched PR head SHA ensures we fetch checks
for the most up-to-date commit.
* Fix mobile PR creation errors and validate branch presence
- Reject branch matches when the status branch is null or missing to
prevent PR creation when the branch is lost.
- Display actual PR creation errors in the sidebar instead of silently
ignoring them on failure.
- Trim leading and trailing whitespace from the base branch reference
before persisting the worktree link.
* Improve mobile emulator pairing startup
* Enhance mobile emulator script with --port option and robust IP lookup
Introduce support for configuring the Metro bundler port via --port, and
allow overriding the CLI command name using the ORCA_CLI environment variable.
Additionally, improve LAN IP detection and verification so that Metro URLs
are correctly resolved and tested for reachability. Finally, fix the
worktree argument passed during the emulator attach step.
* Improve mobile emulator script shutdown
- Remove Windows from the release evidence platform matrix check because Windows release evidence is temporarily paused due to CI runner PTY readiness.
- Add scenarioTitle as taskTitle and a display name to mock agent objects to satisfy updated runtime row shapes in mobile lag scripts.
When the hosting provider (GitHub) reports conflicts but a local merge
simulation is clean, we now mark the conflict summary as locally clean.
This state is surfaced in both the desktop and mobile sidebars with a
clear explanation and a copyable set of commands to trigger a remote
mergeability recalculation via an empty commit and push.
* Add worktree-list sidebar for mobile tablet/foldable layouts
On wide canvases (tablet/foldable, >=700pt) the per-host worktree list now
renders as a persistent left sidebar with the routed screens (terminal,
source control, review, accounts, tasks) shown in a detail pane to its
right — mirroring the desktop's sidebar + center layout. Phones keep the
existing single-pane stack navigation unchanged.
- Reuse the existing worktree-list screen as the sidebar via an `embedded`
mode (props for hostId/action, mount-driven fetch since a sidebar is never
the focused route, hide-sidebar control in place of the back button, and
open-into-detail-pane navigation that replaces rather than stacks).
- The default host route renders an empty WorkspaceDetailPlaceholder on wide
layouts (the list lives in the sidebar) and the full screen on phones, so
exactly one instance mounts either way.
- Hide button collapses the sidebar to give the detail pane full width; an
elevated reveal tab brings it back.
- Detail-pane screen transitions use `animation: 'none'` while the split is
active so workspaces swap instantly instead of sliding and flashing the
screen beneath.
- Drag the sidebar's right edge to resize (clamped 280-560pt, detail pane
kept >=320pt, tap-transparent so list rows still work); width persists via
AsyncStorage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Address PR review feedback (#5505)
- Clamp the restored/persisted sidebar width against the current window and
re-clamp when the window shrinks, so a width saved on a larger device can't
starve the detail pane below MIN_DETAIL_WIDTH. Extract a shared
clampSidebarToWindow helper reused by load, window-resize, and drag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Polish mobile tablet sidebar
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>