refactor(mobile): generate the terminal WebView document from typed modules (OTA phase C, C7.1) (#21804)

* 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
This commit is contained in:
Jinwoo Hong
2026-09-20 12:33:03 -04:00
committed by GitHub
parent d86d5cbbee
commit 9fbdfc592c
94 changed files with 12232 additions and 3196 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules/
src/terminal/terminal-webview-engine.generated.ts
src/terminal/terminal-webview-document-script.generated.ts
src/components/pr-sidebar/mermaid-webview-engine.generated.ts
.expo/
dist/
+1 -1
View File
@@ -7,7 +7,7 @@
"start": "node scripts/start-expo.mjs",
"android": "expo run:android",
"ios": "expo run:ios",
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs",
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs && node scripts/build-terminal-document-script.mjs",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"typecheck:tests": "tsc --noEmit -p tsconfig.test.json",
@@ -0,0 +1,78 @@
import { writeFile } from 'node:fs/promises'
import path from 'node:path'
import { importTypeScriptModule } from './import-typescript-module.mjs'
/**
* Writes the committed copy of the terminal WebView document that
* `terminal-document-identity.test.ts` diffs against.
*
* The document is a build artifact: fourteen source slices joined in a pinned order, with the
* generated xterm engine spliced into two of them. `terminal-webview-payload-hash.test.ts` already
* says *whether* it moved; what it cannot say is *where*, and a refactor whose whole claim is that
* the document did not move needs the diff, not the digest.
*
* The two generated engine strings are stored as placeholders rather than inline. They are already
* pinned by the hash test, they are regenerated by postinstall from whatever xterm version the
* lockfile holds, and inlining them would put 612 KiB of vendored bytes in the fixture and turn
* every xterm bump into an unreadable diff of the file that is supposed to isolate hand-written
* changes.
*
* Regenerating this fixture is a review event: it is only correct when the emitted document was
* meant to change, and the diff is the evidence for that. Run `node scripts/build-terminal-document-fixture.mjs`
* from `mobile/`.
*/
const mobileRoot = path.resolve(import.meta.dirname, '..')
const entry = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-html.ts')
const enginePath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts')
export const TERMINAL_DOCUMENT_FIXTURE_PATH = path.join(
mobileRoot,
'src',
'terminal',
'terminal-document-golden.txt'
)
/** Chosen so the document cannot contain one by accident; asserted below and in the test. */
export const ENGINE_JS_PLACEHOLDER = '__ORCA_TERMINAL_ENGINE_JS__'
export const ENGINE_CSS_PLACEHOLDER = '__ORCA_TERMINAL_ENGINE_CSS__'
/**
* The document with both generated sections replaced by their placeholders.
*
* Exported so the test builds the same text the script writes, rather than restating the
* substitution and agreeing with a fixture that was written wrong.
*/
export function terminalDocumentFixture(document, engineJs, engineCss) {
for (const placeholder of [ENGINE_JS_PLACEHOLDER, ENGINE_CSS_PLACEHOLDER]) {
if (document.includes(placeholder)) {
throw new Error(`the document already contains ${placeholder}`)
}
}
for (const [name, value] of [
['XTERM_ENGINE_JS', engineJs],
['XTERM_ENGINE_CSS', engineCss]
]) {
if (document.split(value).length !== 2) {
throw new Error(`${name} does not appear exactly once in the document`)
}
}
return document
.replace(engineJs, ENGINE_JS_PLACEHOLDER)
.replace(engineCss, ENGINE_CSS_PLACEHOLDER)
}
async function main() {
const [{ XTERM_HTML }, { XTERM_ENGINE_JS, XTERM_ENGINE_CSS }] = await Promise.all([
importTypeScriptModule(entry),
importTypeScriptModule(enginePath)
])
const fixture = terminalDocumentFixture(XTERM_HTML, XTERM_ENGINE_JS, XTERM_ENGINE_CSS)
await writeFile(TERMINAL_DOCUMENT_FIXTURE_PATH, fixture)
console.log(
`[build-terminal-document-fixture] ${Buffer.byteLength(fixture, 'utf8')} bytes (document ${Buffer.byteLength(XTERM_HTML, 'utf8')})`
)
}
if (import.meta.filename === process.argv[1]) {
await main()
}
@@ -0,0 +1,189 @@
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import * as esbuild from 'esbuild'
import { importTypeScriptModule } from './import-typescript-module.mjs'
import {
TERMINAL_DOCUMENT_MODULE_ORDER,
TERMINAL_DOCUMENT_SCOPE_MODULE
} from './terminal-document-module-order.mjs'
/**
* Turns one module of the in-WebView terminal document back into the script text the document
* carries.
*
* The document is a string the native WebView loads, so its parts cannot be imported by anything;
* the web page needs exactly those parts and must not re-implement them. So the parts are modules,
* and this is the other direction: the modules' declarations, with their imports removed and their
* exports unmarked, spliced into the one function scope the document has always been.
*
* Imports are dropped rather than resolved because inside the document every name is already in
* scope — that is what the single IIFE means. `document-externals.ts` declares the names that have
* not moved into modules yet, and it emits nothing at all.
*
* `esbuild` does the TypeScript, as it already does for the xterm engine beside this file. It is a
* transform and not a bundle: a bundler would order the output by its dependency graph, and the
* document's order is part of what the equivalence test holds fixed.
*/
const INDENT = ' '
const constantsPath = path.join(
import.meta.dirname,
'..',
'src',
'terminal',
'document',
'document-constants.ts'
)
let substitutions = null
/**
* `document-constants.ts` as the literal text each name stands for.
*
* Substitution happens after the import lines are dropped, when the names are free again, and it is
* textual rather than an esbuild `define` because a `define` whose value is an object or an array
* is injected as a helper binding instead of being inlined, which is not what the document carries.
* The names are exported for this purpose only and none of them appears inside a string.
*/
async function documentConstantSubstitutions() {
if (substitutions === null) {
const module = await importTypeScriptModule(constantsPath)
substitutions = Object.fromEntries(
Object.entries(module).map(([name, value]) => [name, JSON.stringify(value)])
)
}
return substitutions
}
/**
* Replaces each constant's name with its literal.
*
* The replacement is a function, not the literal itself: as a string, `$&`, `` $` ``, `$'` and
* `$n` are replacement patterns, so a constant whose value contains one would be spliced with the
* match rather than written out. A function replacer has no such reading.
*/
export function substituteDocumentConstants(text, substitutions) {
let substituted = text
for (const [name, literal] of Object.entries(substitutions)) {
substituted = substituted.replaceAll(new RegExp(`\\b${name}\\b`, 'g'), () => literal)
}
return substituted
}
/**
* Whether a line is a lint directive.
*
* These are removed before the transform, not after it: a directive inside an expression makes
* esbuild wrap that expression in parentheses to keep the comment where it was, and those
* parentheses are tokens the document does not have. They are tooling metadata about the source,
* not part of the program the WebView runs.
*/
function isLintDirectiveLine(line) {
return /^\s*\/\/\s*oxlint-disable/.test(line)
}
/** Whether a line opens an import the document does not need. */
function isImportLine(line) {
return /^import[\s{'"]/.test(line)
}
/** Whether a statement that started on this line also ended on it. */
function closesOnSameLine(line, closer) {
return line.includes(closer)
}
/**
* The emitted text of one module: transpiled, unexported, un-imported and indented into the IIFE.
*
* Multi-line imports are handled by dropping through to the line that closes them, which esbuild's
* output makes safe: it prints one import per line.
*/
export async function emitTerminalDocumentModule(modulePath) {
const source = await readFile(modulePath, 'utf8')
const program = source
.split('\n')
.filter((line) => !isLintDirectiveLine(line))
.join('\n')
const { code } = await esbuild.transform(program, {
loader: 'ts',
format: 'esm',
target: 'chrome74',
// The document is read by people as well as by a WebView, and the equivalence test compares
// tokens, so keeping the printer's own layout costs nothing and keeps the diff legible.
minify: false
})
const kept = []
// esbuild wraps a long import or export list across lines, so both are skipped to their closer
// rather than by their first line. An export list dropped by its keyword alone would leave a
// bare block statement in the document, and an import list would leave its names loose.
let skipUntil = null
for (const line of code.split('\n')) {
if (skipUntil !== null) {
if (closesOnSameLine(line, skipUntil)) {
skipUntil = null
}
continue
}
if (isImportLine(line)) {
skipUntil = closesOnSameLine(line, ' from ') || closesOnSameLine(line, ';') ? null : ' from '
continue
}
if (line.startsWith('export {')) {
skipUntil = closesOnSameLine(line, '}') ? null : '}'
continue
}
kept.push(line.startsWith('export ') ? line.slice('export '.length) : line)
}
const text = substituteDocumentConstants(kept.join('\n'), await documentConstantSubstitutions())
const substituted = await esbuild.transform(text, {
loader: 'js',
format: 'esm',
target: 'chrome74',
minify: false
})
const body = substituted.code.trim()
return body
.split('\n')
.map((line) => (line.length === 0 ? line : `${INDENT}${line}`))
.join('\n')
}
const documentDirectory = path.join(import.meta.dirname, '..', 'src', 'terminal', 'document')
export const TERMINAL_DOCUMENT_SCRIPT_PATH = path.join(
import.meta.dirname,
'..',
'src',
'terminal',
'terminal-webview-document-script.generated.ts'
)
/**
* The document's whole script: every module in the order the document had, inside the one function
* scope it has always been.
*/
export async function buildTerminalDocumentScript() {
const emitted = []
// The scope object goes first: every module below reads it, and the document is one function
// scope, so it has to exist before any of them run. It is the only part of the emitted script
// the hand-written document did not have.
for (const name of [TERMINAL_DOCUMENT_SCOPE_MODULE, ...TERMINAL_DOCUMENT_MODULE_ORDER]) {
emitted.push(await emitTerminalDocumentModule(path.join(documentDirectory, `${name}.ts`)))
}
return `(function() {\n${emitted.join('\n')}\n})();`
}
async function main() {
const script = await buildTerminalDocumentScript()
await writeFile(
TERMINAL_DOCUMENT_SCRIPT_PATH,
`// Generated by scripts/build-terminal-document-script.mjs. Do not edit.\n` +
`// The source is mobile/src/terminal/document/, in the order\n` +
`// scripts/terminal-document-module-order.mjs pins.\n` +
`export const TERMINAL_DOCUMENT_SCRIPT = ${JSON.stringify(script)}\n`
)
}
if (process.argv[1] === import.meta.filename) {
await main()
}
@@ -0,0 +1,105 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
emitTerminalDocumentModule,
substituteDocumentConstants
} from './build-terminal-document-script.mjs'
import { terminalBackgroundFallback } from '../src/terminal/document/document-constants'
/**
* What the generator drops, what it keeps, and how it puts a module back into the document.
*
* The per-group tests compare a real module against the string the document carries, which says
* the two agree; these say why, on inputs small enough to read. The import and export cases are
* the ones that bit: esbuild wraps a long list across lines, and skipping only the first line
* leaves the rest of the names loose in the document.
*/
let directory: string
async function emit(source: string): Promise<string> {
const path = join(directory, `module-${Math.random().toString(36).slice(2)}.ts`)
await writeFile(path, source)
return emitTerminalDocumentModule(path)
}
beforeAll(async () => {
directory = await mkdtemp(join(tmpdir(), 'orca-terminal-document-'))
})
afterAll(async () => {
await rm(directory, { recursive: true, force: true })
})
describe('emitting one terminal document module', () => {
it('unmarks an export and indents it into the document scope', async () => {
expect(await emit('export function f() {\n return 1\n}\n')).toBe(
' function f() {\n return 1;\n }'
)
})
it('drops an import that fits on one line', async () => {
expect(await emit("import { a } from './x'\nexport const b = 1\n")).toBe(' const b = 1;')
})
it('drops an import esbuild wrapped across lines', async () => {
// The case that produced an unparseable document: the names after the first line stayed.
const source =
"import { alpha, beta, gamma, delta, epsilon, zeta, eta, theta } from './document-externals'\n" +
'export const b = alpha\n'
expect(await emit(source)).toBe(' const b = alpha;')
})
it('drops the trailing export block esbuild prints, not just its keyword', async () => {
// Left behind it is a bare block statement, which parses and does nothing.
const emitted = await emit('function f() {}\nfunction g() {}\nexport { f, g }\n')
expect(emitted).not.toContain('{ f, g }')
expect(emitted).toBe(' function f() {\n }\n function g() {\n }')
})
it('erases types without touching the program', async () => {
expect(
await emit(
'export type T = { a: number }\nexport function f(v: T): number {\n return v.a\n}\n'
)
).toBe(' function f(v) {\n return v.a;\n }')
})
it('substitutes a build-time constant the document carries as a literal', async () => {
const emitted = await emit(
"import { terminalBackgroundFallback } from '../src/terminal/document/document-constants'\n" +
'export function paint() {\n' +
' return terminalBackgroundFallback\n' +
'}\n'
)
expect(emitted).toContain(JSON.stringify(terminalBackgroundFallback))
expect(emitted).not.toContain('terminalBackgroundFallback')
})
it('drops a lint directive rather than let it parenthesise the expression it guards', async () => {
expect(
await emit(
'export const R =\n' + ' // oxlint-disable-next-line no-useless-escape\n' + ' /a/g\n'
)
).toBe(' const R = /a/g;')
})
})
describe('substituting a build-time constant', () => {
it('writes a value containing a replacement pattern out as it stands', () => {
// `$&` is the matched text to `String.replaceAll`'s string form, which would splice the
// constant's own name in here and ship a document that says something else.
const literal = JSON.stringify('a $& b')
expect(substituteDocumentConstants('const v = marker;', { marker: literal })).toBe(
'const v = "a $& b";'
)
})
// `$n` is not listed: the pattern has no capture group, so it is already literal under either
// form and a case for it could not tell them apart.
it.each([['$&'], ["$'"], ['$`']])('is not read as the replacement pattern %s', (pattern) => {
const literal = JSON.stringify(`x${pattern}y`)
expect(substituteDocumentConstants('marker', { marker: literal })).toBe(literal)
})
})
@@ -0,0 +1,21 @@
import * as esbuild from 'esbuild'
/**
* Imports a TypeScript module from a build script, by bundling it to a data URL.
*
* Node cannot import TypeScript and these scripts run outside the app's bundler, so the values the
* document is built from — the theme, the URL limits, the caret options — would otherwise have to be
* restated here. Restating them is what the generator exists to avoid.
*/
export async function importTypeScriptModule(entryPoint) {
const result = await esbuild.build({
entryPoints: [entryPoint],
bundle: true,
format: 'esm',
platform: 'node',
write: false,
logLevel: 'silent'
})
const code = result.outputFiles[0].text
return import(`data:text/javascript;base64,${Buffer.from(code, 'utf8').toString('base64')}`)
}
@@ -0,0 +1,48 @@
/**
* The order the document's modules are spliced back into the script, which is the order the
* hand-written document had. It is data, not a dependency graph: the document is one function
* scope, so declarations must land where they landed before.
*
* Both the generator and the equivalence test read this, so neither can drift from the other.
*/
/** The scope object, emitted ahead of everything else because everything else reads it. */
export const TERMINAL_DOCUMENT_SCOPE_MODULE = 'document-scope'
export const TERMINAL_DOCUMENT_MODULE_ORDER = [
'runtime-constants',
'terminal-handle',
'query-reply',
'surface-swap',
'text-scaling',
'viewport-transform',
'terminal-theme',
'fit-scale',
'mouse-mode-decset-scan',
'write-queue',
'webgl-recovery',
'terminal-init',
'reflow',
'host-notify',
'host-message-router',
'selection-state-and-eviction',
'mode-mirroring',
'keyboard-avoidance-metrics',
'term-observers',
'viewport-cell',
'mouse-report-cell',
'mouse-input-encoding',
'normal-buffer-smooth-scroll',
'cell-geometry',
'path-tap',
'url-tap',
'osc-link-tap',
'surface-tap',
'selection-range',
'selection-overlay',
'tap-dispatch',
'wheel-scroll',
'mouse-click-drag',
'selection-menu-buttons',
'surface-touch-gestures',
'message-bridge'
]
@@ -0,0 +1,46 @@
import { getCellHeight } from './fit-scale'
import { getCellWidth, getTotalScale } from './viewport-transform'
import { scope } from './document-scope'
export function cellToViewportPx(col: number, absRow: number) {
if (!scope.term) {
return { x: 0, y: 0 }
}
const cellW = getCellWidth()
const cellH = getCellHeight()
const viewportRow = absRow - scope.term.buffer.active.viewportY
const sx = col * cellW
const sy = viewportRow * cellH
const total = getTotalScale()
return { x: sx * total + scope.panX, y: sy * total + scope.panY }
}
export function getLineText(absRow: number) {
if (!scope.term) {
return ''
}
const line = scope.term.buffer.active.getLine(absRow)
if (!line) {
return ''
}
return line.translateToString(false)
}
// Why: getLineText collapses wide chars (emoji, CJK) to one string char, so a
// tap's CELL column no longer equals the STRING index that url/path matchers use.
// Convert by measuring the string length up to the tapped cell (the count of
// string chars before it). Without this, taps on lines with a leading wide char
// (e.g. agent output prefixed with ⏺) resolve to the wrong column and miss.
export function cellColToStringIndex(absRow: number, col: number) {
if (!scope.term) {
return col
}
const line = scope.term.buffer.active.getLine(absRow)
if (!line) {
return col
}
return line.translateToString(false, 0, col).length
}
// File-path-under-tap detection (matchFilePathAtColumn). See path-tap.ts;
// mirrors the unit-tested terminal-path-tap.ts.
@@ -0,0 +1,46 @@
import { colors } from '../../theme/mobile-theme'
import { TERMINAL_TEXT_SCALES } from '../../storage/preferences'
import {
DEFAULT_TERMINAL_THEME,
MOBILE_TERMINAL_CARET_OPTIONS
} from '../terminal-webview-html/theme'
import {
TERMINAL_FILE_URL_REGEX_SOURCE,
TERMINAL_HTTP_URL_MAX_LENGTH,
TERMINAL_HTTP_URL_REGEX_SOURCE
} from '../terminal-webview-url-tap'
/**
* The build-time values the document's script text carries as literals.
*
* The document is a string, so it cannot import: today each of these is interpolated into a
* template literal at the site that needs it. A module cannot do that and still be the same
* program, so the generator substitutes these exports into the text it emits, and the web page
* imports the very same bindings. One source either way.
*
* Every export must be JSON-serialisable, because a substitution is a JSON literal.
*/
/** The page background before a theme arrives, and the fallback when a theme omits one. */
export const terminalBackgroundFallback = colors.terminalBg
/** The http(s) candidate pattern, as a string because the document builds the RegExp per call. */
export const terminalHttpUrlRegexSource = TERMINAL_HTTP_URL_REGEX_SOURCE
/** The file:// candidate pattern, same shape. */
export const terminalFileUrlRegexSource = TERMINAL_FILE_URL_REGEX_SOURCE
/** The longest candidate a tap will open, matching desktop. */
export const terminalHttpUrlMaxLength = TERMINAL_HTTP_URL_MAX_LENGTH
/** The caret options, one export each because a substitution is keyed by name. */
export const terminalCursorBlink = MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink
export const terminalCursorStyle = MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle
export const terminalShowCursorImmediately = MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately
export const terminalCursorInactiveStyle = MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle
/** The text-scale presets, as the document's own array literal. */
export const terminalTextScalePresets = [...TERMINAL_TEXT_SCALES]
/** The built-in theme, as the document's own object literal. */
export const terminalDefaultTheme = DEFAULT_TERMINAL_THEME
@@ -0,0 +1,43 @@
import { readdirSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import {
TERMINAL_DOCUMENT_MODULE_ORDER,
TERMINAL_DOCUMENT_SCOPE_MODULE
} from '../../../scripts/terminal-document-module-order.mjs'
/**
* Every module in this directory is in the document, and everything in the order list is here.
*
* The generator emits exactly what the order list names, so a module added here and forgotten
* there is dead code that reads as live, and a name left in the list after its file goes makes the
* generator throw at build time rather than at review time. Both directions are asserted.
*
* `document-constants` is the one file that is deliberately not emitted: its exports are
* substituted into the modules that import them as literals, so the document carries its values
* without carrying the module.
*/
const NOT_EMITTED = 'document-constants'
function documentModuleNames(): string[] {
return readdirSync(new URL('.', import.meta.url))
.filter((entry) => entry.endsWith('.ts'))
.filter((entry) => !entry.endsWith('.test.ts') && !entry.endsWith('.test-support.ts'))
.map((entry) => entry.slice(0, -'.ts'.length))
.sort()
}
describe('the document module order', () => {
it('names every module the directory holds, and only those', () => {
const expected = [
NOT_EMITTED,
TERMINAL_DOCUMENT_SCOPE_MODULE,
...TERMINAL_DOCUMENT_MODULE_ORDER
].sort()
expect(documentModuleNames()).toEqual(expected)
})
it('names each module once, so the generator cannot emit one twice', () => {
const listed = [TERMINAL_DOCUMENT_SCOPE_MODULE, ...TERMINAL_DOCUMENT_MODULE_ORDER]
expect(listed).toHaveLength(new Set(listed).size)
})
})
@@ -0,0 +1,406 @@
import { terminalDefaultTheme, terminalTextScalePresets } from './document-constants'
import type { TerminalDocumentThemeMessage } from './terminal-theme'
/**
* The state the in-WebView terminal document shares across its parts.
*
* The document is one function scope: 2,758 lines around 100 `var` declarations, 57 of which are
* written from more than one place. Moving its parts into modules is what lets the web page import
* them instead of re-implementing them, and a variable assigned from another module cannot be an
* import — assigning an imported binding is a syntax error. So the written ones become fields here,
* and the group that owns each is named beside it.
*
* Two things keep a variable out of this table. One the script never assigns again is an ordinary
* local. One both declared and assigned inside a single group is that module's own state, however
* often it is written — `terminalDataRepliesEnabled` is written from four places and all four are
* in `query-reply`, so it stays a `let` there.
*
* Declared, not merely written: while the rest of the document is still strings, a variable the
* main slice declares is shared even when every use of it is in one group, because the declaration
* has nowhere else to live yet. `webglRecoveryTimer` is that case. Those can migrate out of this
* table when the flip makes the main slice modules too, and doing it before then would emit a
* second declaration beside the one the slice still carries.
*
* The table grows one group at a time as C7.1 extracts them; a field arrives with its group.
*/
/** One cell of a buffer line, as the document inspects it. */
/** xterm's OSC 8 link service, reached through internals and always guarded. */
export type TerminalOscLinkService = { getLinkData?: (id: number) => { uri?: string } | undefined }
/** The xterm internals the OSC 8 lookup walks. */
export type TerminalDocumentCore = {
_renderService?: { dimensions?: { css: { cell: { height: number; width: number } } } }
_oscLinkService?: TerminalOscLinkService
_inputHandler?: { _oscLinkService?: TerminalOscLinkService }
}
/** An OSC 8 link the host captured from scrollback before xterm replayed it. */
export type TerminalInitialOscLink = {
uri?: string
row: number
startCol: number
endCol: number
text?: string
}
export type TerminalDocumentCell = {
isBgDefault: () => boolean
extended?: { urlId?: number }
isInverse: () => boolean
isUnderline?: () => boolean
isStrikethrough?: () => boolean
isOverline?: () => boolean
}
/** One buffer line, as the document inspects it. */
export type TerminalDocumentLine = {
readonly length: number
translateToString: (trimRight: boolean, startColumn?: number, endColumn?: number) => string
getCell?: (x: number, cell?: TerminalDocumentCell | null) => TerminalDocumentCell | null
}
/** One side of xterm's buffer, as the document reads it. */
export type TerminalDocumentBuffer = {
readonly length: number
readonly viewportY: number
readonly baseY: number
readonly cursorY: number
readonly type: string
getNullCell?: () => TerminalDocumentCell
getLine: (index: number) => TerminalDocumentLine | undefined
}
/** As much of xterm's terminal as the document's own code touches. */
/** A terminal colour theme: xterm reads it as a flat map of slot to CSS colour. */
export type TerminalDocumentTheme = Record<string, string>
/** The xterm options the document writes; each field is owned by the group that sets it. */
export type TerminalDocumentTerminalOptions = {
theme: TerminalDocumentTheme
minimumContrastRatio: number
fontSize: number
}
export type TerminalDocumentTerminal = {
readonly cols: number
readonly rows: number
readonly buffer: { readonly active: TerminalDocumentBuffer }
options: TerminalDocumentTerminalOptions
write: (data: string, callback?: () => void) => void
open: (element: HTMLElement) => void
scrollToLine: (line: number) => void
clear: () => void
reset: () => void
selectAll: () => void
getSelection?: () => string
select: (col: number, row: number, length: number) => void
clearSelection: () => void
readonly unicode: { activeVersion: string }
attachCustomKeyEventHandler: (handler: () => boolean) => void
onData: (listener: (data: string) => void) => TerminalDocumentDisposable
readonly textarea?: {
readOnly: boolean
tabIndex: number
setAttribute: (name: string, value: string) => void
}
readonly element?: HTMLElement
readonly _core?: TerminalDocumentCore
readonly modes?: {
bracketedPasteMode?: boolean
mouseTrackingMode?: string
applicationCursorKeysMode?: boolean
}
onLineFeed?: (listener: () => void) => TerminalDocumentDisposable
onScroll?: (listener: () => void) => TerminalDocumentDisposable
onWriteParsed?: (listener: () => void) => TerminalDocumentDisposable
resize: (cols: number, rows: number) => void
refresh: (start: number, end: number) => void
dispose: () => void
loadAddon: (addon: TerminalDocumentWebglAddon) => void
scrollToBottom: () => void
scrollLines: (amount: number) => void
}
export type TerminalDocumentScope = {
/** `terminal-handle`: the live xterm terminal, or null before the first init. */
term: TerminalDocumentTerminal | null
/** `viewport-transform`: the surface's pan offset, in viewport pixels. */
panX: number
panY: number
/** `terminal-init`: bumped on every re-init, so a late callback can tell it is stale. */
terminalGeneration: number
/** `term-observers`: xterm listener handles to dispose when the terminal is replaced. */
termObserverDisposables: TerminalDocumentDisposable[]
/** `terminal-init`: the row count the last init or reflow settled on. */
initRows: number
/** `webgl-recovery`: the loaded WebGL addon, or null on the DOM renderer. */
webglAddon: TerminalDocumentWebglAddon | null
/** `webgl-recovery`: the pending single retry after a context loss. */
webglRecoveryTimer: ReturnType<typeof setTimeout> | null
/** `terminal-theme`: the theme the host last sent, replayed on visibility. */
terminalThemeInput: TerminalDocumentThemeMessage
/** `wheel-scroll`: sub-line wheel travel carried between events; reset by a touch scroll. */
wheelAccumDeltaY: number
/** `terminal-theme`: the built-in theme, and the fallback for every slot a host theme omits. */
defaultTheme: TerminalDocumentTheme
/** `terminal-theme`: the host theme normalised against the built-in one. */
terminalTheme: TerminalDocumentTheme
/** `terminal-theme`: the contrast floor in force, published or derived from the background. */
terminalMinimumContrastRatio: number
/** `selection-overlay`: OSC 8 links captured from scrollback before xterm replayed it. */
initialOscLinks: TerminalInitialOscLink[]
/** `selection-overlay`: how far the captured rows have scrolled out of the buffer. */
initialOscLinkRowOffset: number
/** `runtime-constants`: the escape byte every report is prefixed with. */
ESC: string
/** `mode-mirroring`: the last mode set published to the host, to suppress repeats. */
lastEmittedModes: TerminalDocumentModes
/** `terminal-init`: whether the terminal has ever reached ready. */
everReady: boolean
/** `runtime-constants`: the C1 form of the control sequence introducer. */
C1_CSI: string
/** `mouse-mode-decset-scan`: the tail of the last chunk, in case a DECSET straddles two writes. */
mouseModeScanTail: string
/** `mouse-mode-decset-scan`: the mouse tracking mode the TUI last asked for. */
trackedMouseTrackingMode: string
/** `mouse-mode-decset-scan`: whether the TUI asked for SGR (1006) mouse reports. */
sgrMouseMode: boolean
/** `mouse-mode-decset-scan`: whether the TUI asked for SGR pixel (1016) mouse reports. */
sgrMousePixelsMode: boolean
/** `text-scaling`: the scroll indicator's hide timer. */
scrollIndicatorHideTimer: ReturnType<typeof setTimeout> | null
/** `text-scaling`: the narrowest grid a text-scale change will fit to. */
MIN_FIT_COLS: number
/** `text-scaling`: the smallest text-scale preset. */
MIN_TEXT_SCALE: number
/** `text-scaling`: the largest text-scale preset. */
MAX_TEXT_SCALE: number
/** `viewport-transform`: host message ids already handled, to drop repeats. */
handledMessageIds: number[]
/** `text-scaling`: the text scale the user picked, as a preset index. */
currentTextScale: number
/** `text-scaling`: the font stack xterm renders with. */
terminalFontFamily: string
/** `terminal-init`: whether the first live chunk since init is still pending. */
firstDataPending: boolean
/** `terminal-init`: whether the replayed snapshot was an alternate screen. */
activeAltScreenSnapshot: boolean
/** `fit-scale`: the fit scale the document committed. */
currentScale: number
/** `text-scaling`: the pinch zoom the user applied on top of the fit scale. */
userScale: number
/** `runtime-constants`: Claude's record dot, which iOS WebKit would otherwise promote to emoji. */
CLAUDE_STATUS_DOT: string
/** `runtime-constants`: the variation selector that forces the text glyph. */
TEXT_PRESENTATION_SELECTOR: string
/** `runtime-constants`: the variation selector that forces the emoji glyph. */
EMOJI_PRESENTATION_SELECTOR: string
/** `runtime-constants`: the dot with any trailing selectors, as one pattern. */
CLAUDE_STATUS_DOT_PATTERN: RegExp
/** `write-queue`: whether a chunk ended mid-selector, so the next one starts inside it. */
statusDotPendingSelector: boolean
/** `write-queue`: how far a split DECSET may be carried before the scan gives up. */
PRIVATE_MODE_SCAN_TAIL_LIMIT: number
/** `write-queue`: chunks and boundaries waiting for xterm. */
writeQueue: TerminalWriteQueueEntry[]
/** `write-queue`: how far the queue has been consumed, before compaction. */
writeQueueHead: number
/** `write-queue`: whether a write is parsing right now. */
writesDraining: boolean
/** `write-queue`: callbacks waiting for the queue to empty. */
afterDrainCallbacks: (() => void)[]
/** `terminal-init`: whether the terminal has been initialised. */
ready: boolean
/** `normal-buffer-smooth-scroll`: sub-row scroll travel not yet committed to xterm. */
smoothScrollOffsetY: number
/** `normal-buffer-smooth-scroll`: scroll travel waiting for the next frame. */
pendingNormalScrollDeltaY: number
/** `normal-buffer-smooth-scroll`: the frame request that will apply it, if one is pending. */
normalScrollFrameId: number | null
/** `selection-state-and-eviction`: what counts as one word for select-all and word seeding. */
WORD_RE: RegExp
/** `selection-state-and-eviction`: how close to an edge a handle drag starts scrolling. */
EDGE_SCROLL_PX: number
/** `selection-state-and-eviction`: the edge-scroll tick, in milliseconds. */
EDGE_SCROLL_INTERVAL: number
/** `selection-state-and-eviction`: the menu pill element. */
selMenu: HTMLElement | null
/** `selection-state-and-eviction`: the pill's copy button. */
btnCopy: HTMLElement | null
/** `selection-state-and-eviction`: the pill's select-all button. */
btnSelAll: HTMLElement | null
/** `selection-state-and-eviction`: the running edge-scroll timer. */
edgeScrollTimer: ReturnType<typeof setInterval> | null
/** `selection-state-and-eviction`: which way the edge scroll is going. */
edgeScrollDir: number
/** `selection-state-and-eviction`: where the dragging finger last was. */
edgeScrollClientX: number
/** `selection-state-and-eviction`: where the dragging finger last was. */
edgeScrollClientY: number
/** `selection-state-and-eviction`: whether captured OSC 8 rows may start shifting with eviction. */
initialOscLinkEvictionReady: boolean
/** `selection-overlay`: the press duration that starts a selection, in milliseconds. */
LONG_PRESS_MS: number
/** `selection-overlay`: the travel that cancels a pending long press, in pixels. */
LONG_PRESS_SLOP: number
/** `selection-overlay`: the travel that disqualifies a tap, in pixels. */
TAP_SLOP: number
/** `selection-overlay`: the longest press still counted as a tap, in milliseconds. */
TAP_MAX_MS: number
/** `selection-overlay`: the overlay element that carries the handles and the menu pill. */
selectionOverlay: HTMLElement | null
/** `selection-overlay`: the selection's leading handle element. */
handleStart: HTMLElement | null
/** `selection-overlay`: the selection's trailing handle element. */
handleEnd: HTMLElement | null
/** `selection-overlay`: `navigate` or `select`. */
selMode: string
/** `selection-overlay`: the live selection, or null when there is none. */
sel: TerminalDocumentSelection | null
/** `selection-overlay`: the pending long-press timer. */
longPressTimer: ReturnType<typeof setTimeout> | null
/** `selection-overlay`: where the pending long press started. */
longPressOrigin: TerminalDocumentTouchOrigin | null
/** `selection-overlay`: the touch that may still resolve as a tap. */
tapCandidate: TerminalDocumentTapCandidate | null
/** `surface-swap`: the element xterm is currently mounted on. */
surface: HTMLElement | null
/** `surface-swap`: the terminal of a hidden replacement surface that has not committed. */
pendingTerm: TerminalDocumentTerminal | null
}
/** An xterm listener handle, as the document disposes of one. */
/** The live selection; only the dragged handle is read outside the overlay slice. */
export type TerminalDocumentSelection = {
anchor: { row: number; col: number }
focus: { row: number; col: number }
activeHandle: string | null
}
/** Where a press began, and which finger began it. */
export type TerminalDocumentTouchOrigin = { x: number; y: number; identifier: number }
/** A touch that may still resolve as a tap: its origin, its start time and its finger. */
export type TerminalDocumentTapCandidate = TerminalDocumentTouchOrigin & { t: number }
/** The terminal modes the host mirrors. */
export type TerminalDocumentModes = {
bracketedPasteMode: boolean
altScreen: boolean
mouseTrackingMode: string
sgrMouseMode: boolean
sgrMousePixelsMode: boolean
}
/** One entry of the write queue: a chunk, a boundary callback, or a consumed slot. */
export type TerminalWriteQueueEntry = string | (() => void) | undefined
export type TerminalDocumentDisposable = { dispose?: () => void }
/** xterm's WebGL addon, as the document loads, repaints and disposes of it. */
export type TerminalDocumentWebglAddon = {
onContextLoss?: (listener: () => void) => void
clearTextureAtlas?: () => void
dispose: () => void
}
/**
* The initial values, which are the ones the document's own declarations carried.
*
* A factory rather than a shared literal so a second document — a test, or a page that remounts —
* starts from its own state instead of inheriting what the last one left.
*/
const textScalePresets = terminalTextScalePresets
const statusDot = String.fromCharCode(0x23fa)
const textPresentationSelector = String.fromCharCode(0xfe0e)
const emojiPresentationSelector = String.fromCharCode(0xfe0f)
export function createTerminalDocumentScope(): TerminalDocumentScope {
return {
term: null,
panX: 0,
panY: 0,
terminalGeneration: 0,
termObserverDisposables: [],
initRows: 24,
webglAddon: null,
webglRecoveryTimer: null,
terminalThemeInput: null,
defaultTheme: terminalDefaultTheme,
terminalTheme: terminalDefaultTheme,
terminalMinimumContrastRatio: 3,
initialOscLinks: [],
initialOscLinkRowOffset: 0,
ESC: String.fromCharCode(27),
lastEmittedModes: {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
},
everReady: false,
C1_CSI: String.fromCharCode(155),
mouseModeScanTail: '',
trackedMouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false,
scrollIndicatorHideTimer: null,
MIN_FIT_COLS: 20,
MIN_TEXT_SCALE: textScalePresets[0],
MAX_TEXT_SCALE: textScalePresets[textScalePresets.length - 1],
handledMessageIds: [],
currentTextScale: 1,
terminalFontFamily: '',
firstDataPending: true,
activeAltScreenSnapshot: false,
currentScale: 1,
userScale: 1,
CLAUDE_STATUS_DOT: statusDot,
TEXT_PRESENTATION_SELECTOR: textPresentationSelector,
EMOJI_PRESENTATION_SELECTOR: emojiPresentationSelector,
CLAUDE_STATUS_DOT_PATTERN: new RegExp(
statusDot + '[' + textPresentationSelector + emojiPresentationSelector + ']*',
'g'
),
statusDotPendingSelector: false,
PRIVATE_MODE_SCAN_TAIL_LIMIT: 4096,
writeQueue: [],
writeQueueHead: 0,
writesDraining: false,
afterDrainCallbacks: [],
ready: false,
smoothScrollOffsetY: 0,
pendingNormalScrollDeltaY: 0,
normalScrollFrameId: null,
WORD_RE: /[\p{L}\p{N}_./:@~+=?&#%-]/u,
EDGE_SCROLL_PX: 40,
EDGE_SCROLL_INTERVAL: 60,
selMenu: null,
btnCopy: null,
btnSelAll: null,
edgeScrollTimer: null,
edgeScrollDir: 0,
edgeScrollClientX: 0,
edgeScrollClientY: 0,
initialOscLinkEvictionReady: false,
LONG_PRESS_MS: 500,
LONG_PRESS_SLOP: 10,
TAP_SLOP: 24,
TAP_MAX_MS: 700,
selectionOverlay: null,
handleStart: null,
handleEnd: null,
selMode: 'navigate',
sel: null,
longPressTimer: null,
longPressOrigin: null,
tapCandidate: null,
wheelAccumDeltaY: 0,
surface: null,
pendingTerm: null
}
}
/** The document's own scope. The generator emits this declaration at the top of the script. */
export const scope: TerminalDocumentScope = createTerminalDocumentScope()
+146
View File
@@ -0,0 +1,146 @@
import { repositionOverlay } from './selection-overlay'
import {
computeFitScale,
flog,
getCellWidth,
getTotalScale,
updateTransform
} from './viewport-transform'
import { scope } from './document-scope'
export function getCellHeight() {
if (!scope.term || !scope.term._core) {
return 15
}
const core = scope.term._core
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.height || 15
}
return 15
}
// Why: clamp pan so the terminal content always covers the viewport
// when zoomed in. When content is smaller than viewport in a
// dimension, pin to top-left (no floating in the middle).
export function clampPan() {
if (!scope.term || !scope.term.element) {
return
}
const ts = getTotalScale()
const cw = scope.term.element.scrollWidth * ts
const ch = scope.term.element.scrollHeight * ts
const vpW = window.innerWidth
const vpH = window.innerHeight
if (cw > vpW) {
scope.panX = Math.min(0, Math.max(vpW - cw, scope.panX))
} else {
scope.panX = 0
}
if (ch > vpH) {
scope.panY = Math.min(0, Math.max(vpH - ch, scope.panY))
} else {
scope.panY = 0
}
}
// Why: intentional no-op. Mobile replays a live PTY snapshot then applies
// live cursor-relative chunks from that same PTY; resizing only the WebView
// xterm changes cursor coordinates and makes TUI repaint chunks duplicate or
// overlap. Kept as a no-op so its call sites stay legible.
export function adjustRowsForViewport() {}
// Why: cold-start fit. After init() opens xterm, the renderer needs
// several frames before cell dimensions are computed. Reading too early
// gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM
// not laid out), and computeFitScale returns 1 → no zoom.
//
// Gate: cellWidth × cols is the canonical "logical width" of the grid
// and reflects xterm's layout decision, independent of buffer content.
// We commit when cellWidth becomes positive (renderer ready). Fallback:
// if cellWidth never becomes available, gate on stable positive
// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
// so a backgrounded WebView never spins forever.
const FIT_RETRY_MAX_FRAMES = 60
let fitRetryToken = 0
export function applyFitScale(reason: string) {
if (!scope.term || !scope.term.element) {
return
}
const token = ++fitRetryToken
let attempts = 0
let lastScrollWidth = -1
function attempt() {
if (token !== fitRetryToken) {
return
}
if (!scope.term || !scope.term.element) {
return
}
attempts++
const cellW = getCellWidth()
if (cellW > 0 && scope.term.cols > 0) {
commitFitScale(reason, attempts, 'cellW')
return
}
const w = scope.term.element.scrollWidth
if (w > 0 && w === lastScrollWidth) {
commitFitScale(reason, attempts, 'stableSW')
return
}
lastScrollWidth = w
if (attempts >= FIT_RETRY_MAX_FRAMES) {
flog('commit-timeout', {
reason: reason,
attempts: attempts,
cellW: cellW,
scrollWidth: w,
cols: scope.term.cols
})
commitFitScale(reason, attempts, 'timeout')
return
}
requestAnimationFrame(attempt)
}
requestAnimationFrame(attempt)
}
export function commitFitScale(reason: string, attempts: number, gate: string) {
if (!scope.term || !scope.term.element) {
return
}
const preSnapScale = computeFitScale()
scope.currentScale = preSnapScale
// Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar
// sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents
// a second applyFitScale from observing a "no-op needed" state.
if (scope.currentScale >= 0.95) {
scope.currentScale = 1
}
scope.userScale = 1
scope.panX = 0
scope.panY = 0
scope.smoothScrollOffsetY = 0
updateTransform()
adjustRowsForViewport()
const cellW = getCellWidth()
const sw = scope.term.element.scrollWidth
const vpW = window.innerWidth
const expectedW = cellW * scope.term.cols
const suspect = scope.currentScale === 1 && scope.term.cols > 0 && expectedW > vpW + 1 // expected wider than viewport but no zoom
if (suspect) {
flog('commit-SUSPECT', {
reason: reason,
attempts: attempts,
gate: gate,
preSnapScale: preSnapScale,
finalScale: scope.currentScale,
cellW: cellW,
cols: scope.term.cols,
expectedW: expectedW,
scrollWidth: sw,
vpWidth: vpW
})
}
repositionOverlay()
}
@@ -0,0 +1,51 @@
import { fileURLToPath } from 'node:url'
import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs'
import { XTERM_HTML } from '../terminal-webview-html'
const SCOPE_OPEN = '(function() {\n'
// The first statement the document runs once the scope object exists.
const FIRST_STATEMENT_AFTER_SCOPE = ' scope.surface = document.getElementById'
/**
* The scope object the document opens with. Every block below it reads and writes document state
* through this one object, so a test that evaluates a block has to build it first.
*/
export function documentScopePreamble(): string {
const start = XTERM_HTML.indexOf(SCOPE_OPEN)
const end = XTERM_HTML.indexOf(FIRST_STATEMENT_AFTER_SCOPE, start)
if (start === -1 || end <= start) {
throw new Error('the document does not open with the scope object')
}
return XTERM_HTML.slice(start + SCOPE_OPEN.length, end)
}
/**
* One module's text as the document carries it. The module is re-emitted and then located in the
* document, so a test that evaluates the result is running the WebView's own bytes, not a
* parallel copy of them.
*/
export async function generatedDocumentModule(name: string): Promise<string> {
const emitted = await emitTerminalDocumentModule(
fileURLToPath(new URL(`./${name}.ts`, import.meta.url))
)
if (!XTERM_HTML.includes(emitted)) {
throw new Error(`the document does not carry the ${name} module; rebuild the document script`)
}
return emitted
}
/**
* A function the document's own text declared, read out of the context it was evaluated in. The
* name is checked to be callable, so only its parameter and return types are the caller's claim.
*/
export function documentDeclaredFunction<T extends (...args: never[]) => unknown>(
context: Record<string, unknown>,
name: string
): T {
const value = context[name]
if (typeof value !== 'function') {
throw new Error(`the document text did not declare ${name}`)
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: checked callable above.
return value as T
}
@@ -0,0 +1,194 @@
import { scope } from './document-scope'
import { applyFitScale } from './fit-scale'
import { notify } from './host-notify'
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
import { emitModesIfChanged } from './mode-mirroring'
import { reflow } from './reflow'
import { resumeTerminalDataReplyAuthority } from './query-reply'
import { repositionOverlay } from './selection-overlay'
import { cancelSelect } from './selection-range'
import { resetEvictionCounter } from './selection-state-and-eviction'
import { applyTerminalTheme } from './terminal-theme'
import { init, resize, write } from './terminal-init'
import { applyTextScale } from './text-scaling'
import { flog } from './viewport-transform'
import { resetWriteQueue } from './write-queue'
/** One message from the host. Every field is optional because the router reads them by type. */
export type TerminalHostMessage = {
id?: number
type?: string
cols?: number
rows?: number
initialData?: unknown
terminalTheme?: Parameters<typeof applyTerminalTheme>[0]
fontScale?: number
preserveScroll?: boolean
oscLinks?: unknown
data?: string
containerHeight?: number
}
export function measureFitDimensions(containerHeightPx: unknown, retriesLeft?: number) {
if (typeof retriesLeft !== 'number') {
retriesLeft = 30
}
// Why: init and measure are posted back-to-back from React, but
// init has an async rAF chain. A measure that runs synchronously
// after init can find term null, disposed, lacking element, or
// with cells size 0. Retry the whole gate for ~500ms.
const notReady = !scope.term || !scope.term.element
let cellWidth = 0
let cellHeight = 0
if (!notReady) {
const core = scope.term!._core
if (core && core._renderService && core._renderService.dimensions) {
cellWidth = core._renderService.dimensions.css.cell.width
cellHeight = core._renderService.dimensions.css.cell.height
}
}
if (notReady || cellWidth <= 0 || cellHeight <= 0) {
if (retriesLeft > 0) {
requestAnimationFrame(function () {
measureFitDimensions(containerHeightPx, retriesLeft - 1)
})
return
}
flog('measure-fail', {
notReady: notReady,
cellWidth: cellWidth,
cellHeight: cellHeight,
retriesLeft: retriesLeft
})
notify({ type: 'measure-result', cols: null, rows: null })
return
}
const vpWidth = window.innerWidth
// Why: prefer the container height passed from React Native over
// window.innerHeight. The RN layout system knows the exact pixel
// height of the terminal frame after the accessory/input bars are
// subtracted, whereas innerHeight can overstate the visible area
// due to layout timing or safe-area insets.
const vpHeight =
typeof containerHeightPx === 'number' && containerHeightPx > 0
? containerHeightPx
: window.innerHeight
const cols = Math.floor(vpWidth / cellWidth)
if (cols < scope.MIN_FIT_COLS) {
flog('measure-skip-small-width', {
vpWidth: vpWidth,
cellWidth: cellWidth,
cols: cols
})
notify({ type: 'measure-result', cols: null, rows: null })
return
}
// Why: the rows we report become the PTY's actual row count after the
// server fits to viewport, and xterm renders exactly that many lines
// anchored top-left of the WebView. Subtracting rows here would leave
// dead xterm-background space at the bottom of the container and make
// the last PTY rows visually appear above an "invisible line." Any
// safety margin between the prompt and the accessory bar must come
// from RN layout (terminalFrame's flex bounds), not from undersizing
// the PTY.
const rows = Math.max(8, Math.floor(vpHeight / cellHeight))
notify({ type: 'measure-result', cols: cols, rows: rows })
}
export function handleMsg(msg: TerminalHostMessage) {
if (typeof msg.id === 'number') {
// oxlint-disable-next-line unicorn/prefer-includes -- the document's text is pinned token for token; rewriting this changes the native program
if (scope.handledMessageIds.indexOf(msg.id) !== -1) {
return
}
scope.handledMessageIds.push(msg.id)
if (scope.handledMessageIds.length > 256) {
scope.handledMessageIds.shift()
}
}
if (msg.type === 'ping') {
notify({ type: 'pong', pingId: msg.id })
} else if (msg.type === 'init') {
init(
msg.cols!,
msg.rows!,
msg.initialData,
msg.terminalTheme,
msg.fontScale,
msg.preserveScroll!,
msg.oscLinks
)
} else if (msg.type === 'set-font-scale') {
// Why: ignore RN echoing back the value a pinch just set (msg.fontScale ===
// currentTextScale) so the post-pinch state isn't reset; only apply changes.
if (
typeof msg.fontScale === 'number' &&
msg.fontScale > 0 &&
msg.fontScale !== scope.currentTextScale
) {
scope.userScale = 1
scope.panX = 0
scope.panY = 0
applyTextScale(msg.fontScale)
}
} else if (msg.type === 'resize') {
resize(msg.cols!, msg.rows!)
} else if (msg.type === 'reflow') {
reflow(msg.cols!, msg.rows!)
} else if (msg.type === 'write') {
write(msg.data!)
} else if (msg.type === 'clear') {
scope.terminalGeneration++
resetWriteQueue()
resumeTerminalDataReplyAuthority() // Why: clear drops the replay boundary.
scope.statusDotPendingSelector = false
scope.afterDrainCallbacks = []
scope.writesDraining = false
scope.mouseModeScanTail = ''
scope.trackedMouseTrackingMode = 'none'
scope.sgrMouseMode = false
scope.sgrMousePixelsMode = false
scope.initialOscLinks = []
scope.initialOscLinkRowOffset = 0
scope.initialOscLinkEvictionReady = false
if (scope.term) {
scope.term.clear()
scope.term.reset()
}
emitModesIfChanged()
emitKeyboardAvoidanceMetrics()
resetEvictionCounter()
if (scope.selMode === 'select') {
notify({ type: 'selection-evicted' })
cancelSelect()
}
} else if (msg.type === 'measure') {
measureFitDimensions(msg.containerHeight)
} else if (msg.type === 'reset-zoom') {
applyFitScale('reset-zoom-msg')
} else if (msg.type === 'set-theme') {
applyTerminalTheme(msg.terminalTheme)
} else if (msg.type === 'cancel-select') {
if (scope.selMode === 'select') {
cancelSelect()
}
} else if (msg.type === 'do-select-all') {
if (scope.term) {
try {
scope.term.selectAll()
const b = scope.term.buffer.active
if (scope.selMode !== 'select') {
scope.selMode = 'select'
scope.selectionOverlay!.classList.add('active')
notify({ type: 'set-select-mode', enabled: true })
}
scope.sel = {
anchor: { col: 0, row: 0 },
focus: { col: scope.term.cols - 1, row: b.length - 1 },
activeHandle: null
}
repositionOverlay()
} catch {}
}
}
}
@@ -0,0 +1,86 @@
import { scope } from './document-scope'
/**
* The postMessage bridge to the host, and the engine error reporting that rides on it.
*
* They are one module because the document declares them together, ahead of the message router
* that both serve.
*/
declare global {
interface Window {
__engineErrors: string[]
}
}
export function notify(msg: Record<string, unknown>) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(msg))
}
}
/** What a thrown value can be here: an Error-shaped object, a string, or nothing. */
export type TerminalEngineError = string | null | undefined | { message?: unknown }
export function engineErrorText(err: TerminalEngineError) {
if (!err) {
return ''
}
if (typeof err === 'string') {
return err
}
if (err && typeof err.message === 'string') {
return err.message
}
try {
return String(err)
} catch {
return ''
}
}
export function chromeVersionText() {
const match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\/([0-9.]+)/)
return match ? 'Chrome ' + match[1] : 'Chrome version unknown'
}
let nonFatalErrorNotifies = 0
export function reportEngineError(context: string, err: TerminalEngineError, fatal?: unknown) {
const isFatal = fatal === undefined ? !scope.everReady : !!fatal
if (!isFatal) {
// Why: a constructed-but-degraded engine can throw per frame; cap
// non-fatal notifies so RN isn't flooded. Fatal reports always emit.
nonFatalErrorNotifies++
if (nonFatalErrorNotifies > 5) {
return
}
}
const parts = [context]
const errText = engineErrorText(err)
if (errText) {
parts.push(errText)
}
if (window.__engineErrors && window.__engineErrors.length) {
parts.push('captured: ' + window.__engineErrors.join(' | '))
}
parts.push(chromeVersionText())
notify({
type: 'error',
fatal: isFatal,
message: parts.join(' - ')
})
}
window.onerror = function (
msg: string | (Event & { message?: unknown }),
source,
line,
column,
err?: TerminalEngineError
) {
if (window.__engineErrors.length < 20) {
window.__engineErrors.push(String(msg))
}
reportEngineError('terminal runtime error', err || msg)
}
@@ -0,0 +1,70 @@
import { notify } from './host-notify'
import { scope, type TerminalDocumentCell, type TerminalDocumentLine } from './document-scope'
export function lineHasVisibleContent(
line: TerminalDocumentLine,
cell: TerminalDocumentCell | null
) {
if (line.translateToString(true).trim().length > 0) {
return true
}
if (!cell || !line.getCell) {
return false
}
const limit = Math.min(scope.term!.cols || 0, line.length || 0)
for (let x = 0; x < limit; x++) {
const current = line.getCell(x, cell)
if (!current) {
continue
}
if (!current.isBgDefault() || current.isInverse()) {
return true
}
if (typeof current.isUnderline === 'function' && current.isUnderline()) {
return true
}
if (typeof current.isStrikethrough === 'function' && current.isStrikethrough()) {
return true
}
if (typeof current.isOverline === 'function' && current.isOverline()) {
return true
}
}
return false
}
export function computeContentBottomRow() {
if (!scope.term || !scope.term.buffer || !scope.term.buffer.active) {
return 0
}
const buffer = scope.term.buffer.active
const top = buffer.viewportY || 0
const cell = buffer.getNullCell ? buffer.getNullCell() : null
for (let y = (scope.term.rows || 0) - 1; y >= 0; y--) {
try {
const line = buffer.getLine(top + y)
if (line && lineHasVisibleContent(line, cell)) {
return y
}
} catch {}
}
return 0
}
export function emitKeyboardAvoidanceMetrics() {
if (!scope.term) {
return
}
let alt = false
try {
alt =
scope.term.buffer && scope.term.buffer.active && scope.term.buffer.active.type === 'alternate'
} catch {}
notify({
type: 'keyboard-avoidance-metrics',
cursorY: scope.term.buffer && scope.term.buffer.active ? scope.term.buffer.active.cursorY : 0,
contentBottomRow: alt ? 0 : computeContentBottomRow(),
rows: scope.term.rows || 0,
altScreen: alt
})
}
@@ -0,0 +1,53 @@
import { adjustRowsForViewport, applyFitScale, clampPan } from './fit-scale'
import { repositionOverlay } from './selection-overlay'
import { handleMsg, type TerminalHostMessage } from './host-message-router'
import { notify, reportEngineError, type TerminalEngineError } from './host-notify'
import { updateTransform } from './viewport-transform'
import { scope } from './document-scope'
declare global {
interface Window {
Terminal?: unknown
}
}
export function handleIncomingMessage(e: Event & { data?: TerminalHostMessage | string }) {
let msg: TerminalHostMessage
try {
msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data
} catch {
return
}
try {
handleMsg(msg!)
} catch (ex) {
reportEngineError(
msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed',
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a catch binding is `unknown`; the reporter reads only `message` and falls back to String().
ex as TerminalEngineError,
msg && msg.type === 'init' && !scope.everReady
)
}
}
window.addEventListener('message', handleIncomingMessage)
document.addEventListener('message', handleIncomingMessage)
window.addEventListener('resize', function () {
// Why: viewport changed (keyboard open/close, orientation, RN container
// size update). Re-fit so the scale matches the new vpWidth — without
// this, opening the keyboard leaves the terminal at the old scale even
// though there's now less vertical room and the fit ratio may differ.
applyFitScale('window-resize')
adjustRowsForViewport()
repositionOverlay()
clampPan()
updateTransform()
})
if (window.Terminal) {
notify({ type: 'web-ready' })
} else {
reportEngineError('terminal engine missing', 'xterm failed to load', true)
}
@@ -0,0 +1,46 @@
import { notify } from './host-notify'
import { getMouseTrackingMode } from './mouse-input-encoding'
import { scope } from './document-scope'
export function emitModesIfChanged() {
if (!scope.term) {
return
}
const bp = !!(scope.term.modes && scope.term.modes.bracketedPasteMode)
let alt = false
const mouseTrackingMode = getMouseTrackingMode()
try {
alt =
scope.term.buffer && scope.term.buffer.active && scope.term.buffer.active.type === 'alternate'
} catch {}
if (
bp !== scope.lastEmittedModes.bracketedPasteMode ||
alt !== scope.lastEmittedModes.altScreen ||
mouseTrackingMode !== scope.lastEmittedModes.mouseTrackingMode ||
scope.sgrMouseMode !== scope.lastEmittedModes.sgrMouseMode ||
scope.sgrMousePixelsMode !== scope.lastEmittedModes.sgrMousePixelsMode
) {
scope.lastEmittedModes = {
bracketedPasteMode: bp,
altScreen: alt,
mouseTrackingMode: mouseTrackingMode,
sgrMouseMode: scope.sgrMouseMode,
sgrMousePixelsMode: scope.sgrMousePixelsMode
}
notify({
type: 'modes',
bracketedPasteMode: bp,
altScreen: alt,
mouseTrackingMode: mouseTrackingMode,
sgrMouseMode: scope.sgrMouseMode,
sgrMousePixelsMode: scope.sgrMousePixelsMode
})
}
}
scope.lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
}
@@ -0,0 +1,283 @@
import { handleDragMove, repositionOverlay, stopEdgeScroll } from './selection-overlay'
import { applyXtermSelection, cancelSelect } from './selection-range'
import { notify } from './host-notify'
import { getMouseTrackingMode, isSafeSgrMouseCoordinate } from './mouse-input-encoding'
import { viewportToCell } from './viewport-cell'
import { scope } from './document-scope'
import { notifyTerminalSurfaceTap } from './surface-tap'
import { viewportToMouseReportCell } from './mouse-report-cell'
import { dispatcherShouldBlockSurface } from './tap-dispatch'
/** A mouse press being tracked from pointerdown to pointerup. */
export type TerminalMouseGesture = {
startX: number
startY: number
lastX: number
lastY: number
lastCellKey: string | null
moved: boolean
mode: string
dismissedSelection: boolean
}
let mouseGesture: TerminalMouseGesture | null = null
// One report per transition, built with the same encoding ladder as
// buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns ''
// when the mode does not report this transition (x10 has no release, only
// drag/any report motion) or the cell is not encodable.
export function buildMouseButtonReport(kind: string, clientX: number, clientY: number) {
const mouseTrackingMode = getMouseTrackingMode()
if (mouseTrackingMode === 'none') {
return ''
}
if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') {
return ''
}
if (kind === 'release' && mouseTrackingMode === 'x10') {
return ''
}
const cell = viewportToMouseReportCell(clientX, clientY)
if (!cell) {
return ''
}
const sgrButton = kind === 'motion' ? 32 : 0
const sgrFinal = kind === 'release' ? 'm' : 'M'
if (scope.sgrMousePixelsMode) {
if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) {
return ''
}
return scope.ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal
}
if (scope.sgrMouseMode) {
// Why: xterm increments zero-based mouse cells before encoding reports.
const sgrCol = cell.col + 1
const sgrRow = cell.row + 1
if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) {
return ''
}
return scope.ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal
}
const button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32
const col = cell.col + 1 + 32
const row = cell.row + 1 + 32
// Why: non-SGR mouse bytes above ASCII are not preserved reliably through
// the mobile JSON/RPC string path; drop instead of corrupting input.
if (col > 126 || row > 126) {
return ''
}
return (
scope.ESC +
'[M' +
String.fromCharCode(button) +
String.fromCharCode(col) +
String.fromCharCode(row)
)
}
export function mouseReportCellKey(clientX: number, clientY: number) {
const cell = viewportToMouseReportCell(clientX, clientY)
return cell ? cell.col + ',' + cell.row : null
}
export function abandonMouseGesture() {
const gesture = mouseGesture
mouseGesture = null
if (!gesture) {
return
}
if (gesture.mode === 'tracking') {
// Why: the press report already went to the TUI; a lost pointer must not
// leave the button latched down on the far side.
const release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY)
if (release) {
notify({ type: 'terminal-input', bytes: release })
}
} else if (gesture.mode === 'selecting') {
if (scope.sel) {
scope.sel.activeHandle = null
}
stopEdgeScroll()
}
}
export function beginMouseDrag(gesture: TerminalMouseGesture) {
gesture.moved = true
if (getMouseTrackingMode() !== 'none') {
gesture.mode = 'tracking'
gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY)
const press = buildMouseButtonReport('press', gesture.startX, gesture.startY)
if (press) {
notify({ type: 'terminal-input', bytes: press })
}
return
}
const anchor = viewportToCell(gesture.startX, gesture.startY)
if (!anchor) {
gesture.mode = 'cancelled'
return
}
// Why: mouse drags select character-anchored ranges like desktop terminals,
// not the word-seeded long-press selection; reuse the touch handle-drag
// plumbing (edge scroll included) by acting as a live 'end' handle.
gesture.mode = 'selecting'
scope.selMode = 'select'
scope.sel = { anchor: anchor, focus: anchor, activeHandle: 'end' }
scope.selectionOverlay!.classList.add('active')
notify({ type: 'set-select-mode', enabled: true })
applyXtermSelection()
repositionOverlay()
}
export function attachSurfaceMouseClickDragHandler(targetSurface: HTMLElement) {
targetSurface.addEventListener(
'pointerdown',
function (e) {
if (e.pointerType !== 'mouse' || e.button !== 0) {
return
}
if (dispatcherShouldBlockSurface() || !scope.term) {
return
}
// Why: a pointerup lost outside the WebView must not leave the previous
// gesture latched (tracking press with no release) when the next one lands.
if (mouseGesture) {
abandonMouseGesture()
}
// Why: mouse pointers have no implicit capture; without it a drag that
// leaves the surface drops pointermove/pointerup and strands the gesture.
try {
if (targetSurface.setPointerCapture) {
targetSurface.setPointerCapture(e.pointerId)
}
} catch {}
mouseGesture = {
startX: e.clientX,
startY: e.clientY,
lastX: e.clientX,
lastY: e.clientY,
lastCellKey: null,
moved: false,
mode: 'pending',
dismissedSelection: false
}
if (scope.selMode === 'select') {
// Why: touch parity — pressing outside the pill dismisses the current
// selection; the same press may still start a new drag selection.
cancelSelect()
mouseGesture.dismissedSelection = true
}
},
true
)
targetSurface.addEventListener(
'pointermove',
function (e) {
const gesture = mouseGesture
if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') {
return
}
if (!scope.term) {
return
}
gesture.lastX = e.clientX
gesture.lastY = e.clientY
if ((e.buttons & 1) === 0) {
// Why: a pointerup lost outside the WebView (capture unavailable) must
// end the gesture here, or a tracked press stays latched at the TUI.
// Coordinates first, so the synthesized release lands where the
// pointer re-entered rather than at the previous cell.
abandonMouseGesture()
return
}
if (!gesture.moved) {
const dx = Math.abs(e.clientX - gesture.startX)
const dy = Math.abs(e.clientY - gesture.startY)
if (dx + dy <= scope.TAP_SLOP) {
return
}
beginMouseDrag(gesture)
}
if (gesture.mode === 'tracking') {
// Why: one motion report per cell keeps drags bounded by grid size, not
// by pointermove cadence, so the RN rate limiter is never the bottleneck.
const cellKey = mouseReportCellKey(e.clientX, e.clientY)
if (cellKey && cellKey !== gesture.lastCellKey) {
gesture.lastCellKey = cellKey
const motion = buildMouseButtonReport('motion', e.clientX, e.clientY)
if (motion) {
notify({ type: 'terminal-input', bytes: motion })
}
}
} else if (gesture.mode === 'selecting') {
handleDragMove('end', e.clientX, e.clientY)
}
},
true
)
targetSurface.addEventListener(
'pointerup',
function (e) {
const gesture = mouseGesture
if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) {
return
}
mouseGesture = null
if (gesture.mode === 'cancelled' || !scope.term) {
return
}
if (gesture.mode === 'tracking') {
const release = buildMouseButtonReport('release', e.clientX, e.clientY)
if (release) {
notify({ type: 'terminal-input', bytes: release })
}
return
}
if (gesture.mode === 'selecting') {
if (scope.sel) {
scope.sel.activeHandle = null
}
stopEdgeScroll()
repositionOverlay()
return
}
if (dispatcherShouldBlockSurface()) {
return
}
// Why: a dismissing tap only clears the selection (touch parity); it must
// not also open a link or focus the keyboard underneath.
if (gesture.dismissedSelection) {
return
}
// Pointer clicks keep their current link, file, TUI mouse, and focus priority.
notifyTerminalSurfaceTap(e.clientX, e.clientY, false)
},
true
)
targetSurface.addEventListener(
'pointercancel',
function (e) {
if (e.pointerType !== 'mouse') {
return
}
abandonMouseGesture()
},
true
)
// Why: Android input injection can pair a mouse-flavored pointerdown with
// real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives,
// the document touch dispatcher owns the gesture.
targetSurface.addEventListener(
'touchstart',
function () {
if (mouseGesture) {
abandonMouseGesture()
}
},
true
)
}
@@ -0,0 +1,230 @@
import { notify } from './host-notify'
import { scope } from './document-scope'
import { viewportToMouseReportCell } from './mouse-report-cell'
export function isAlternateBufferActive() {
try {
return !!(
scope.term &&
scope.term.buffer &&
scope.term.buffer.active &&
scope.term.buffer.active.type === 'alternate'
)
} catch {
return false
}
}
export function getMouseTrackingMode() {
try {
if (scope.term && scope.term.modes && typeof scope.term.modes.mouseTrackingMode === 'string') {
const mode = scope.term.modes.mouseTrackingMode
if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') {
return mode
}
return 'none'
}
} catch {}
if (
scope.trackedMouseTrackingMode === 'x10' ||
scope.trackedMouseTrackingMode === 'vt200' ||
scope.trackedMouseTrackingMode === 'drag' ||
scope.trackedMouseTrackingMode === 'any'
) {
return scope.trackedMouseTrackingMode
}
return 'none'
}
export function repeatSequence(sequence: string, count: number) {
let out = ''
for (let i = 0; i < count; i++) {
out += sequence
}
return out
}
export function buildArrowScrollSequence(lines: number) {
let prefix = '['
try {
if (scope.term && scope.term.modes && scope.term.modes.applicationCursorKeysMode) {
prefix = 'O'
}
} catch {}
return scope.ESC + prefix + (lines < 0 ? 'A' : 'B')
}
export function buildMouseWheelSequence(lines: number, clientX: number, clientY: number) {
const cell = viewportToMouseReportCell(clientX, clientY)
if (!cell) {
return ''
}
const eventCode = lines < 0 ? 64 : 65
if (scope.sgrMousePixelsMode) {
if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) {
return ''
}
return scope.ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M'
}
if (scope.sgrMouseMode) {
// Why: xterm increments zero-based mouse cells before encoding reports.
const sgrCol = cell.col + 1
const sgrRow = cell.row + 1
if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) {
return ''
}
return scope.ESC + '[<' + eventCode + ';' + sgrCol + ';' + sgrRow + 'M'
}
// Why: xterm increments zero-based mouse cells before encoding reports.
const button = eventCode + 32
const col = cell.col + 1 + 32
const row = cell.row + 1 + 32
// Why: non-SGR mouse bytes above ASCII are not preserved reliably through
// the mobile JSON/RPC string path. Fall back to keys for wide terminals.
if (button > 126 || col > 126 || row > 126) {
return ''
}
return (
scope.ESC +
'[M' +
String.fromCharCode(button) +
String.fromCharCode(col) +
String.fromCharCode(row)
)
}
export function isSafeSgrMouseCoordinate(value: number) {
return Number.isInteger(value) && value >= 0 && value <= 9999
}
export function buildMouseClickInput(clientX: number, clientY: number) {
const mouseTrackingMode = getMouseTrackingMode()
if (!isClickMouseTrackingMode(mouseTrackingMode)) {
return ''
}
const cell = viewportToMouseReportCell(clientX, clientY)
if (!cell) {
return ''
}
if (scope.sgrMousePixelsMode) {
// Why: xterm 1016 keeps SGR syntax but reports raw zero-based pixel positions.
const pixelX = cell.x
const pixelY = cell.y
if (!isSafeSgrMouseCoordinate(pixelX) || !isSafeSgrMouseCoordinate(pixelY)) {
return ''
}
const pixelPress = scope.ESC + '[<0;' + pixelX + ';' + pixelY + 'M'
if (mouseTrackingMode === 'x10') {
return pixelPress
}
return pixelPress + scope.ESC + '[<0;' + pixelX + ';' + pixelY + 'm'
}
if (scope.sgrMouseMode) {
// Why: xterm increments zero-based mouse cells before encoding reports.
const sgrCol = cell.col + 1
const sgrRow = cell.row + 1
if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) {
return ''
}
const sgrPress = scope.ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M'
if (mouseTrackingMode === 'x10') {
return sgrPress
}
return sgrPress + scope.ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm'
}
// Why: non-SGR click coordinates use printable ASCII bytes on the mobile
// bridge; unsafe wide-terminal cells must not turn into corrupted input.
const col = cell.col + 1 + 32
const row = cell.row + 1 + 32
if (col > 126 || row > 126) {
return ''
}
const press =
scope.ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row)
if (mouseTrackingMode === 'x10') {
return press
}
return (
press +
scope.ESC +
'[M' +
String.fromCharCode(35) +
String.fromCharCode(col) +
String.fromCharCode(row)
)
}
export function isClickMouseTrackingMode(mode: string) {
return mode !== 'none'
}
export function isWheelMouseTrackingMode(mode: string) {
return mode !== 'none' && mode !== 'x10'
}
export function shouldRouteScrollToTerminalInput() {
return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive()
}
export function buildMouseWheelScrollInput(lines: number, clientX: number, clientY: number) {
const count = Math.min(Math.abs(lines), 32)
if (count === 0) {
return ''
}
const sequence = buildMouseWheelSequence(lines, clientX, clientY)
if (!sequence) {
return ''
}
return repeatSequence(sequence, count)
}
export function buildTuiScrollInput(lines: number, clientX: number, clientY: number) {
const count = Math.min(Math.abs(lines), 32)
if (count === 0) {
return ''
}
const mouseTrackingMode = getMouseTrackingMode()
let sequence = ''
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
sequence = buildMouseWheelSequence(lines, clientX, clientY)
}
if (!sequence) {
sequence = buildArrowScrollSequence(lines)
}
return repeatSequence(sequence, count)
}
export function routeScrollLines(lines: number, clientX: number, clientY: number) {
if (!scope.term || lines === 0) {
return
}
const mouseTrackingMode = getMouseTrackingMode()
const alternateBufferActive = isAlternateBufferActive()
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
// Why: xterm sends wheel events to mouse-aware TUIs before considering
// scrollback, even if the app stays on the normal buffer.
const mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY)
if (mouseInput) {
notify({ type: 'terminal-input', bytes: mouseInput })
return
}
// Why: default mouse encoding can be unrepresentable in our ASCII-safe
// RPC path on wide terminals. Send bounded arrows instead of local
// scrollback/no-op while a mouse-aware app owns scroll gestures.
const fallbackInput = buildTuiScrollInput(lines, clientX, clientY)
if (fallbackInput) {
notify({ type: 'terminal-input', bytes: fallbackInput })
}
return
}
if (alternateBufferActive) {
// Why: alternate-screen TUIs own their scroll state and xterm has no
// scrollback there, so mobile scroll gestures must become terminal input.
const input = buildTuiScrollInput(lines, clientX, clientY)
if (input) {
notify({ type: 'terminal-input', bytes: input })
}
return
}
scope.term.scrollLines(lines)
}
@@ -0,0 +1,74 @@
import { extractMouseModeScanTail } from './write-queue'
import { scope } from './document-scope'
export function isAltScreenActive(data: unknown): data is string {
if (typeof data !== 'string') {
return false
}
const on = data.lastIndexOf(scope.ESC + '[?1049h')
const off = data.lastIndexOf(scope.ESC + '[?1049l')
return on !== -1 && on > off
}
export function normalizeInitialData(data: unknown) {
if (!isAltScreenActive(data)) {
return data
}
const on = data.lastIndexOf(scope.ESC + '[?1049h')
// Why: SerializeAddon can include normal-buffer scrollback before the
// active alternate-screen snapshot. Replaying both into a fresh mobile
// xterm duplicates TUI frames and can flatten SGR attributes.
return on > 0 ? data.slice(on) : data
}
export function updateMouseModeFromData(data: unknown) {
if (typeof data !== 'string' || data.length === 0) {
return
}
const input = scope.mouseModeScanTail + data
scope.mouseModeScanTail = extractMouseModeScanTail(input)
const re = new RegExp(
scope.ESC + 'c|' + scope.ESC + '\\[\\?([0-9;]+)([hl])|' + scope.C1_CSI + '\\?([0-9;]+)([hl])',
'g'
)
let match: RegExpExecArray | null
while ((match = re.exec(input)) !== null) {
if (match[0] === scope.ESC + 'c') {
scope.trackedMouseTrackingMode = 'none'
scope.sgrMouseMode = false
scope.sgrMousePixelsMode = false
continue
}
const enabled = (match[2] || match[4]) === 'h'
const params = (match[1] || match[3]).split(';')
for (let i = 0; i < params.length; i++) {
if (params[i] === '') {
continue
}
const param = Number(params[i])
if (!Number.isInteger(param)) {
continue
}
if (param === 9) {
scope.trackedMouseTrackingMode = enabled ? 'x10' : 'none'
}
if (param === 1000) {
scope.trackedMouseTrackingMode = enabled ? 'vt200' : 'none'
}
if (param === 1002) {
scope.trackedMouseTrackingMode = enabled ? 'drag' : 'none'
}
if (param === 1003) {
scope.trackedMouseTrackingMode = enabled ? 'any' : 'none'
}
if (param === 1006) {
scope.sgrMouseMode = enabled
scope.sgrMousePixelsMode = false
}
if (param === 1016) {
scope.sgrMouseMode = false
scope.sgrMousePixelsMode = enabled
}
}
}
}
@@ -0,0 +1,67 @@
import { getCellHeight } from './fit-scale'
import { getCellWidth, getTotalScale } from './viewport-transform'
import { scope } from './document-scope'
/** Where a viewport point lands in the terminal's cell grid, for an xterm mouse report. */
export type MouseReportCell = { col: number; row: number; x: number; y: number }
/**
* Maps a viewport point to a mouse-report cell, or null when there is no grid to map onto.
*
* Reads through the pan offset and the total scale rather than the element's box: the surface is a
* transformed layer, so its on-screen geometry is not the geometry xterm reports in.
*/
export function viewportToMouseReportCell(
clientX: number,
clientY: number
): MouseReportCell | null {
if (!scope.term) {
return null
}
const cellW = getCellWidth()
const cellH = getCellHeight()
if (cellW <= 0 || cellH <= 0) {
return null
}
if (typeof clientX !== 'number') {
clientX = window.innerWidth / 2
}
if (typeof clientY !== 'number') {
clientY = window.innerHeight / 2
}
let total = getTotalScale()
if (total <= 0) {
total = 1
}
let sx = (clientX - scope.panX) / total
let sy = (clientY - scope.panY) / total
const maxX = Math.max(0, scope.term.cols * cellW - 1)
const maxY = Math.max(0, scope.term.rows * cellH - 1)
if (sx < 0) {
sx = 0
}
if (sx > maxX) {
sx = maxX
}
if (sy < 0) {
sy = 0
}
if (sy > maxY) {
sy = maxY
}
let col = Math.floor(sx / cellW)
let row = Math.floor(sy / cellH)
if (col < 0) {
col = 0
}
if (col > scope.term.cols - 1) {
col = scope.term.cols - 1
}
if (row < 0) {
row = 0
}
if (row > scope.term.rows - 1) {
row = scope.term.rows - 1
}
return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }
}
@@ -0,0 +1,102 @@
import { getCellHeight } from './fit-scale'
import { getTotalScale, updateScrollIndicator } from './viewport-transform'
import { scope } from './document-scope'
export function clampNormalScrollLines(lines: number) {
if (!scope.term || !scope.term.buffer || !scope.term.buffer.active || lines === 0) {
return 0
}
const buffer = scope.term.buffer.active
if (lines > 0) {
return Math.min(lines, Math.max(0, buffer.baseY - buffer.viewportY))
}
return Math.max(lines, -buffer.viewportY)
}
export function canScrollNormalBufferDelta(deltaY: number) {
if (!scope.term || !scope.term.buffer || !scope.term.buffer.active || deltaY === 0) {
return false
}
const buffer = scope.term.buffer.active
if (deltaY > 0) {
return buffer.viewportY < buffer.baseY
}
return buffer.viewportY > 0
}
export function applyNormalBufferScrollDelta(deltaY: number) {
if (!scope.term || deltaY === 0) {
return false
}
const effectiveCellH = getCellHeight() * getTotalScale()
if (effectiveCellH <= 0) {
return false
}
if (!canScrollNormalBufferDelta(deltaY)) {
resetSmoothScrollOffset()
return false
}
scope.smoothScrollOffsetY -= deltaY
const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH)
if (lines !== 0) {
const applied = clampNormalScrollLines(lines)
if (applied !== 0) {
scope.term.scrollLines(applied)
// Why: xterm's renderer is row-based. Buffer touch pixels and only
// commit whole rows so TUI canvas layers do not shimmer between
// fractional transforms and xterm repaints.
scope.smoothScrollOffsetY += applied * effectiveCellH
}
if (applied !== lines) {
scope.smoothScrollOffsetY = 0
}
}
const limit = effectiveCellH - 1
if (scope.smoothScrollOffsetY > limit) {
scope.smoothScrollOffsetY = limit
}
if (scope.smoothScrollOffsetY < -limit) {
scope.smoothScrollOffsetY = -limit
}
updateScrollIndicator(true)
return true
}
export function enqueueNormalBufferScrollDelta(deltaY: number) {
if (!scope.term || deltaY === 0) {
return false
}
if (!canScrollNormalBufferDelta(deltaY)) {
resetSmoothScrollOffset()
return false
}
scope.pendingNormalScrollDeltaY += deltaY
if (scope.normalScrollFrameId !== null) {
return true
}
// Why: dense terminal rows are expensive to repaint. Coalesce touchmove
// deltas into one xterm row-scroll per frame instead of repainting from
// the input event stream.
scope.normalScrollFrameId = requestAnimationFrame(function () {
scope.normalScrollFrameId = null
const delta = scope.pendingNormalScrollDeltaY
scope.pendingNormalScrollDeltaY = 0
if (!applyNormalBufferScrollDelta(delta)) {
resetSmoothScrollOffset()
}
})
return true
}
export function resetSmoothScrollOffset() {
scope.pendingNormalScrollDeltaY = 0
if (scope.normalScrollFrameId !== null) {
cancelAnimationFrame(scope.normalScrollFrameId)
scope.normalScrollFrameId = null
}
if (scope.smoothScrollOffsetY === 0) {
return
}
scope.smoothScrollOffsetY = 0
updateScrollIndicator(false)
}
@@ -0,0 +1,221 @@
import { cellColToStringIndex, getLineText } from './cell-geometry'
import { viewportToCell } from './viewport-cell'
import {
scope,
type TerminalDocumentLine,
type TerminalInitialOscLink,
type TerminalOscLinkService
} from './document-scope'
import { parsePathLineCol, type TerminalPathCandidate } from './path-tap'
/** What a tapped OSC 8 link resolves to: a URL to open, or a file to reveal. */
export type TerminalOscLinkTarget =
| { kind: 'url'; url: string }
| { kind: 'file'; fileTap: TerminalPathCandidate }
// Why: OSC 8 links can render as labels like "#1234"; the URI lives in
// xterm's internal link service, so every access is guarded and falls through.
export function oscLinkService(): TerminalOscLinkService | null {
try {
const core = scope.term && scope.term._core
if (!core) {
return null
}
return (
core._oscLinkService || (core._inputHandler && core._inputHandler._oscLinkService) || null
)
} catch {
return null
}
}
export function oscLinkAtViewportPoint(clientX: number, clientY: number) {
try {
const cell = viewportToCell(clientX, clientY)
if (!cell) {
return null
}
const line = scope.term!.buffer.active.getLine(cell.row)
if (!line) {
return null
}
const urlId = oscLinkIdAtCell(line, cell.col)
if (!urlId) {
return initialOscLinkAtCell(cell.row, cell.col)
}
const svc = oscLinkService()
if (!svc || !svc.getLinkData) {
return initialOscLinkAtCell(cell.row, cell.col)
}
const data = svc.getLinkData(urlId)
const uri = data && data.uri
return terminalOscLinkTarget(uri)
} catch {
return null
}
}
export function initialOscLinkAtCell(row: number, col: number) {
for (let i = 0; i < scope.initialOscLinks.length; i++) {
const link = scope.initialOscLinks[i]
if (!link || typeof link.uri !== 'string') {
continue
}
if (link.row < scope.initialOscLinkRowOffset) {
continue
}
const shiftedRow = link.row - scope.initialOscLinkRowOffset
if (
shiftedRow === row &&
col >= link.startCol &&
col < link.endCol &&
initialOscLinkTextStillMatches(link, shiftedRow)
) {
return terminalOscLinkTarget(link.uri)
}
}
return null
}
export function terminalOscLinkTarget(uri: unknown): TerminalOscLinkTarget | null {
if (typeof uri !== 'string') {
return null
}
if (/^https?:/i.test(uri)) {
return { kind: 'url', url: uri }
}
const fileTap = resolveTerminalOscFileTap(uri)
return fileTap ? { kind: 'file', fileTap: fileTap } : null
}
export function resolveTerminalOscFileTap(uri: string) {
return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri)
}
export function resolveTerminalFileUrlTap(uri: string): TerminalPathCandidate | null {
let parsed: URL
try {
parsed = new URL(uri)
} catch {
return null
}
if (parsed.protocol !== 'file:') {
return null
}
let filePath: string
try {
filePath = decodeURIComponent(parsed.pathname || '')
} catch {
return null
}
if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) {
filePath = '//' + parsed.hostname + filePath
} else if (/^\/[A-Za-z]:\//.test(filePath)) {
filePath = filePath.slice(1)
}
if (!filePath) {
return null
}
const hashTarget = parseFileUrlLineHash(parsed.hash || '')
if (hashTarget) {
return { pathText: filePath, line: hashTarget.line, column: hashTarget.column }
}
if (/%3a/i.test(parsed.pathname || '')) {
return { pathText: filePath, line: null, column: null }
}
return (
parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null }
)
}
export function isLocalFileUriHostname(hostname: string) {
const normalized = String(hostname).toLowerCase()
return (
normalized === 'localhost' ||
normalized === '127.0.0.1' ||
normalized === '::1' ||
normalized === '[::1]'
)
}
export function parseOscPathLikeTarget(value: string) {
if (
!/^(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))/.test(
value
)
) {
return null
}
return parsePathLineCol(value)
}
export function parseFileUrlLineHash(hash: string) {
const match = /^#?L(\d+)(?:C(\d+))?$/i.exec(hash)
if (!match) {
return null
}
const line = Number.parseInt(match[1], 10)
const column = match[2] ? Number.parseInt(match[2], 10) : null
if (line < 1 || (column !== null && column < 1)) {
return null
}
return { line: line, column: column }
}
export function parseFilePathTrailingLineTarget(filePath: string) {
const match = /^(.*?)(?::(\d+))(?::(\d+))?$/.exec(filePath)
if (
!match ||
!match[1] ||
match[1].charAt(match[1].length - 1) === '/' ||
match[1].charAt(match[1].length - 1) === '\\'
) {
return null
}
const line = Number.parseInt(match[2], 10)
const column = match[3] ? Number.parseInt(match[3], 10) : null
if (line < 1 || (column !== null && column < 1)) {
return null
}
return { pathText: match[1], line: line, column: column }
}
export function captureInitialOscLinkTexts() {
if (!Array.isArray(scope.initialOscLinks)) {
return
}
for (let i = 0; i < scope.initialOscLinks.length; i++) {
const link = scope.initialOscLinks[i]
if (!link || typeof link.text === 'string') {
continue
}
link.text = initialOscLinkTextAtRow(link, link.row)
}
}
export function initialOscLinkTextStillMatches(link: TerminalInitialOscLink, row: number) {
if (typeof link.text !== 'string') {
return false
}
return link.text.length > 0 && initialOscLinkTextAtRow(link, row) === link.text
}
export function initialOscLinkTextAtRow(link: TerminalInitialOscLink, row: number) {
try {
const lineText = getLineText(row)
const start = cellColToStringIndex(row, link.startCol)
const end = cellColToStringIndex(row, link.endCol)
return lineText.slice(start, end)
} catch {
return ''
}
}
export function oscLinkIdAtCell(line: TerminalDocumentLine, col: number) {
try {
const bufCell = line.getCell!(col)
return bufCell && bufCell.extended && bufCell.extended.urlId ? bufCell.extended.urlId : 0
} catch {
return 0
}
}
+222
View File
@@ -0,0 +1,222 @@
import { cellColToStringIndex, getLineText } from './cell-geometry'
import { viewportToCell } from './viewport-cell'
/**
* File-path-under-tap detection.
*
* Mirrors the unit-tested `terminal-path-tap.ts`; keep the two in sync. That module is the source
* of truth for the algorithm and has the regression tests.
*
* Matches both slash-bearing paths AND bare filenames with an extension (README.md,
* src/index.ts:5) — like desktop, we propose candidates and let the host's
* files.resolveTerminalPath existence check reject non-files. Agents often print a bare filename
* (the markdown link target is consumed, leaving only the label text), so requiring a slash would
* miss the common case.
*/
/** A span of a rendered line, in string indices. */
export type TerminalPathRange = { text: string; startIndex: number; endIndex: number }
/** A path proposed to the host, with the line and column suffixes it carried. */
export type TerminalPathCandidate = {
pathText: string
line: number | null
column: number | null
}
const FILE_PATH_RE =
// oxlint-disable-next-line no-useless-escape -- the document's text is pinned token for token; rewriting this changes the native program
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g
const SPACED_PATH_RE =
// oxlint-disable-next-line no-useless-escape -- the document's text is pinned token for token; rewriting this changes the native program
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g
const PATH_LEADING_TRIM: Record<string, number> = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 }
const PATH_TRAILING_TRIM: Record<string, number> = {
')': 1,
']': 1,
'}': 1,
'"': 1,
"'": 1,
',': 1,
';': 1,
'.': 1
}
export function parsePathLineCol(value: string): TerminalPathCandidate | null {
const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value)
if (!m) {
return null
}
const pathText = m[1]
const last = pathText.charAt(pathText.length - 1)
if (!pathText || last === '/' || last === '\\') {
return null
}
const line = m[2] ? Number.parseInt(m[2], 10) : null
const column = m[3] ? Number.parseInt(m[3], 10) : null
if ((line !== null && line < 1) || (column !== null && column < 1)) {
return null
}
return { pathText: pathText, line: line, column: column }
}
export function trimPathBoundaryPunctuation(
raw: string,
rawStart: number
): TerminalPathRange | null {
let start = 0,
end = raw.length
while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) {
start += 1
}
while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) {
end -= 1
}
if (start >= end) {
return null
}
return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end }
}
export function hasSeparatorAfterWhitespace(text: string) {
let sawWhitespace = false
for (let i = 0; i < text.length; i++) {
const ch = text.charAt(i)
if (/\s/.test(ch)) {
sawWhitespace = true
continue
}
if (sawWhitespace && (ch === '/' || ch === '\\')) {
return true
}
}
return false
}
export function trimSpacedPathTrailingProse(
range: TerminalPathRange,
col?: number
): TerminalPathRange | null {
// A line-end extension token only extends the span when the added segment
// is path-like (contains a separator) — prose must not be swallowed.
let selected: string | null = null
const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g
let match: RegExpExecArray | null
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
const end = match.index + match[0].length
// Why `var`: the document declares this name twice in one function, which is one binding; two
// block-scoped declarations would be a different program and esbuild renames the inner one.
var text = range.text.slice(0, end)
if (countPathStarts(text) > 1) {
continue
}
if (
end < range.text.length ||
selected === null ||
/[\\/]/.test(range.text.slice(selected.length, end))
) {
selected = text
}
}
if (selected) {
if (col !== undefined && col >= range.startIndex + selected.length) {
return null
}
return {
text: selected,
startIndex: range.startIndex,
endIndex: range.startIndex + selected.length
}
}
var text = range.text.replace(/\s+$/, '')
return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length }
}
export function countPathStarts(text: string) {
let count = 0
const pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g
while (pathStartPattern.exec(text) !== null) {
count += 1
}
return count
}
export function hasSpacedPathExtension(text: string) {
const range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length })
if (!range) {
return false
}
const trimmed = range.text.replace(/\s+$/, '')
return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed)
}
export function matchSpacedFilePathAtColumn(lineText: string, col: number) {
SPACED_PATH_RE.lastIndex = 0
let match: RegExpExecArray | null
while ((match = SPACED_PATH_RE.exec(lineText)) !== null) {
const trimmed = trimPathBoundaryPunctuation(match[0], match.index)
if (
!trimmed ||
(!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))
) {
continue
}
const candidate = trimSpacedPathTrailingProse(trimmed, col)
if (!candidate) {
continue
}
if (col < candidate.startIndex || col >= candidate.endIndex) {
continue
}
const parsed = parsePathLineCol(candidate.text)
if (parsed) {
return parsed
}
}
return null
}
export function matchFilePathAtColumn(lineText: string, col: number) {
const spaced = matchSpacedFilePathAtColumn(lineText, col)
if (spaced) {
return spaced
}
FILE_PATH_RE.lastIndex = 0
let match: RegExpExecArray | null
while ((match = FILE_PATH_RE.exec(lineText)) !== null) {
const raw = match[0]
if (raw.length === 0) {
FILE_PATH_RE.lastIndex += 1
continue
}
const trimmed = trimPathBoundaryPunctuation(raw, match.index)
if (!trimmed) {
continue
}
if (col < trimmed.startIndex || col >= trimmed.endIndex) {
continue
}
const parsed = parsePathLineCol(trimmed.text)
if (parsed) {
return parsed
}
}
return null
}
// Returns the path candidate under the tap, or null. Query-only so the tap
// handler can try file detection before forwarding a mouse click — which lets
// file paths open even inside a mouse-tracking TUI. Relies on viewportToCell/
// getLineText from the host script scope.
export function filePathAtViewportPoint(originX: number, originY: number) {
const tapCell = viewportToCell(originX, originY)
if (!tapCell) {
return null
}
// Map the cell column to a string index so wide chars (emoji/CJK) earlier on
// the line don't shift the match column off the tapped path.
return matchFilePathAtColumn(
getLineText(tapCell.row),
cellColToStringIndex(tapCell.row, tapCell.col)
)
}
@@ -0,0 +1,70 @@
import { enqueueWriteBoundary } from './write-queue'
import { notify } from './host-notify'
import { scope, type TerminalDocumentDisposable } from './document-scope'
/**
* The gate deciding when xterm's parser replies may reach the native host.
*
* One unit so the tests exercise the same replay and generation gate the document runs rather than
* a re-implementation of it — which was already the reason this was one injected string.
*/
export type QueryReplyTerminal = {
attachCustomKeyEventHandler: (handler: () => boolean) => void
textarea?: {
readOnly: boolean
tabIndex: number
setAttribute: (name: string, value: string) => void
}
onData: (listener: (data: string) => void) => TerminalDocumentDisposable
}
// Written from four places, all of them here, so it is this module's state rather than the
// document's and stays a local.
let terminalDataRepliesEnabled = false
export function resetTerminalDataReplyAuthority() {
terminalDataRepliesEnabled = false
}
export function resumeTerminalDataReplyAuthority() {
terminalDataRepliesEnabled = true
}
export function forwardTerminalDataReply(data: string) {
if (terminalDataRepliesEnabled) {
notify({ type: 'terminal-data', bytes: data })
}
}
export function enqueueTerminalDataReplyBoundary(gen: number) {
enqueueWriteBoundary(function () {
if (gen === scope.terminalGeneration) {
terminalDataRepliesEnabled = true
}
})
}
export function attachTerminalQueryReplyBridge(term: QueryReplyTerminal, gen: number) {
// Why: parser replies require stdin enabled, but mobile input is owned by
// native controls. Keep xterm's textarea inert for touch/hardware keys.
try {
term.attachCustomKeyEventHandler(function () {
return false
})
if (term.textarea) {
term.textarea.readOnly = true
term.textarea.tabIndex = -1
term.textarea.setAttribute('inputmode', 'none')
}
} catch {}
try {
scope.termObserverDisposables.push(
term.onData(function (data) {
forwardTerminalDataReply(data)
})
)
} catch {}
// Why: live output can queue before initial replay finishes. Enable replies
// at the replay boundary so those live queries are answered, never replayed ones.
enqueueTerminalDataReplyBoundary(gen)
}
+37
View File
@@ -0,0 +1,37 @@
import { applyFitScale } from './fit-scale'
import { isAlternateBufferActive } from './mouse-input-encoding'
import { updateScrollIndicator } from './viewport-transform'
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
import { scope } from './document-scope'
// Why: rewrap the local xterm buffer (scrollback included) to a new width
// after a server PTY reflow. Skip the alternate screen: those snapshots are
// fully repainted by the PTY and a local resize there can drop SGR attributes
// (see init's alt-screen handling), which shows as white text.
export function reflow(cols: number, rows: number) {
if (!scope.term || isAlternateBufferActive()) {
return
}
const nextCols = cols || scope.term.cols
const nextRows = rows || scope.term.rows
if (nextCols === scope.term.cols && nextRows === scope.term.rows) {
return
}
const buffer = scope.term.buffer.active
// Why: anchor reflow on whether the user was pinned to the live bottom so
// their scroll position survives the rewrap — if they were scrolled up,
// hold the same distance from the bottom; if at the bottom, stay there.
const wasAtBottom = buffer.viewportY >= buffer.baseY
const distanceFromBottom = buffer.baseY - buffer.viewportY
scope.initRows = nextRows
scope.term.resize(nextCols, nextRows)
const rewrapped = scope.term.buffer.active
if (wasAtBottom) {
scope.term.scrollToBottom()
} else {
scope.term.scrollLines(rewrapped.baseY - distanceFromBottom - rewrapped.viewportY)
}
applyFitScale('reflow-msg')
updateScrollIndicator(false)
emitKeyboardAvoidanceMetrics()
}
@@ -0,0 +1,23 @@
import { scope } from './document-scope'
/**
* The first declarations inside the document's IIFE.
*
* All eight are read by other parts of the script, so all eight are scope fields; the document
* shell opens the function they live in and `document-close.ts` closes it.
*/
scope.surface = document.getElementById('terminal-surface')
scope.ESC = String.fromCharCode(27)
scope.C1_CSI = String.fromCharCode(155)
scope.CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa)
scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e)
scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f)
scope.CLAUDE_STATUS_DOT_PATTERN = new RegExp(
scope.CLAUDE_STATUS_DOT +
'[' +
scope.TEXT_PRESENTATION_SELECTOR +
scope.EMOJI_PRESENTATION_SELECTOR +
']*',
'g'
)
scope.statusDotPendingSelector = false
@@ -0,0 +1,36 @@
import { scope } from './document-scope'
import { notify } from './host-notify'
import { cancelSelect } from './selection-range'
import { repositionOverlay } from './selection-overlay'
scope.btnCopy!.addEventListener('click', function (e) {
e.preventDefault()
e.stopPropagation()
if (!scope.term) {
return
}
const text = scope.term.getSelection ? scope.term.getSelection() : ''
if (text && text.length > 0) {
notify({ type: 'selection', text: text })
} else {
cancelSelect()
}
})
scope.btnSelAll!.addEventListener('click', function (e) {
e.preventDefault()
e.stopPropagation()
if (!scope.term) {
return
}
try {
scope.term.selectAll()
const b = scope.term.buffer.active
scope.sel = {
anchor: { col: 0, row: 0 },
focus: { col: scope.term.cols - 1, row: b.length - 1 },
activeHandle: null
}
repositionOverlay()
} catch {}
})
@@ -0,0 +1,141 @@
import { cellToViewportPx } from './cell-geometry'
import { scope } from './document-scope'
import { getCellHeight } from './fit-scale'
import { notify } from './host-notify'
import { applyXtermSelection, selRange } from './selection-range'
import { viewportToCell } from './viewport-cell'
import { getTotalScale } from './viewport-transform'
export function repositionOverlay() {
if (scope.selMode !== 'select' || !scope.sel || !scope.term) {
return
}
const r = selRange()!
const sPx = cellToViewportPx(r.start.col, r.start.row)
const ePx = cellToViewportPx(r.end.col + 1, r.end.row)
const cellH = getCellHeight() * getTotalScale()
// Why: native iOS pattern — start handle anchors at the TOP of the
// first selected cell (dot above, stem covers the cell going down);
// end handle anchors at the BOTTOM of the last selected cell (dot
// below, stem covers the cell going up).
scope.handleStart!.style.left = sPx.x + 'px'
scope.handleStart!.style.top = sPx.y + 'px'
scope.handleEnd!.style.left = ePx.x + 'px'
scope.handleEnd!.style.top = ePx.y + cellH + 'px'
const startVisible = sPx.y >= 0 && sPx.y <= window.innerHeight
const endVisible = ePx.y >= 0 && ePx.y <= window.innerHeight
scope.handleStart!.style.visibility = startVisible ? 'visible' : 'hidden'
scope.handleEnd!.style.visibility = endVisible ? 'visible' : 'hidden'
let menuCenterX: number, menuY: number, vTransform: string, marginTop: string
if (startVisible && sPx.y > 56) {
menuCenterX = sPx.x
menuY = sPx.y
vTransform = 'translateY(-100%)'
marginTop = '-12px'
} else if (endVisible && ePx.y + cellH + 56 < window.innerHeight) {
menuCenterX = ePx.x
menuY = ePx.y + cellH
vTransform = 'translateY(0)'
marginTop = '12px'
} else {
// selection covers full viewport — pin to visible center
menuCenterX = window.innerWidth / 2
menuY = window.innerHeight / 2
vTransform = 'translateY(-50%)'
marginTop = '0'
}
// Why: clamp horizontally so the pill stays fully visible when the
// selection sits near a screen edge. We position via plain left
// (no horizontal translate) so the clamp math is straightforward.
scope.selMenu!.style.transform = vTransform
scope.selMenu!.style.marginTop = marginTop
scope.selMenu!.style.top = menuY + 'px'
scope.selMenu!.style.left = '0px'
const EDGE_MARGIN = 8
const menuW = scope.selMenu!.offsetWidth || 0
const minLeft = EDGE_MARGIN
const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - menuW - EDGE_MARGIN)
const desiredLeft = menuCenterX - menuW / 2
const clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft))
scope.selMenu!.style.left = clampedLeft + 'px'
}
export function syncSelectionHandleToViewportPoint(
handle: string,
clientX: number,
clientY: number
) {
const c = viewportToCell(clientX, clientY)
if (!c || !scope.sel) {
return false
}
if (handle === 'start') {
scope.sel.anchor = c
} else {
scope.sel.focus = c
}
applyXtermSelection()
return true
}
export function syncEdgeScrollSelectionEndpoint() {
if (!scope.sel || !scope.sel.activeHandle) {
return false
}
// Why: WebView may not emit new touchmove events while a handle is held
// at the edge; resample the stored finger point after each viewport scroll.
return syncSelectionHandleToViewportPoint(
scope.sel.activeHandle,
scope.edgeScrollClientX,
scope.edgeScrollClientY
)
}
export function startEdgeScroll(dir: number) {
if (scope.edgeScrollDir === dir) {
return
}
stopEdgeScroll()
scope.edgeScrollDir = dir
scope.edgeScrollTimer = setInterval(function () {
if (!scope.term || scope.edgeScrollDir === 0) {
return
}
const beforeY = scope.term.buffer.active.viewportY
scope.term.scrollLines(scope.edgeScrollDir)
const afterY = scope.term.buffer.active.viewportY
if (beforeY === afterY) {
notify({ type: 'haptic', kind: 'edge-bump' })
stopEdgeScroll()
return
}
syncEdgeScrollSelectionEndpoint()
repositionOverlay()
}, scope.EDGE_SCROLL_INTERVAL)
}
export function stopEdgeScroll() {
if (scope.edgeScrollTimer) {
clearInterval(scope.edgeScrollTimer)
scope.edgeScrollTimer = null
}
scope.edgeScrollDir = 0
}
export function handleDragMove(handle: string, clientX: number, clientY: number) {
scope.edgeScrollClientX = clientX
scope.edgeScrollClientY = clientY
if (!syncSelectionHandleToViewportPoint(handle, clientX, clientY)) {
return
}
repositionOverlay()
if (clientY < scope.EDGE_SCROLL_PX) {
startEdgeScroll(-1)
} else if (clientY > window.innerHeight - scope.EDGE_SCROLL_PX) {
startEdgeScroll(1)
} else {
stopEdgeScroll()
}
}
// Latching document-level touch dispatcher: see tap-dispatch.ts.
@@ -0,0 +1,115 @@
import { getLineText } from './cell-geometry'
import { scope, type TerminalDocumentSelection } from './document-scope'
import { notify } from './host-notify'
import { repositionOverlay, stopEdgeScroll } from './selection-overlay'
/** The ordered ends of the selection, whichever way the user dragged it. */
export type TerminalSelectionRange = {
start: TerminalDocumentSelection['anchor']
end: TerminalDocumentSelection['anchor']
}
export function seedWordSelection(col: number, absRow: number) {
const line = getLineText(absRow)
if (!line) {
scope.sel = {
anchor: { col: col, row: absRow },
focus: { col: col, row: absRow },
activeHandle: null
}
applyXtermSelection()
return
}
let s = col
let e = col
if (col >= 0 && col < line.length && scope.WORD_RE.test(line[col])) {
while (s > 0 && scope.WORD_RE.test(line[s - 1])) {
s--
}
while (e < line.length - 1 && scope.WORD_RE.test(line[e + 1])) {
e++
}
}
scope.sel = {
anchor: { col: s, row: absRow },
focus: { col: e, row: absRow },
activeHandle: null
}
applyXtermSelection()
}
export function isStartFirst(
a: TerminalDocumentSelection['anchor'],
b: TerminalDocumentSelection['anchor']
) {
if (a.row !== b.row) {
return a.row < b.row
}
return a.col <= b.col
}
export function selRange(): TerminalSelectionRange | null {
if (!scope.sel) {
return null
}
if (isStartFirst(scope.sel.anchor, scope.sel.focus)) {
return { start: scope.sel.anchor, end: scope.sel.focus }
}
return { start: scope.sel.focus, end: scope.sel.anchor }
}
export function applyXtermSelection() {
if (!scope.term || !scope.sel) {
return
}
const r = selRange()
if (!r) {
return
}
// Why: term.select(col, row, length) takes a buffer-absolute row,
// not a viewport-relative one. Subtracting viewportY here drifts the
// selection by the scrollback height — handles render where the user
// pressed (their math is independent), but xterm highlights an
// off-screen scrollback region and copies the wrong text.
let length: number
if (r.start.row === r.end.row) {
length = Math.max(1, r.end.col - r.start.col + 1)
} else {
const first = scope.term.cols - r.start.col
const middle = Math.max(0, r.end.row - r.start.row - 1) * scope.term.cols
const last = r.end.col + 1
length = first + middle + last
}
try {
scope.term.select(r.start.col, r.start.row, length)
} catch {}
}
export function cancelSelect() {
scope.selMode = 'navigate'
scope.sel = null
stopEdgeScroll()
if (scope.term) {
try {
scope.term.clearSelection()
} catch {}
// Why: some xterm renderers cache cells and skip repaint on
// clearSelection alone, leaving the previously-highlighted cells
// visually selected. Force a full refresh so the selection layer
// actually clears on screen.
try {
scope.term.refresh(0, scope.term.rows - 1)
} catch {}
}
scope.selectionOverlay!.classList.remove('active')
notify({ type: 'set-select-mode', enabled: false })
}
export function enterSelect(col: number, absRow: number) {
scope.selMode = 'select'
seedWordSelection(col, absRow)
scope.selectionOverlay!.classList.add('active')
notify({ type: 'set-select-mode', enabled: true })
notify({ type: 'haptic', kind: 'selection' })
repositionOverlay()
}
@@ -0,0 +1,82 @@
import { repositionOverlay } from './selection-overlay'
import { cancelSelect } from './selection-range'
import { notify } from './host-notify'
import { scope } from './document-scope'
// ============================================================
// SELECTION MODE (long-press → handles → Copy)
// ============================================================
scope.WORD_RE = /[\p{L}\p{N}_./:@~+=?&#%-]/u
scope.LONG_PRESS_MS = 500
scope.LONG_PRESS_SLOP = 10
// Why: a tap that opens a link/path must survive small finger jitter. The
// long-press slop (10px) only cancels the press-to-select timer; reusing it
// to gate the tap dropped any URL/file tap that wandered >10px — at fit scale
// a few screen px of jitter is a normal tap. Use a wider, time-bounded tap
// window so deliberate scrolls/pans still don't fire a tap.
scope.TAP_SLOP = 24
scope.TAP_MAX_MS = 700
scope.EDGE_SCROLL_PX = 40
scope.EDGE_SCROLL_INTERVAL = 60
scope.selectionOverlay = document.getElementById('selection-overlay')
scope.handleStart = document.getElementById('sel-handle-start')
scope.handleEnd = document.getElementById('sel-handle-end')
scope.selMenu = document.getElementById('sel-menu')
scope.btnCopy = document.getElementById('sel-menu-copy')
scope.btnSelAll = document.getElementById('sel-menu-all')
// mode: 'navigate' | 'select'
scope.selMode = 'navigate'
scope.sel = null // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' }
scope.longPressTimer = null
scope.longPressOrigin = null // {x,y, identifier}
// Why: tap detection is tracked separately from the long-press timer so a
// small jitter that cancels the press-to-select timer does not also cancel
// the tap (which opens links/paths). {x,y,t,identifier} or null once the
// gesture is disqualified as a tap (moved too far or held too long).
scope.tapCandidate = null
scope.edgeScrollTimer = null
scope.edgeScrollDir = 0
scope.edgeScrollClientX = 0
scope.edgeScrollClientY = 0
// Eviction watchdog: linesEverWritten counts onLineFeed since last init.
// Once buffer is full, every onLineFeed evicts the top row in xterm and
// we mirror that by decrementing stored absolute rows.
let linesEverWritten = 0
export function resetEvictionCounter() {
linesEverWritten = 0
}
export function isBufferFull() {
if (!scope.term) {
return false
}
return linesEverWritten >= 5000 + (scope.term.rows || 0)
}
export function checkEviction() {
if (scope.selMode !== 'select' || !scope.sel) {
return
}
const oldest = Math.min(scope.sel.anchor.row, scope.sel.focus.row)
if (oldest < 0) {
notify({ type: 'selection-evicted' })
cancelSelect()
}
}
export function logFeedAndEvict() {
linesEverWritten++
if (scope.initialOscLinkEvictionReady && isBufferFull()) {
scope.initialOscLinkRowOffset += 1
}
if (scope.selMode === 'select' && scope.sel && isBufferFull()) {
scope.sel.anchor.row -= 1
scope.sel.focus.row -= 1
checkEviction()
repositionOverlay()
}
}
@@ -0,0 +1,69 @@
import { disposeTermObservers } from './write-queue'
import { attachSurfaceEventHandlers } from './surface-touch-gestures'
import { scope, type TerminalDocumentTerminal } from './document-scope'
/** The surfaces and terminal a swap is replacing, handed back to whoever commits it. */
export type TerminalSurfaceSwap = {
oldTerm: TerminalDocumentTerminal | null
oldSurface: HTMLElement | null
nextSurface: HTMLElement
}
// Why: phone-fit startup can issue several init() calls before xterm finishes
// replaying. Track the last painted surface separately from its replacement.
let committedTerm: TerminalDocumentTerminal | null = null
let committedSurface = scope.surface
scope.pendingTerm = null
let pendingSurface: HTMLElement | null = null
export function beginTerminalSurfaceSwap() {
// Why: a superseded hidden replacement must not remain between the last
// painted surface and the newest one, or the newest commits below the viewport.
if (pendingSurface) {
try {
pendingSurface.remove()
} catch {}
if (scope.pendingTerm) {
try {
scope.pendingTerm.dispose()
} catch {}
}
pendingSurface = null
scope.pendingTerm = null
}
const swap = {
oldTerm: committedTerm,
oldSurface: committedSurface,
nextSurface: document.createElement('div')
}
disposeTermObservers()
swap.nextSurface.id = 'terminal-surface'
swap.nextSurface.style.visibility = 'hidden'
swap.nextSurface.style.position = 'absolute'
swap.nextSurface.style.left = '0'
swap.nextSurface.style.top = '0'
document.getElementById('terminal-container')!.appendChild(swap.nextSurface)
scope.surface = swap.nextSurface
pendingSurface = swap.nextSurface
attachSurfaceEventHandlers(scope.surface)
swap.oldSurface!.removeAttribute('id')
return swap
}
export function commitTerminalSurfaceSwap(
swap: TerminalSurfaceSwap,
nextTerm: TerminalDocumentTerminal
) {
swap.nextSurface.style.visibility = 'visible'
swap.nextSurface.style.position = ''
swap.nextSurface.style.left = ''
swap.nextSurface.style.top = ''
swap.oldSurface!.remove()
if (swap.oldTerm) {
swap.oldTerm.dispose()
}
committedTerm = nextTerm
committedSurface = swap.nextSurface
scope.pendingTerm = null
pendingSurface = null
}
@@ -0,0 +1,59 @@
import { notify } from './host-notify'
import {
buildMouseClickInput,
getMouseTrackingMode,
isClickMouseTrackingMode
} from './mouse-input-encoding'
import { oscLinkAtViewportPoint, resolveTerminalFileUrlTap } from './osc-link-tap'
import { filePathAtViewportPoint } from './path-tap'
import { fileUrlAtViewportPoint, urlAtViewportPoint } from './url-tap'
export function notifyTerminalSurfaceTap(originX: number, originY: number, focusKeyboard: boolean) {
const tappedOscLink = oscLinkAtViewportPoint(originX, originY)
if (tappedOscLink && tappedOscLink.kind === 'file') {
notify({
type: 'terminal-file-tap',
pathText: tappedOscLink.fileTap.pathText,
line: tappedOscLink.fileTap.line,
column: tappedOscLink.fileTap.column
})
return
}
const tappedFileUrl = fileUrlAtViewportPoint(originX, originY)
const tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null
if (tappedFileUrlPath) {
notify({
type: 'terminal-file-tap',
pathText: tappedFileUrlPath.pathText,
line: tappedFileUrlPath.line,
column: tappedFileUrlPath.column
})
return
}
const tappedUrl =
tappedOscLink && tappedOscLink.kind === 'url'
? tappedOscLink.url
: urlAtViewportPoint(originX, originY)
if (tappedUrl) {
notify({ type: 'open-url', url: tappedUrl })
return
}
const tappedPath = filePathAtViewportPoint(originX, originY)
if (tappedPath) {
notify({
type: 'terminal-file-tap',
pathText: tappedPath.pathText,
line: tappedPath.line,
column: tappedPath.column
})
return
}
const clickInput = buildMouseClickInput(originX, originY)
if (clickInput) {
notify({ type: 'terminal-input', bytes: clickInput })
}
// Touch still needs native input focus after the TUI consumes its mouse click.
if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) {
notify({ type: 'terminal-tap' })
}
}
@@ -0,0 +1,276 @@
import { scope } from './document-scope'
import { clampPan, getCellHeight } from './fit-scale'
import { notify } from './host-notify'
import { attachSurfaceMouseClickDragHandler } from './mouse-click-drag'
import { routeScrollLines, shouldRouteScrollToTerminalInput } from './mouse-input-encoding'
import {
applyNormalBufferScrollDelta,
enqueueNormalBufferScrollDelta,
resetSmoothScrollOffset
} from './normal-buffer-smooth-scroll'
import { dispatcherShouldBlockSurface } from './tap-dispatch'
import { applyTextScale, snapToTextScalePreset } from './text-scaling'
import { getTotalScale, updateTransform } from './viewport-transform'
import { attachSurfaceWheelHandler } from './wheel-scroll'
/** A surface that has already been wired, so a re-mount does not stack handlers. */
type TerminalGestureSurface = HTMLElement & { __orcaSurfaceHandlersAttached?: boolean }
/** The live touch gesture: the last point, the velocity, and the pinch it may be in. */
type TerminalTouchState = {
lastX: number
lastY: number
lastTime: number
velY: number
accumDelta: number
momentumId: number | null
isPinching: boolean
pinchDist: number
pinchScale: number
pinchSurfX: number
pinchSurfY: number
}
const ts: TerminalTouchState = {
lastX: 0,
lastY: 0,
lastTime: 0,
velY: 0,
accumDelta: 0,
momentumId: null,
isPinching: false,
pinchDist: 0,
pinchScale: 0,
pinchSurfX: 0,
pinchSurfY: 0
}
export function updateTouchVelocity(deltaY: number, dt: number) {
if (dt <= 0) {
return
}
const instantVelocity = deltaY / dt
if (!Number.isFinite(instantVelocity)) {
return
}
// Why: touchmove cadence is uneven in WebView. Blend recent samples so
// momentum launch doesn't inherit a one-frame spike or stall.
ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45
}
export function getDistance(a: Touch, b: Touch) {
const dx = a.clientX - b.clientX,
dy = a.clientY - b.clientY
return Math.sqrt(dx * dx + dy * dy)
}
export function attachSurfaceEventHandlers(targetSurface: TerminalGestureSurface) {
if (!targetSurface || targetSurface.__orcaSurfaceHandlersAttached) {
return
}
targetSurface.__orcaSurfaceHandlersAttached = true
// Why: init() swaps in a new hidden surface to avoid flicker; each
// replacement needs gesture handlers or tab-switch replays stop scrolling.
targetSurface.addEventListener(
'mousedown',
function (e) {
e.preventDefault()
e.stopPropagation()
},
true
)
targetSurface.addEventListener(
'click',
function (e) {
e.preventDefault()
e.stopPropagation()
},
true
)
attachSurfaceWheelHandler(targetSurface)
attachSurfaceMouseClickDragHandler(targetSurface)
targetSurface.addEventListener(
'touchstart',
function (e) {
if (dispatcherShouldBlockSurface()) {
return
}
if (ts.momentumId) {
cancelAnimationFrame(ts.momentumId)
ts.momentumId = null
}
if (e.touches.length === 2) {
ts.isPinching = true
scope.smoothScrollOffsetY = 0
ts.pinchDist = getDistance(e.touches[0], e.touches[1])
ts.pinchScale = scope.userScale
const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2
const my = (e.touches[0].clientY + e.touches[1].clientY) / 2
const total = getTotalScale()
ts.pinchSurfX = (mx - scope.panX) / total
ts.pinchSurfY = (my - scope.panY) / total
} else if (e.touches.length === 1) {
ts.isPinching = false
ts.lastX = e.touches[0].clientX
ts.lastY = e.touches[0].clientY
ts.lastTime = Date.now()
ts.velY = 0
ts.accumDelta = 0
}
},
{ capture: true, passive: true }
)
targetSurface.addEventListener(
'touchmove',
function (e) {
if (dispatcherShouldBlockSurface()) {
return
}
if (!scope.term) {
return
}
e.preventDefault()
e.stopPropagation()
if (e.touches.length === 2) {
ts.isPinching = true
const dist = getDistance(e.touches[0], e.touches[1])
const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2
const my = (e.touches[0].clientY + e.touches[1].clientY) / 2
const ratio = dist / ts.pinchDist
// Why: userScale is a CSS multiplier on the current font size; bound it so
// the resulting apparent size (currentTextScale × userScale) stays within
// the preset range, since release snaps to one of those presets.
const loScale = scope.MIN_TEXT_SCALE / scope.currentTextScale
const hiScale = scope.MAX_TEXT_SCALE / scope.currentTextScale
scope.userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio))
const total = getTotalScale()
scope.panX = mx - ts.pinchSurfX * total
scope.panY = my - ts.pinchSurfY * total
clampPan()
updateTransform()
} else if (e.touches.length === 1 && !ts.isPinching) {
const x = e.touches[0].clientX,
y = e.touches[0].clientY
const now = Date.now(),
dt = now - ts.lastTime
// Why: pan horizontally only when content overflows the viewport (larger
// than fit) — same check clampPan() uses. Vertical always drives buffer
// scroll so scrollback stays reachable at any text size; calling the
// never-defined contentWiderThanViewport() here threw and killed all
// single-finger scrolling, scrollback included.
if (
scope.term.element &&
scope.term.element.scrollWidth * getTotalScale() > window.innerWidth + 1
) {
scope.panX += x - ts.lastX
clampPan()
updateTransform()
}
const deltaY = ts.lastY - y
ts.lastTime = now
if (shouldRouteScrollToTerminalInput()) {
updateTouchVelocity(deltaY, dt)
resetSmoothScrollOffset()
const effectiveCellH = getCellHeight() * getTotalScale()
ts.accumDelta += deltaY
const lines = Math.trunc(ts.accumDelta / effectiveCellH)
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH
routeScrollLines(lines, x, y)
}
} else {
if (enqueueNormalBufferScrollDelta(deltaY)) {
updateTouchVelocity(deltaY, dt)
} else {
ts.velY = 0
}
}
ts.lastX = x
ts.lastY = y
}
},
{ capture: true, passive: false }
)
targetSurface.addEventListener(
'touchend',
function (e) {
if (dispatcherShouldBlockSurface()) {
return
}
if (!scope.term) {
return
}
if (ts.isPinching && e.touches.length < 2) {
ts.isPinching = false
// Why: a finished pinch snaps to the nearest preset and becomes the new
// font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way
// to set the text size. The CSS pinch zoom (userScale) is reset; the real
// size change reflows columns and RN persists + resizes the PTY to match.
const target = snapToTextScalePreset(scope.currentTextScale * scope.userScale)
const changed = target !== scope.currentTextScale
scope.userScale = 1
scope.panX = 0
scope.panY = 0
applyTextScale(target)
updateTransform()
notify({ type: 'font-scale-changed', fontScale: target })
if (changed) {
notify({ type: 'haptic', kind: 'selection' })
}
if (e.touches.length === 1) {
ts.lastX = e.touches[0].clientX
ts.lastY = e.touches[0].clientY
ts.lastTime = Date.now()
ts.velY = 0
ts.accumDelta = 0
}
return
}
if (e.touches.length === 0) {
let vel = ts.velY
const FRICTION = 0.972
const MIN_VEL = 0.012
function momentumStep() {
vel *= FRICTION
if (Math.abs(vel) < MIN_VEL) {
ts.momentumId = null
return
}
const delta = vel * 16
if (shouldRouteScrollToTerminalInput()) {
resetSmoothScrollOffset()
const effectiveCellH = getCellHeight() * getTotalScale()
ts.accumDelta += delta
const lines = Math.trunc(ts.accumDelta / effectiveCellH)
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH
routeScrollLines(lines, ts.lastX, ts.lastY)
}
} else {
if (!applyNormalBufferScrollDelta(delta)) {
ts.momentumId = null
return
}
}
ts.momentumId = requestAnimationFrame(momentumStep)
}
if (Math.abs(vel) > MIN_VEL) {
ts.momentumId = requestAnimationFrame(momentumStep)
}
}
},
{ capture: true, passive: true }
)
}
attachSurfaceEventHandlers(scope.surface!)
@@ -0,0 +1,248 @@
import { handleDragMove, stopEdgeScroll } from './selection-overlay'
import { cancelSelect, enterSelect } from './selection-range'
import { notify } from './host-notify'
import { viewportToCell } from './viewport-cell'
import { scope } from './document-scope'
import { notifyTerminalSurfaceTap } from './surface-tap'
// ============================================================
// LATCHING TOUCH DISPATCHER (document-level)
// ============================================================
/** What the dispatcher has latched onto, and the fingers it is tracking. */
export type TerminalTouchDispatch = {
mode: string
touchId: number | null
touchIds: number[] | null
longPressFingerInsideOverlay: boolean
}
/** An element a target can be tested against; a method so a real element satisfies it. */
type TerminalDocumentTargetContainer = { contains(other: EventTarget | null): boolean }
const dispatch: TerminalTouchDispatch = {
mode: 'idle',
touchId: null,
touchIds: null,
longPressFingerInsideOverlay: false
}
export function touchById(touches: TouchList, id: number | null) {
for (let i = 0; i < touches.length; i++) {
if (touches[i].identifier === id) {
return touches[i]
}
}
return null
}
export function targetInside(
target: EventTarget | null,
el: TerminalDocumentTargetContainer | null
) {
if (!target || !el) {
return false
}
return el.contains(target)
}
export function clearLongPress() {
if (scope.longPressTimer) {
clearTimeout(scope.longPressTimer)
scope.longPressTimer = null
}
scope.longPressOrigin = null
}
export function armLongPress(touch: Touch) {
scope.longPressOrigin = { x: touch.clientX, y: touch.clientY, identifier: touch.identifier }
scope.longPressTimer = setTimeout(function () {
scope.longPressTimer = null
if (!scope.longPressOrigin) {
return
}
const c = viewportToCell(scope.longPressOrigin.x, scope.longPressOrigin.y)
if (!c) {
return
}
enterSelect(c.col, c.row)
}, scope.LONG_PRESS_MS)
}
export function touchSlopExceeded(t: Touch) {
if (!scope.longPressOrigin) {
return false
}
const dx = Math.abs(t.clientX - scope.longPressOrigin.x)
const dy = Math.abs(t.clientY - scope.longPressOrigin.y)
return dx + dy > scope.LONG_PRESS_SLOP
}
// Why: existing surface handlers stay attached to surface but we wrap
// their entry to no-op when the dispatcher latches into select-drag.
export function dispatcherShouldBlockSurface() {
return dispatch.mode === 'select-drag'
}
document.addEventListener(
'touchstart',
function (e) {
const t = e.touches[0]
const target = e.target
const onHandle = target === scope.handleStart || target === scope.handleEnd
const inOverlay = targetInside(target, scope.selectionOverlay)
const inSurface = targetInside(target, scope.surface)
// Why: clear any stale tap candidate up front; only a fresh single-finger
// surface touch (below) re-arms it, so handle drags / pinches / dismiss
// taps never resolve as a link tap on touchend.
scope.tapCandidate = null
if (e.touches.length === 2) {
// pinch latch
if (scope.selMode === 'select') {
notify({ type: 'mobile-clip-cancel-by-pinch' })
cancelSelect()
}
dispatch.mode = 'pinch'
dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier]
clearLongPress()
return
}
if (onHandle && scope.selMode === 'select') {
// start handle drag
const handleName = target === scope.handleStart ? 'start' : 'end'
scope.sel!.activeHandle = handleName
dispatch.mode = 'select-drag'
dispatch.touchId = t.identifier
e.preventDefault()
return
}
if (inOverlay) {
// tap on menu pill — let the buttons' own handlers fire
return
}
if (inSurface && scope.selMode === 'select') {
// Why: tap-to-dismiss matches native iOS/Android — touching outside the
// selection clears it. We cancel immediately and latch to 'surface' so
// the same gesture still drives scroll/pan without a second touch.
cancelSelect()
dispatch.mode = 'surface'
dispatch.touchId = t.identifier
return
}
if (inSurface) {
dispatch.mode = 'surface'
dispatch.touchId = t.identifier
scope.tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier }
armLongPress(t)
}
},
{ capture: true, passive: false }
)
document.addEventListener(
'touchmove',
function (e) {
if (dispatch.mode === 'select-drag') {
const t = touchById(e.touches, dispatch.touchId)
if (!t || !scope.sel || !scope.sel.activeHandle) {
return
}
e.preventDefault()
handleDragMove(scope.sel.activeHandle, t.clientX, t.clientY)
return
}
if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') {
// long-press slop check
if (scope.longPressTimer && e.touches.length === 1) {
if (touchSlopExceeded(e.touches[0])) {
clearLongPress()
}
}
// Why: disqualify the tap only once the finger travels past TAP_SLOP
// (a scroll/pan), independent of the long-press timer — so a tap that
// jitters under TAP_SLOP still opens the link/path under the finger.
if (scope.tapCandidate && e.touches.length === 1) {
const mt = e.touches[0]
if (mt.identifier === scope.tapCandidate.identifier) {
const dx = Math.abs(mt.clientX - scope.tapCandidate.x)
const dy = Math.abs(mt.clientY - scope.tapCandidate.y)
if (dx + dy > scope.TAP_SLOP) {
scope.tapCandidate = null
}
}
} else if (e.touches.length !== 1) {
scope.tapCandidate = null
}
// existing surface handler will run from its own listener
}
},
{ capture: true, passive: false }
)
document.addEventListener(
'touchend',
function (e) {
if (dispatch.mode === 'select-drag') {
if (scope.sel) {
scope.sel.activeHandle = null
}
stopEdgeScroll()
dispatch.mode = 'idle'
dispatch.touchId = null
return
}
if (dispatch.mode === 'pinch') {
if (e.touches.length < 2) {
dispatch.mode = e.touches.length === 1 ? 'surface' : 'idle'
dispatch.touchIds = null
if (e.touches.length === 1) {
dispatch.touchId = e.touches[0].identifier
}
}
return
}
if (dispatch.mode === 'surface') {
// Why: fire the tap from the tap-candidate origin (survives jitter under
// TAP_SLOP) rather than longPressOrigin, which the press-to-select slop
// can null mid-tap — that was dropping URL/file taps that moved a few px.
if (
e.touches.length === 0 &&
scope.tapCandidate &&
scope.selMode !== 'select' &&
Date.now() - scope.tapCandidate.t <= scope.TAP_MAX_MS
) {
notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true)
}
clearLongPress()
scope.tapCandidate = null
if (e.touches.length === 0) {
dispatch.mode = 'idle'
dispatch.touchId = null
}
}
},
{ capture: true, passive: true }
)
document.addEventListener(
'touchcancel',
function () {
clearLongPress()
scope.tapCandidate = null
stopEdgeScroll()
if (dispatch.mode === 'select-drag') {
if (scope.sel) {
scope.sel.activeHandle = null
}
}
dispatch.mode = 'idle'
dispatch.touchId = null
dispatch.touchIds = null
},
{ capture: true, passive: true }
)
@@ -0,0 +1,40 @@
import { afterWritesDrained, disposeTermObservers } from './write-queue'
import { updateScrollIndicator } from './viewport-transform'
import { scope } from './document-scope'
import { logFeedAndEvict } from './selection-state-and-eviction'
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
import { emitModesIfChanged } from './mode-mirroring'
export function attachTermObservers() {
if (!scope.term) {
return
}
disposeTermObservers()
try {
scope.termObserverDisposables.push(scope.term.onLineFeed!(logFeedAndEvict))
} catch {}
try {
scope.termObserverDisposables.push(
scope.term.onScroll!(function () {
updateScrollIndicator(false)
})
)
} catch {}
// Why: emit modes on every parsed write so RN's mirror stays current
// without round-trip; covers \x1b[?2004h/l and alt-screen toggles.
try {
if (scope.term.onWriteParsed) {
scope.termObserverDisposables.push(
scope.term.onWriteParsed(function () {
emitModesIfChanged()
emitKeyboardAvoidanceMetrics()
})
)
}
} catch {}
// Initial emit once buffer settles.
afterWritesDrained(function () {
emitModesIfChanged()
emitKeyboardAvoidanceMetrics()
})
}
@@ -0,0 +1,391 @@
import {
describeToken,
readScriptTokens,
type DocumentToken
} from './terminal-document-tokens.test-support'
/**
* Whether two versions of the in-WebView document script are the same program, allowing only the
* scope qualifier that moving it into modules requires.
*
* C7.1 turns the document's one 2,758-line IIFE into modules the web page can import. A variable
* the script assigns across what became a module boundary cannot stay a free variable — assigning
* an imported binding is a syntax error — so those become fields of one scope object, 73
* declaration sites in all, and every read and write of them gains a qualifier. Nothing else about
* the program may change.
*
* Byte comparison cannot make that claim once the source is formatter-owned: `oxfmt` writes the
* repository's style, which drops the semicolons the hand-written document carries, so the emitted
* text necessarily differs on almost every line for reasons that are not the refactor. Tokens are
* the level where the claim is exactly true. Semicolons are excluded for the same reason they moved
* — they are the formatter's, not the program's — and comments never reach the stream.
*
* This is deliberately stricter than "it still runs": a reordered statement, a changed literal, a
* dropped `!`, a renamed local, all diverge here and are reported with the token index and both
* sides, so the flip commit is reviewed by running this rather than by reading a 515-line diff.
*/
/**
* The differences moving the script into modules is allowed to make, each counted on its own.
*
* Eight classes and no others. Six are the repository's own rules and the printer rewriting the
* document's ES5 style the moment its source is a linted module — measured over the whole script,
* not assumed: `curly` braces 279 brace-less bodies, `no-unused-vars` unbinds 36 catch clauses, 373
* `var` declarators become `const` or `let`, `unicorn/prefer-number-properties` moves 17 globals
* onto `Number`, the printer spells out 4 shorthand properties whose value gained a qualifier, and
* it stops renaming 7 bindings that are no longer shadows. The other two are the move itself: 609
* qualified references and 73 declarations onto the scope. Semicolons and whitespace are the
* formatter's and never reach the token stream at all.
*
* Counted separately because the flip commit pins each number: a total would let one class absorb
* another, which is exactly the drift the pin exists to catch.
*/
export type TerminalDocumentNormalisations = {
/** `name` became `<qualifier>.name`; the declaration stayed where it was. */
readonly qualifiedReferences: number
/**
* `var name` became `<qualifier>.name`; the declaration moved onto the scope object. A `var`
* with several declarators counts once per declarator, because each becomes its own assignment.
*/
readonly scopeFieldDeclarations: number
/** `var` became `const` or `let`, the binding staying local to the emitted script. */
readonly rebindings: number
/** A brace-less `if`/`else`/`for`/`while` body gained its braces. */
readonly bracedBodies: number
/** `catch (e)` became `catch`, the unused binding dropped. */
readonly unboundCatches: number
/** A global numeric function became its `Number` property. */
readonly numberProperties: number
/** `{ name: name }` was shorthand; qualifying the value spells the property out again. */
readonly shorthandProperties: number
/**
* An inner binding that shadowed a document variable stopped being a shadow once that variable
* moved onto the scope, so the printer stopped renaming it.
*/
readonly unshadowedNames: number
}
/**
* The bindings the printer renamed on the baseline and leaves alone in the modules, listed.
*
* A parameter named for a document variable shadowed it while both lived in one function scope, so
* the printer gave the inner one a decimal suffix; once the outer name is a scope field there is no
* shadow and the inner one keeps its own name. Listed rather than matched by shape: a rule that
* accepted any `name2` facing `name` would also accept an unrelated rename that happens to end in a
* digit, which is a changed program, not a normalisation.
*
* One entry covers all seven sites the whole script has: the `term` parameter of
* `attachTerminalQueryReplyBridge` in `query-reply.ts` and its six uses.
*/
const UNSHADOWED_RENAMES: readonly {
readonly baseline: string
readonly generated: string
readonly module: string
}[] = [{ baseline: 'term2', generated: 'term', module: 'query-reply' }]
/** Whether this exact baseline-to-generated pair is one of the listed unshadowed renames. */
function isListedUnshadowedRename(baseline: string, generated: string): boolean {
return UNSHADOWED_RENAMES.some(
(entry) => entry.baseline === baseline && entry.generated === generated
)
}
/**
* The globals `unicorn/prefer-number-properties` moves onto `Number`.
*
* Measured over the whole script: seventeen sites, and the rule is the only one of its kind that
* appears often enough to be worth matching. Each is equivalent here because every call is already
* behind a `typeof … === 'number'` check or is parsing a string, which is what the `Number` form
* does with no coercion of its own.
*/
const NUMBER_GLOBALS = new Set(['isFinite', 'isNaN', 'parseInt', 'parseFloat'])
export type TerminalDocumentEquivalence =
| { readonly equivalent: true; readonly normalisations: TerminalDocumentNormalisations }
| { readonly equivalent: false; readonly reason: string }
/**
* `baseline` is the script as it stood before the move, `candidate` the one the modules generate.
*
* The qualifier is read from `qualifier`, not assumed, so the test names the object it expects and
* a rename cannot quietly satisfy this.
*/
/** The statement heads `curly` braces: everything whose body may be a single unbraced statement. */
const BRACEABLE_HEAD_KEYWORDS = new Set(['if', 'for', 'while'])
/**
* Whether the `{` at `open` is the body of a braceable head rather than some other block.
*
* `else` and `do` are followed by their body directly. The rest put a parenthesised head first, so
* the `)` is walked back to its `(` and the keyword before that is what decides. Without this a
* bare block anywhere in the generated script would be absorbed as a linter-added body, when it is
* a statement the baseline does not have.
*/
function isBraceableHeadBody(tokens: readonly DocumentToken[], open: number): boolean {
const previous = tokens[open - 1]
if (previous === undefined) {
return false
}
if (previous.label === 'else' || previous.label === 'do') {
return true
}
if (previous.label !== ')') {
return false
}
let depth = 0
for (let i = open - 1; i >= 0; i--) {
const label = tokens[i]?.label
if (label === ')') {
depth += 1
continue
}
if (label === '(') {
depth -= 1
if (depth === 0) {
return BRACEABLE_HEAD_KEYWORDS.has(tokens[i - 1]?.label ?? '')
}
}
}
return false
}
/** The index of the `}` closing the `{` at `open`, or -1 when the generated script has none. */
function matchingCloseIndex(tokens: readonly DocumentToken[], open: number): number {
let depth = 0
for (let i = open; i < tokens.length; i++) {
const label = tokens[i]?.label
if (label === '{') {
depth += 1
continue
}
if (label === '}') {
depth -= 1
if (depth === 0) {
return i
}
}
}
return -1
}
export function compareTerminalDocumentScripts(
baseline: string,
candidate: string,
qualifier: string
): TerminalDocumentEquivalence {
const baselineTokens = readScriptTokens(baseline, 'the baseline')
if (!baselineTokens.ok) {
return { equivalent: false, reason: baselineTokens.reason }
}
const candidateTokens = readScriptTokens(candidate, 'the generated script')
if (!candidateTokens.ok) {
return { equivalent: false, reason: candidateTokens.reason }
}
const before = baselineTokens.tokens
const after = candidateTokens.tokens
let qualifiedReferences = 0
let scopeFieldDeclarations = 0
let rebindings = 0
let bracedBodies = 0
let unboundCatches = 0
let numberProperties = 0
let shorthandProperties = 0
let unshadowedNames = 0
// The generated index each inserted `{` expects its `}` at, innermost last. Recording the index
// rather than counting means an absorbed close is the one that closes that body and no other.
const insertedBraceCloses: number[] = []
let lastMatched: DocumentToken | undefined
let left = 0
let right = 0
while (left < before.length && right < after.length) {
const expected = before[left]
const actual = after[right]
// Ahead of the equality check on purpose: the baseline's next token is a `}` too wherever a
// braced body ends a block, and this index is known to close the inserted body, so matching
// them as a pair would consume the wrong one and leave the counts right for the wrong reason.
if (actual.label === '}' && insertedBraceCloses.at(-1) === right) {
insertedBraceCloses.pop()
right += 1
continue
}
if (expected.label === actual.label && expected.text === actual.text) {
lastMatched = expected
left += 1
right += 1
continue
}
// `term2` -> `term`: the printer disambiguated a shadowed binding on the baseline side, and
// qualifying the outer name removed the shadow, so the inner one keeps its own name.
if (
expected.label === 'name' &&
actual.label === 'name' &&
isListedUnshadowedRename(expected.text, actual.text)
) {
unshadowedNames += 1
lastMatched = actual
left += 1
right += 1
continue
}
// `{ name }` -> `{ name: <qualifier>.name }`: the printer writes the baseline's shorthand back
// as one token, and qualifying the value makes the property name unavoidable again.
if (
actual.label === ':' &&
lastMatched?.label === 'name' &&
after[right + 1]?.label === 'name' &&
after[right + 1]?.text === qualifier &&
after[right + 2]?.label === '.' &&
after[right + 3]?.text === lastMatched.text
) {
shorthandProperties += 1
right += 4
continue
}
// `name` -> `<qualifier>.name`, three tokens for one.
if (isQualified(after, right, expected, qualifier)) {
qualifiedReferences += 1
left += 1
right += 3
continue
}
// `parseInt` -> `Number.parseInt`, the same shape under a different object.
if (NUMBER_GLOBALS.has(expected.text) && isQualified(after, right, expected, 'Number')) {
numberProperties += 1
left += 1
right += 3
continue
}
// `var name` -> `<qualifier>.name`: the declaration itself moved onto the scope object.
if (
expected.label === 'var' &&
before[left + 1] !== undefined &&
isQualified(after, right, before[left + 1], qualifier)
) {
scopeFieldDeclarations += 1
left += 2
right += 3
continue
}
// `var a = 1, b = 2` where both moved onto the scope: the comma introduces the second
// declaration, which is written as its own assignment.
if (
expected.label === ',' &&
before[left + 1] !== undefined &&
isQualified(after, right, before[left + 1], qualifier)
) {
scopeFieldDeclarations += 1
left += 2
right += 3
continue
}
if (expected.label === 'var' && isBlockScopedKeyword(actual)) {
rebindings += 1
lastMatched = actual
left += 1
right += 1
continue
}
// `catch (e) {` -> `catch {`: three baseline tokens the linted form does not carry.
if (
lastMatched?.label === 'catch' &&
expected.label === '(' &&
before[left + 1]?.label === 'name' &&
before[left + 2]?.label === ')' &&
actual.label === '{'
) {
unboundCatches += 1
left += 3
continue
}
// `if (a) b;` -> `if (a) { b; }`: the body the repository's `curly` rule braced. Only a
// braceable head's body qualifies, and only that body's own close is absorbed.
if (actual.label === '{' && isBraceableHeadBody(after, right)) {
const close = matchingCloseIndex(after, right)
if (close !== -1) {
bracedBodies += 1
insertedBraceCloses.push(close)
right += 1
continue
}
}
return {
equivalent: false,
reason: `token ${left}: expected ${describeToken(expected)}, generated ${describeToken(actual)}`
}
}
// A body braced at the very end of the script leaves its close after the baseline has run out.
while (insertedBraceCloses.at(-1) === right && after[right]?.label === '}') {
insertedBraceCloses.pop()
right += 1
}
if (left !== before.length || right !== after.length) {
return {
equivalent: false,
reason: `length: ${before.length - left} token(s) left in the baseline, ${after.length - right} in the generated script`
}
}
if (insertedBraceCloses.length !== 0) {
return {
equivalent: false,
reason: `${insertedBraceCloses.length} inserted brace(s) never closed`
}
}
return {
equivalent: true,
normalisations: {
qualifiedReferences,
scopeFieldDeclarations,
rebindings,
bracedBodies,
unboundCatches,
numberProperties,
shorthandProperties,
unshadowedNames
}
}
}
/**
* Whether a token is the `const` or `let` a `var` became.
*
* `let` is contextual outside strict mode, so acorn reports it as a name rather than as a keyword;
* matching on the label alone would refuse every `let` the linter introduced.
*/
function isBlockScopedKeyword(token: DocumentToken): boolean {
return token.label === 'const' || (token.label === 'name' && token.text === 'let')
}
/** Whether the generated stream reads `<qualifier>.<expected>` where the baseline read `expected`. */
function isQualified(
after: DocumentToken[],
right: number,
expected: DocumentToken,
qualifier: string
): boolean {
return (
after[right]?.label === 'name' &&
after[right]?.text === qualifier &&
after[right + 1]?.label === '.' &&
after[right + 2]?.label === expected.label &&
after[right + 2]?.text === expected.text
)
}
/**
* The hand-written script out of the whole document, which is the part C7.1 moves.
*
* Read by locating the generated engine rather than by an index into the text, so a slice added
* above or below it does not silently shift what gets compared.
*/
export function readTerminalDocumentScript(document: string, engineJs: string): string {
const opener = `<script>${engineJs}</script>`
const start = document.indexOf(opener)
if (start === -1) {
throw new Error('the document does not carry the generated engine script')
}
const scriptStart = document.indexOf('<script>', start + opener.length)
const scriptEnd = document.lastIndexOf('</script>')
if (scriptStart === -1 || scriptEnd <= scriptStart) {
throw new Error('the document does not carry a hand-written script after the engine')
}
return document.slice(scriptStart + '<script>'.length, scriptEnd)
}
@@ -0,0 +1,225 @@
import { describe, expect, it } from 'vitest'
import { XTERM_ENGINE_JS } from '../terminal-webview-engine.generated'
import { XTERM_HTML } from '../terminal-webview-html'
import {
compareTerminalDocumentScripts,
readTerminalDocumentScript,
type TerminalDocumentNormalisations
} from './terminal-document-equivalence.test-support'
/**
* The instrument the C7.1 flip commit is reviewed with, exercised on what it will be asked.
*
* Each refusal below is one way the move could go wrong, and they matter more than the
* acceptances: a comparison that let a reordered statement or a changed literal through would pass
* the flip while the document had quietly become a different program.
*/
const QUALIFIER = 'scope'
const script = readTerminalDocumentScript(XTERM_HTML, XTERM_ENGINE_JS)
const NONE: TerminalDocumentNormalisations = {
qualifiedReferences: 0,
scopeFieldDeclarations: 0,
rebindings: 0,
bracedBodies: 0,
unboundCatches: 0,
numberProperties: 0,
shorthandProperties: 0,
unshadowedNames: 0
}
function normalisationsOf(before: string, after: string): TerminalDocumentNormalisations | string {
const result = compareTerminalDocumentScripts(before, after, QUALIFIER)
return result.equivalent ? result.normalisations : result.reason
}
describe('terminal document script equivalence', () => {
it('reads the hand-written script out of the real document', () => {
expect(script.trimStart().startsWith('(function() {')).toBe(true)
expect(script.trimEnd().endsWith('})();')).toBe(true)
})
it('accepts the real script against itself, normalising nothing', () => {
// The instrument on the real 2,758-line program rather than on a toy, which is the only way to
// know it survives everything the document actually contains.
expect(normalisationsOf(script, script)).toEqual(NONE)
})
it('ignores the semicolons the formatter drops and the comments it keeps', () => {
const before = '// one\nvar a = 1;\nfunction f() {\n b(a);\n}\n'
const after = '/* other */\nvar a = 1\nfunction f() {\n b(a)\n}\n'
expect(normalisationsOf(before, after)).toEqual(NONE)
})
it('counts a reference that gained the qualifier', () => {
expect(
normalisationsOf(
'function f() { return a + a; }',
'function f() { return scope.a + scope.a }'
)
).toEqual({ ...NONE, qualifiedReferences: 2 })
})
it('counts a declaration that moved onto the scope object', () => {
// `var a = 1` and `a = 1` are different sites: one dropped a `var`, the other never had one.
expect(normalisationsOf('var a = 1;\na = 2;', 'scope.a = 1\nscope.a = 2')).toEqual({
...NONE,
scopeFieldDeclarations: 1,
qualifiedReferences: 1
})
})
it('counts a var that stayed local and only changed keyword', () => {
expect(normalisationsOf('var a = 1;', 'const a = 1')).toEqual({ ...NONE, rebindings: 1 })
})
it('counts a var that became a let, which acorn reports as a name', () => {
// `let` is contextual outside strict mode, so a rule matching on the token label alone would
// refuse every reassigned local the linter rewrote.
expect(normalisationsOf('var a = 1;\na = 2;', 'let a = 1\na = 2')).toEqual({
...NONE,
rebindings: 1
})
})
it('counts braces the linter adds to a brace-less body', () => {
const before = 'if (a) b();\nfor (;;) c();\n'
const after = 'if (a) {\n b()\n}\nfor (;;) {\n c()\n}\n'
expect(normalisationsOf(before, after)).toEqual({ ...NONE, bracedBodies: 2 })
})
it('counts a catch clause the linter unbound', () => {
expect(normalisationsOf('try { a(); } catch (e) {}', 'try {\n a()\n} catch {}')).toEqual({
...NONE,
unboundCatches: 1,
numberProperties: 0,
shorthandProperties: 0,
unshadowedNames: 0
})
})
it('counts a global numeric function the linter moved onto Number', () => {
expect(
normalisationsOf('if (isFinite(a)) b();', 'if (Number.isFinite(a)) {\n b()\n}')
).toEqual({
...NONE,
numberProperties: 1,
bracedBodies: 1
})
})
it('refuses a global the linter does not move, qualified as if it did', () => {
// Only the four numeric globals are this rewrite; anything else under `Number` is a change.
expect(normalisationsOf('a = setTimeout(f);', 'a = Number.setTimeout(f)')).toContain('token 2')
})
it('refuses a qualifier under a name it was not told to expect', () => {
expect(normalisationsOf('a = 1;', 'state.a = 1')).toContain('token 0')
})
it('counts a shorthand property the qualifier had to spell out', () => {
expect(
normalisationsOf('var o = { alt: alt, n: 1 };', 'var o = { alt: scope.alt, n: 1 };')
).toEqual({ ...NONE, shorthandProperties: 1 })
})
it('counts each declarator of one var that moved onto the scope', () => {
expect(normalisationsOf('var a = 1, b = 2;', 'scope.a = 1; scope.b = 2;')).toEqual({
...NONE,
scopeFieldDeclarations: 2
})
})
it('reads a block-scoped function declaration the same way on both sides', () => {
const script = 'function f() { if (a) { function g() { return 1; } return g(); } }'
expect(normalisationsOf(script, script)).toEqual(NONE)
})
it('counts a name the printer no longer has to disambiguate', () => {
expect(
normalisationsOf(
'var term = null; function f(term) { return term; }',
'scope.term = null; function f(term) { return term; }'
)
).toEqual({ ...NONE, scopeFieldDeclarations: 1, unshadowedNames: 2 })
})
it('refuses a numeric-suffix rename that is not a listed unshadowed binding', () => {
// The shape `value2` -> `value` is what the printer does to a shadow, but this pair is not one
// of the document's, so it is a renamed local: a changed program, not a normalisation.
expect(
normalisationsOf('function f() { return value2; }', 'function f() { return value; }')
).toBe('token 6: expected name value2, generated name value')
})
it('refuses a bare block the baseline does not have', () => {
// A block that is nobody's body cannot be the `curly` rule's work, so absorbing it would hide
// a statement boundary the baseline never had.
expect(normalisationsOf('let value = 1; use(value);', '{ let value = 1; } use(value);')).toBe(
'token 0: expected name let, generated {'
)
})
it('counts a braced body only for a head that can carry an unbraced one', () => {
expect(normalisationsOf('if (a) b();', 'if (a) { b(); }')).toEqual({
...NONE,
bracedBodies: 1
})
expect(normalisationsOf('for (;;) b();', 'for (;;) { b(); }')).toEqual({
...NONE,
bracedBodies: 1
})
expect(normalisationsOf('while (a) b();', 'while (a) { b(); }')).toEqual({
...NONE,
bracedBodies: 1
})
expect(normalisationsOf('if (a) b(); else c();', 'if (a) { b(); } else { c(); }')).toEqual({
...NONE,
bracedBodies: 2
})
expect(normalisationsOf('do b(); while (a);', 'do { b(); } while (a);')).toEqual({
...NONE,
bracedBodies: 1
})
})
it('refuses a changed literal', () => {
expect(normalisationsOf('var a = 1;', 'var a = 2')).toBe(
'token 3: expected num 1, generated num 2'
)
})
it('refuses a dropped operator', () => {
expect(normalisationsOf('if (!a) return;', 'if (a) return')).toBe(
'token 2: expected !/~ !, generated name a'
)
})
it('refuses a reordered pair of statements', () => {
expect(normalisationsOf('a();\nb();', 'b()\na()')).toContain('token 0')
})
it('refuses a dropped statement', () => {
expect(normalisationsOf('a();\nb();', 'a()')).toBe(
'length: 3 token(s) left in the baseline, 0 in the generated script'
)
})
it('refuses a renamed local', () => {
expect(normalisationsOf('function f(x) { return x; }', 'function f(y) { return y }')).toBe(
'token 3: expected name x, generated name y'
)
})
it('refuses a generated script the printer cannot parse', () => {
// Both sides go through the printer, so something broken is reported here with its own
// message rather than thrown out of the comparison.
expect(normalisationsOf('if (a) b();', 'if (a) {\n b()')).toContain(
'the generated script does not parse'
)
})
it('refuses a baseline the printer cannot parse, naming that side', () => {
expect(normalisationsOf('a()\n}', 'a()')).toContain('the baseline does not parse')
})
})
@@ -0,0 +1,80 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { fileURLToPath } from 'node:url'
import {
buildTerminalDocumentScript,
emitTerminalDocumentModule
} from '../../../scripts/build-terminal-document-script.mjs'
import { TERMINAL_DOCUMENT_MODULE_ORDER } from '../../../scripts/terminal-document-module-order.mjs'
import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support'
/**
* The review of the move, as one number per difference class.
*
* `terminal-document-pre-flip-script.txt` is the hand-written script exactly as it stood before any
* of this, taken from the byte fixture that pinned it. This says the modules emit the same program
* modulo the qualifier and the repository's own rules rewriting an ES5 document the moment its
* source is a linted module. Anything outside those classes refuses with the token index and both
* sides, so a reordered statement, a changed literal or a renamed local cannot pass here.
*
* The scope object is the one thing the emitted script has that the document did not, so it is
* pinned on its own below rather than folded into a count.
*
* Retirement, per ruling 18: this test is the proof of the flip and holds only while no module
* changes, so 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.
*/
const preFlipScript = readFileSync(
new URL('../terminal-document-pre-flip-script.txt', import.meta.url),
'utf8'
)
describe('the whole terminal document script', () => {
it('is what the modules emit, modulo the eight counted classes', async () => {
const emitted = await Promise.all(
TERMINAL_DOCUMENT_MODULE_ORDER.map((name) =>
emitTerminalDocumentModule(fileURLToPath(new URL(`./${name}.ts`, import.meta.url)))
)
)
const candidate = `(function() {\n${emitted.join('\n')}\n})();`
expect(compareTerminalDocumentScripts(preFlipScript, candidate, 'scope')).toEqual({
equivalent: true,
normalisations: {
// The qualifier, partitioned: 609 reads and writes of a name whose declaration stayed put,
// and 73 declarations that moved onto the scope object. 682 sites in all.
qualifiedReferences: 609,
scopeFieldDeclarations: 73,
// The document's 446 `var` declarators, less the 73 that became scope fields.
rebindings: 373,
// `curly`, measured over the whole script before any of this started.
bracedBodies: 279,
// Of the document's 38 catch clauses, two name their error and report it, so they keep it.
unboundCatches: 36,
// `unicorn/prefer-number-properties`, also measured up front.
numberProperties: 17,
// Two SGR mode flags written twice each: shorthand cannot survive a qualified value.
shorthandProperties: 4,
// Names the printer had to disambiguate while an outer binding of the same name existed.
unshadowedNames: 7
}
})
})
it('adds the scope object and nothing else', async () => {
const script = await buildTerminalDocumentScript()
const emitted = await Promise.all(
TERMINAL_DOCUMENT_MODULE_ORDER.map((name) =>
emitTerminalDocumentModule(fileURLToPath(new URL(`./${name}.ts`, import.meta.url)))
)
)
const body = emitted.join('\n')
const at = script.indexOf(body)
expect(at).toBeGreaterThan(-1)
const preamble = script.slice('(function() {\n'.length, at)
expect(script.slice(at + body.length)).toBe('\n})();')
expect(preamble).toContain('function createTerminalDocumentScope()')
expect(preamble).toContain('const scope = createTerminalDocumentScope();')
expect(preamble.split('createTerminalDocumentScope').length - 1).toBe(2)
})
})
@@ -0,0 +1,94 @@
import { tokenizer } from 'acorn'
import { transformSync } from 'esbuild'
/**
* Reading a version of the in-WebView document script as a token stream.
*
* Kept apart from the comparison that consumes it: this side answers what the script says, and
* says nothing about which differences between two of them are allowed.
*/
/** One token as this comparison reads it: what kind it is, and the text it carried. */
export type DocumentToken = { readonly label: string; readonly text: string }
/**
* Acorn's `Token` class declares `type`, `start` and `end` and not `value`, which it does carry,
* so the field is read through a narrowing check rather than asserted onto the declared type.
*/
function readDocumentToken(token: unknown): DocumentToken | null {
if (typeof token !== 'object' || token === null || !('type' in token) || !('value' in token)) {
return null
}
const type: unknown = token.type
if (typeof type !== 'object' || type === null || !('label' in type)) {
return null
}
const label: unknown = type.label
if (typeof label !== 'string') {
return null
}
const value: unknown = token.value
return { label, text: value === undefined || value === null ? '' : String(value) }
}
/** The directive prepended to both sides, and checked to have survived printing. */
const STRICT_DIRECTIVE = 'use strict'
/**
* Both sides are printed by the generator's own 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. Printing both sides with
* one printer removes that whole class by construction rather than by a rule per symptom, and
* leaves only what the eight counted classes cover.
*/
function significantTokens(source: string): DocumentToken[] {
// Read strict on both sides. 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; a module
// does not, so one side would carry a rename the other cannot. Neither name escapes its block, so
// the two readings agree on behaviour and only the strict one can be compared.
const printed = transformSync(`'${STRICT_DIRECTIVE}';\n${source}`, {
loader: 'js',
target: 'chrome74',
minify: false
}).code
const kept: DocumentToken[] = []
for (const raw of tokenizer(printed, { ecmaVersion: 2020 })) {
const token = readDocumentToken(raw)
if (token === null) {
throw new Error('acorn produced a token this comparison cannot read')
}
if (token.label === ';' || token.label === 'eof') {
continue
}
kept.push(token)
}
if (kept[0]?.text !== STRICT_DIRECTIVE) {
throw new Error('the strict directive this comparison prepends did not survive printing')
}
return kept.slice(1)
}
/**
* The tokens of one side, or the reason it could not be read.
*
* A script that does not parse is a refusal with the printer's own message rather than an
* exception out of the comparison: a generator that emitted something broken should say so where
* the other differences are reported.
*/
export function readScriptTokens(
source: string,
side: string
): { ok: true; tokens: DocumentToken[] } | { ok: false; reason: string } {
try {
return { ok: true, tokens: significantTokens(source) }
} catch (error) {
return {
ok: false,
reason: `${side} does not parse: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`
}
}
}
export function describeToken(token: DocumentToken | undefined): string {
return token === undefined ? '(end of script)' : `${token.label} ${token.text}`.trim()
}
@@ -0,0 +1,10 @@
import { scope } from './document-scope'
/**
* The two fields the document declares before the query-reply bridge that follows it.
*
* They are one module because the emitted document puts them on one line, ahead of an injected
* group; nothing else joins them.
*/
scope.PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
scope.term = null
@@ -0,0 +1,201 @@
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
import {
terminalCursorBlink,
terminalCursorInactiveStyle,
terminalCursorStyle,
terminalShowCursorImmediately
} from './document-constants'
import { notify } from './host-notify'
import { fontPxForScale } from './text-scaling'
import {
scope,
type TerminalDocumentTerminal,
type TerminalDocumentWebglAddon
} from './document-scope'
import { applyFitScale } from './fit-scale'
import {
isAltScreenActive,
normalizeInitialData,
updateMouseModeFromData
} from './mouse-mode-decset-scan'
import { captureInitialOscLinkTexts } from './osc-link-tap'
import { attachTerminalQueryReplyBridge, resetTerminalDataReplyAuthority } from './query-reply'
import { cancelSelect } from './selection-range'
import { resetEvictionCounter } from './selection-state-and-eviction'
import { beginTerminalSurfaceSwap, commitTerminalSurfaceSwap } from './surface-swap'
import { attachTermObservers } from './term-observers'
import { applyTerminalTheme } from './terminal-theme'
import { attachWebglAddon, cancelWebglContextRecovery } from './webgl-recovery'
import { afterWritesDrained, enqueueWrite, pumpWrites, resetWriteQueue } from './write-queue'
declare global {
interface Window {
Unicode11Addon?: { Unicode11Addon: new () => TerminalDocumentWebglAddon }
}
const Terminal: new (options: Record<string, unknown>) => TerminalDocumentTerminal
}
export function init(
cols: number,
rows: number,
initialData: unknown,
nextTheme: Parameters<typeof applyTerminalTheme>[0],
nextFontScale: unknown,
preserveScroll: boolean,
nextOscLinks: unknown
) {
if (typeof nextFontScale === 'number' && nextFontScale > 0) {
scope.currentTextScale = nextFontScale
}
// Why: a width-reflow re-stream rewraps the same content at new cols.
// Distance-from-bottom (rows) is the only stable anchor across reflow,
// since line counts and cell positions change. null = stay pinned to bottom.
const prevB =
preserveScroll && scope.term && scope.term.buffer && scope.term.buffer.active
? scope.term.buffer.active
: null
const scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1
scope.terminalGeneration++
const gen = scope.terminalGeneration
// Why: snapshot replay can contain old queries whose replies must never
// re-enter the live PTY. Each replacement terminal earns authority anew.
resetTerminalDataReplyAuthority()
cancelWebglContextRecovery()
scope.webglAddon = null
scope.ready = false
resetWriteQueue()
scope.statusDotPendingSelector = false
scope.writesDraining = false
scope.afterDrainCallbacks = []
scope.initRows = rows || 24
scope.firstDataPending = true
scope.smoothScrollOffsetY = 0
scope.wheelAccumDeltaY = 0
scope.mouseModeScanTail = ''
scope.trackedMouseTrackingMode = 'none'
scope.sgrMouseMode = false
scope.sgrMousePixelsMode = false
scope.lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
}
const replayData = normalizeInitialData(initialData)
// Why: normalizeInitialData can discard pre-alt-screen bytes. Keep the
// mirrored modes aligned with exactly what this mobile xterm replays.
updateMouseModeFromData(replayData)
scope.activeAltScreenSnapshot = isAltScreenActive(replayData)
scope.initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : []
scope.initialOscLinkRowOffset = 0
scope.initialOscLinkEvictionReady = false
const surfaceSwap = beginTerminalSurfaceSwap()
// oxlint-disable-next-line no-unused-vars -- the document declares it here; removing it is a different program
const nextSurface = surfaceSwap.nextSurface
applyTerminalTheme(nextTheme)
scope.term = new Terminal({
cols: cols || 80,
rows: rows || 24,
theme: scope.terminalTheme,
minimumContrastRatio: scope.terminalMinimumContrastRatio,
fontFamily: scope.terminalFontFamily,
fontSize: fontPxForScale(scope.currentTextScale),
fontWeight: '300',
fontWeightBold: '500',
scrollback: 5000,
// Why: xterm suppresses parser-generated query replies when disableStdin
// is true. Native accepts only validated reply grammars from onData.
disableStdin: false,
cursorBlink: terminalCursorBlink,
cursorStyle: terminalCursorStyle,
// Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret.
showCursorImmediately: terminalShowCursorImmediately,
// A full inactive cell remains visible under the terminal's phone-fit scale.
cursorInactiveStyle: terminalCursorInactiveStyle,
convertEol: false,
allowProposedApi: true
})
const nextTerm = scope.term
scope.pendingTerm = nextTerm
scope.term.open(scope.surface!)
attachWebglAddon(true)
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) {
try {
scope.term.loadAddon(new window.Unicode11Addon.Unicode11Addon())
scope.term.unicode.activeVersion = '11'
} catch {}
}
if (typeof replayData === 'string' && replayData.length > 0) {
// Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output.
enqueueWrite(scope.ESC + '[0m' + replayData)
}
// Why: reset eviction tracking + attach observers for the new term.
resetEvictionCounter()
cancelSelect()
attachTermObservers()
attachTerminalQueryReplyBridge(scope.term, gen)
requestAnimationFrame(function () {
if (gen !== scope.terminalGeneration) {
return
}
scope.ready = true
scope.everReady = true
afterWritesDrained(function () {
if (gen !== scope.terminalGeneration) {
return
}
commitTerminalSurfaceSwap(surfaceSwap, nextTerm)
// Why: restore the reader's place after the rewrapped buffer replays.
// Replay lands at bottom, so only act when they were scrolled up (rows>0).
if (scrollAnchorRows > 0 && scope.term && scope.term.buffer && scope.term.buffer.active) {
try {
scope.term.scrollToLine(
Math.max(0, (scope.term.buffer.active.baseY || 0) - scrollAnchorRows)
)
} catch {}
}
captureInitialOscLinkTexts()
scope.initialOscLinkRowOffset = 0
scope.initialOscLinkEvictionReady = true
applyFitScale('init-replay')
notify({ type: 'ready', cols: cols, rows: rows })
})
})
}
export function write(data: string) {
updateMouseModeFromData(data)
enqueueWrite(data)
pumpWrites(scope.terminalGeneration)
// Why: first live data chunk after init may widen the buffer past
// what the post-replay applyFitScale measured. Re-fit once after this
// chunk drains to catch the wider line. Subsequent chunks don't re-fit
// (the user's manual zoom is sticky after that).
if (scope.firstDataPending) {
scope.firstDataPending = false
const gen = scope.terminalGeneration
afterWritesDrained(function () {
if (gen !== scope.terminalGeneration) {
return
}
applyFitScale('first-data')
})
}
}
export function resize(cols: number, rows: number) {
if (!scope.term) {
return
}
scope.initRows = rows || scope.initRows
scope.term.resize(cols || scope.term.cols, rows || scope.term.rows)
emitKeyboardAvoidanceMetrics()
applyFitScale('resize-msg')
notify({ type: 'ready', cols: cols, rows: rows })
}
// reflow(): see reflow.ts.
@@ -0,0 +1,180 @@
import { terminalBackgroundFallback } from './document-constants'
import { scope, type TerminalDocumentTheme } from './document-scope'
/** A terminal colour with no alpha: what the contrast maths works on. */
export type TerminalDocumentRgb = { r: number; g: number; b: number }
/** A parsed CSS colour, alpha included, before it is composited onto the app surface. */
export type TerminalDocumentRgba = TerminalDocumentRgb & { a: number }
/** The theme payload the host publishes; an older host omits the contrast floor. */
export type TerminalDocumentThemeMessage =
| { theme?: Record<string, string>; minimumContrastRatio?: number }
| null
| undefined
const DARK_BG_MIN_CONTRAST = 3
const LIGHT_BG_MIN_CONTRAST = 4.5
// Dark app surface a transparent terminal background composites over (matches desktop APP_SURFACE_COLORS.dark).
const CONTRAST_APP_SURFACE = { r: 10, g: 10, b: 10 }
export function parseTerminalBackgroundRgba(value: unknown): TerminalDocumentRgba | null {
if (typeof value !== 'string') {
return null
}
const v = value.trim().toLowerCase()
if (!v) {
return null
}
if (v === 'black') {
return { r: 0, g: 0, b: 0, a: 1 }
}
if (v === 'white') {
return { r: 255, g: 255, b: 255, a: 1 }
}
if (v === 'transparent') {
return { r: 0, g: 0, b: 0, a: 0 }
}
const hex = v.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/)
if (hex) {
const h = hex[1]
let ch: number[]
if (h.length === 3 || h.length === 4) {
ch = h.split('').map(function (p) {
return Number.parseInt(p + p, 16)
})
} else {
ch = []
for (let i = 0; i < h.length; i += 2) {
ch.push(Number.parseInt(h.slice(i, i + 2), 16))
}
}
return { r: ch[0], g: ch[1], b: ch[2], a: ch[3] === undefined ? 1 : ch[3] / 255 }
}
const rgb = v.match(/^rgba?\(([^)]+)\)$/)
if (!rgb) {
return null
}
// oxlint-disable-next-line unicorn/prefer-includes -- the document's text is pinned token for token; rewriting this changes the native program
let parts = rgb[1].indexOf(',') >= 0 ? rgb[1].split(',') : rgb[1].split(/[\s/]+/)
parts = parts
.map(function (p) {
return p.trim()
})
.filter(function (p) {
return p.length > 0
})
if (parts.length < 3) {
return null
}
const channel = function (p: string) {
const n =
p.charAt(p.length - 1) === '%' ? (Number.parseFloat(p) / 100) * 255 : Number.parseFloat(p)
return Number.isFinite(n) ? Math.min(255, Math.max(0, Math.round(n))) : null
}
const r = channel(parts[0]),
g = channel(parts[1]),
b = channel(parts[2])
if (r === null || g === null || b === null) {
return null
}
let a = 1
if (parts[3] !== undefined) {
const raw =
parts[3].charAt(parts[3].length - 1) === '%'
? Number.parseFloat(parts[3]) / 100
: Number.parseFloat(parts[3])
a = Number.isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1
}
return { r: r, g: g, b: b, a: a }
}
export function terminalRelativeLuminance(rgb: TerminalDocumentRgb) {
const lin = function (c: number) {
const n = c / 255
// oxlint-disable-next-line prefer-exponentiation-operator -- the document's text is pinned token for token; rewriting this changes the native program
return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4)
}
return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b)
}
export function terminalContrastRatio(a: TerminalDocumentRgb, b: TerminalDocumentRgb) {
const la = terminalRelativeLuminance(a),
lb = terminalRelativeLuminance(b)
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
}
// Clamp an explicit desktop override to xterm's 1-21 range; null means "no usable override".
export function normalizeTerminalContrastOverride(value: unknown) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null
}
return Math.min(21, Math.max(1, value))
}
// Pick the xterm minimumContrastRatio floor from the composed terminal background.
// Unparseable input defaults to the dark floor so agent output never stays invisible.
export function resolveTerminalContrastFloor(background: unknown) {
const color = parseTerminalBackgroundRgba(background)
if (!color) {
return DARK_BG_MIN_CONTRAST
}
const composited =
color.a < 1
? {
r: Math.round(color.r * color.a + CONTRAST_APP_SURFACE.r * (1 - color.a)),
g: Math.round(color.g * color.a + CONTRAST_APP_SURFACE.g * (1 - color.a)),
b: Math.round(color.b * color.a + CONTRAST_APP_SURFACE.b * (1 - color.a))
}
: color
const isLight =
terminalContrastRatio({ r: 0, g: 0, b: 0 }, composited) >=
terminalContrastRatio({ r: 255, g: 255, b: 255 }, composited)
return isLight ? LIGHT_BG_MIN_CONTRAST : DARK_BG_MIN_CONTRAST
}
export function normalizeTerminalTheme(input: TerminalDocumentThemeMessage) {
const source =
input && typeof input === 'object' && input.theme && typeof input.theme === 'object'
? input.theme
: null
if (!source) {
return scope.defaultTheme
}
const next: Record<string, string> = {}
const keys = Object.keys(scope.defaultTheme)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
if (typeof source[key] === 'string') {
next[key] = source[key]
}
}
return Object.assign({}, scope.defaultTheme, next)
}
/**
* What `applyTerminalTheme` writes through. Both slots are written, so a target may arrive without
* a theme; nothing else on the terminal is touched.
*/
export type TerminalDocumentThemeTarget = {
options: { theme?: TerminalDocumentTheme; minimumContrastRatio: number }
}
export function applyTerminalTheme(input: TerminalDocumentThemeMessage) {
scope.terminalThemeInput = input
scope.terminalTheme = normalizeTerminalTheme(input)
const background = scope.terminalTheme.background || terminalBackgroundFallback
document.documentElement.style.background = background
document.body.style.background = background
// Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754);
// an older host omits the field and the luminance gate stays authoritative.
const publishedFloor = normalizeTerminalContrastOverride(
input && typeof input === 'object' ? input.minimumContrastRatio : undefined
)
scope.terminalMinimumContrastRatio =
publishedFloor === null ? resolveTerminalContrastFloor(background) : publishedFloor
if (scope.term) {
scope.term.options.theme = scope.terminalTheme
scope.term.options.minimumContrastRatio = scope.terminalMinimumContrastRatio
}
}
@@ -0,0 +1,94 @@
import { terminalTextScalePresets } from './document-constants'
import { scope } from './document-scope'
import { applyFitScale, getCellHeight } from './fit-scale'
import { getCellWidth } from './viewport-transform'
import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics'
export const scrollIndicator = document.getElementById('scroll-indicator')
export const scrollThumb = document.getElementById('scroll-thumb')
scope.scrollIndicatorHideTimer = null
scope.writeQueue = []
scope.writeQueueHead = 0
scope.writesDraining = false
scope.afterDrainCallbacks = []
scope.termObserverDisposables = []
scope.ready = false
// Why: init() flips ready false on every re-init (live width reflow included)
// while the old surface stays visible; a document-scoped latch drives the
// fatal/non-fatal decision so a transient reflow cannot blank a live terminal.
scope.everReady = false
scope.currentScale = 1
// Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a
// gesture only; it resets to 1 on release. The persistent "text size" is the
// real xterm fontSize (currentTextScale × BASE_FONT_PX), so changing it
// reflows the grid: a bigger cell means fewer columns fit, and RN re-measures
// and resizes the PTY (terminal.updateViewport) so the shell rewraps to the
// new width. A finished pinch snaps to the nearest preset and reports it to RN.
scope.userScale = 1
const BASE_FONT_PX = 13
const MIN_FONT_PX = 6
scope.MIN_FIT_COLS = 20
scope.currentTextScale = 1
const TEXT_SCALE_PRESETS = terminalTextScalePresets
scope.MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0]
scope.MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1]
export function snapToTextScalePreset(value: number) {
let best = TEXT_SCALE_PRESETS[0],
bestDelta = Infinity
for (let i = 0; i < TEXT_SCALE_PRESETS.length; i++) {
const delta = Math.abs(TEXT_SCALE_PRESETS[i] - value)
if (delta < bestDelta) {
bestDelta = delta
best = TEXT_SCALE_PRESETS[i]
}
}
return best
}
export function fontPxForScale(scale: number) {
return Math.max(MIN_FONT_PX, Math.round(BASE_FONT_PX * scale))
}
export function isIOSWebView() {
if (/iP(ad|hone|od)/.test(navigator.userAgent)) {
return true
}
return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1
}
// Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can
// fall to a non-monospace face; lead with the ui-monospace generic to avoid that.
const TERMINAL_FONT_FALLBACKS =
'"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace'
scope.terminalFontFamily =
(isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS
// Why: change the real font size, then resize the grid to fit the viewport at
// the new cell metrics so the text shows at its true size immediately. RN's
// refit (measure → updateViewport) then makes the server reflow the PTY to the
// same column count so the shell rewraps. cell metrics update on the frame
// after fontSize changes, so the resize/fit is deferred one rAF.
export function applyTextScale(scale: number) {
scope.currentTextScale = scale
if (!scope.term) {
return
}
const px = fontPxForScale(scale)
if (scope.term.options.fontSize === px) {
return
}
scope.term.options.fontSize = px
requestAnimationFrame(function () {
if (!scope.term) {
return
}
const cellW = getCellWidth()
const cellH = getCellHeight()
if (cellW > 0 && cellH > 0) {
const cols = Math.floor(window.innerWidth / cellW)
if (cols < scope.MIN_FIT_COLS) {
return
}
const rows = Math.max(8, Math.floor(window.innerHeight / cellH))
scope.term.resize(cols, rows)
emitKeyboardAvoidanceMetrics()
}
applyFitScale('text-scale')
})
}
+55
View File
@@ -0,0 +1,55 @@
import { cellColToStringIndex, getLineText } from './cell-geometry'
import { viewportToCell } from './viewport-cell'
import {
terminalFileUrlRegexSource,
terminalHttpUrlMaxLength,
terminalHttpUrlRegexSource
} from './document-constants'
const URL_TAP_RE_SOURCE = terminalHttpUrlRegexSource
const FILE_URL_TAP_RE_SOURCE = terminalFileUrlRegexSource
const URL_TAP_MAX_LENGTH = terminalHttpUrlMaxLength
export function findUrlAtColumn(lineText: string, col: number) {
return findTerminalUrlAtColumn(lineText, col, URL_TAP_RE_SOURCE)
}
export function findFileUrlAtColumn(lineText: string, col: number) {
return findTerminalUrlAtColumn(lineText, col, FILE_URL_TAP_RE_SOURCE)
}
export function findTerminalUrlAtColumn(lineText: unknown, col: number, source: string) {
if (typeof lineText !== 'string' || lineText.length === 0) {
return null
}
const re = new RegExp(source, 'gi')
let match: RegExpExecArray | null
while ((match = re.exec(lineText)) !== null) {
const end = match.index + match[0].length
if (match[0].length <= URL_TAP_MAX_LENGTH && col >= match.index && col < end) {
return match[0]
}
if (match[0].length === 0) {
re.lastIndex++
}
}
return null
}
export function fileUrlAtViewportPoint(clientX: number, clientY: number) {
const cell = viewportToCell(clientX, clientY)
if (!cell) {
return null
}
return findFileUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col))
}
export function urlAtViewportPoint(clientX: number, clientY: number) {
const cell = viewportToCell(clientX, clientY)
if (!cell) {
return null
}
// Map the cell column to a string index so wide chars earlier on the line
// don't shift the match column off the tapped URL.
return findUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col))
}
@@ -0,0 +1,36 @@
import { scope } from './document-scope'
import { getCellHeight } from './fit-scale'
import { getCellWidth, getTotalScale } from './viewport-transform'
export function viewportToCell(clientX: number, clientY: number) {
if (!scope.term) {
return null
}
const cellW = getCellWidth()
const cellH = getCellHeight()
if (cellW <= 0 || cellH <= 0) {
return null
}
let total = getTotalScale()
if (total <= 0) {
total = 1
}
const sx = (clientX - scope.panX) / total
const sy = (clientY - scope.panY) / total
let col = Math.floor(sx / cellW)
let viewportRow = Math.floor(sy / cellH)
if (col < 0) {
col = 0
}
if (col > scope.term.cols - 1) {
col = scope.term.cols - 1
}
if (viewportRow < 0) {
viewportRow = 0
}
if (viewportRow > scope.term.rows - 1) {
viewportRow = scope.term.rows - 1
}
const viewportY = scope.term.buffer.active.viewportY
return { col: col, row: viewportRow + viewportY }
}
@@ -0,0 +1,142 @@
import { terminalDefaultTheme } from './document-constants'
import { repositionOverlay } from './selection-overlay'
import { shouldRouteScrollToTerminalInput } from './mouse-input-encoding'
import { scope } from './document-scope'
import { scrollIndicator, scrollThumb } from './text-scaling'
declare global {
interface Window {
ReactNativeWebView?: { postMessage: (message: string) => void }
}
}
scope.panX = 0
scope.panY = 0
scope.smoothScrollOffsetY = 0
scope.pendingNormalScrollDeltaY = 0
scope.normalScrollFrameId = null
scope.initRows = 24
scope.terminalGeneration = 0
scope.defaultTheme = terminalDefaultTheme
scope.terminalThemeInput = null
scope.terminalTheme = scope.defaultTheme
scope.terminalMinimumContrastRatio = 3
scope.webglAddon = null
scope.webglRecoveryTimer = null
scope.activeAltScreenSnapshot = false
scope.trackedMouseTrackingMode = 'none'
scope.sgrMouseMode = false
scope.sgrMousePixelsMode = false
scope.initialOscLinks = []
scope.initialOscLinkRowOffset = 0
scope.initialOscLinkEvictionReady = false
scope.mouseModeScanTail = ''
scope.handledMessageIds = []
// Why: after init() the initial scrollback applyFitScale may have run
// against an empty buffer (or one without the widest line yet). Re-fit
// once when the first live data chunk arrives so a wider line that pushes
// scrollWidth past the previously-measured value gets re-scaled to fit.
scope.firstDataPending = false
// Diagnostic logger — bridges WebView console.log to RN via postMessage.
// Tag with [fit] so it's easy to filter in the Expo/Metro logs.
export function flog(tag: string, payload: Record<string, unknown>) {
try {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(
JSON.stringify({
type: 'log',
tag: '[fit]' + tag,
payload: payload
})
)
}
} catch {}
}
export function getCellWidth() {
if (!scope.term || !scope.term._core) {
return 0
}
const core = scope.term._core
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.width || 0
}
return 0
}
// Why: width measurement strategy.
// 1. Prefer cellWidth × term.cols — this is what xterm's renderer uses
// to lay out and is independent of buffer content. It's the "logical
// width" of the terminal grid.
// 2. Fall back to term.element.scrollWidth — the actual rendered DOM
// width — only when cellWidth isn't available yet (renderer not
// initialized). This is content-dependent (reflects widest row),
// but better than nothing.
// 3. If both are 0, return 1 (no scale change). The retry loop in
// applyFitScale will keep trying until one is positive.
export function computeFitScale() {
if (!scope.term) {
return 1
}
const cellW = getCellWidth()
const termWidth =
cellW > 0 ? cellW * scope.term.cols : scope.term.element ? scope.term.element.scrollWidth : 0
if (termWidth <= 0) {
return 1
}
const vpWidth = window.innerWidth
return Math.min(1, vpWidth / termWidth)
}
export function getTotalScale() {
return scope.currentScale * scope.userScale
}
export function updateTransform() {
scope.surface!.style.transform =
'translate(' + scope.panX + 'px,' + scope.panY + 'px) scale(' + getTotalScale() + ')'
updateScrollIndicator(false)
if (scope.selMode === 'select') {
repositionOverlay()
}
}
export function updateScrollIndicator(reveal: boolean) {
if (
!scrollIndicator ||
!scrollThumb ||
!scope.term ||
!scope.term.buffer ||
!scope.term.buffer.active
) {
return
}
const buffer = scope.term.buffer.active
const maxViewportY = buffer.baseY || 0
if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) {
scrollIndicator.classList.remove('visible')
return
}
const trackHeight = Math.max(0, window.innerHeight - 8)
const totalRows = maxViewportY + (scope.term.rows || 0)
if (trackHeight <= 0 || totalRows <= 0) {
return
}
const thumbHeight = Math.max(24, (trackHeight * (scope.term.rows || 0)) / totalRows)
const maxTop = Math.max(0, trackHeight - thumbHeight)
const top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0
scrollThumb.style.height = thumbHeight + 'px'
scrollThumb.style.transform = 'translateY(' + top + 'px)'
if (!reveal) {
return
}
scrollIndicator.classList.add('visible')
if (scope.scrollIndicatorHideTimer) {
clearTimeout(scope.scrollIndicatorHideTimer)
}
scope.scrollIndicatorHideTimer = setTimeout(function () {
scrollIndicator!.classList.remove('visible')
scope.scrollIndicatorHideTimer = null
}, 550)
}
@@ -0,0 +1,105 @@
import { flog } from './viewport-transform'
import { applyTerminalTheme } from './terminal-theme'
import { scope, type TerminalDocumentWebglAddon } from './document-scope'
/** xterm's WebGL addon constructor, as the engine bundle puts it on `window`. */
type WebglAddonGlobal = { WebglAddon?: new () => TerminalDocumentWebglAddon }
declare global {
interface Window {
WebglAddon?: WebglAddonGlobal
}
}
export function refreshTerminalSurface() {
if (!scope.term) {
return
}
try {
scope.term.refresh(0, Math.max(0, scope.term.rows - 1))
} catch {}
}
export function cancelWebglContextRecovery() {
if (!scope.webglRecoveryTimer) {
return
}
clearTimeout(scope.webglRecoveryTimer)
scope.webglRecoveryTimer = null
}
export function attachWebglAddon(allowRecovery: boolean) {
if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) {
return false
}
let addon: TerminalDocumentWebglAddon | null = null
try {
addon = new window.WebglAddon.WebglAddon()
scope.webglAddon = addon
if (addon.onContextLoss) {
addon.onContextLoss(function () {
if (scope.webglAddon !== addon) {
return
}
flog('webgl-context-loss', { retry: allowRecovery })
scope.webglAddon = null
try {
addon!.dispose()
} catch {}
refreshTerminalSurface()
if (!allowRecovery) {
return
}
// Why: one delayed retry handles transient iOS context loss without
// entering a GPU crash loop; a second loss stays on the DOM renderer.
cancelWebglContextRecovery()
const recoveryTerm = scope.term
const recoveryGeneration = scope.terminalGeneration
scope.webglRecoveryTimer = setTimeout(function () {
scope.webglRecoveryTimer = null
if (scope.term !== recoveryTerm || scope.terminalGeneration !== recoveryGeneration) {
return
}
attachWebglAddon(false)
}, 100)
})
}
scope.term.loadAddon(addon)
if (!allowRecovery) {
try {
if (addon.clearTextureAtlas) {
addon.clearTextureAtlas()
}
} catch {}
refreshTerminalSurface()
}
return true
} catch (e) {
flog('webgl-attach-failed', { retry: !allowRecovery, message: String(e) })
if (scope.webglAddon === addon) {
scope.webglAddon = null
}
try {
if (addon) {
addon.dispose()
}
} catch {}
refreshTerminalSurface()
return false
}
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState !== 'visible') {
return
}
// Why: iOS may restore the xterm model while discarding GPU pixels/theme
// paint state, so visibility must rebuild the atlas and repaint every row.
applyTerminalTheme(scope.terminalThemeInput)
try {
if (scope.webglAddon && scope.webglAddon.clearTextureAtlas) {
scope.webglAddon.clearTextureAtlas()
}
} catch {}
refreshTerminalSurface()
})
@@ -0,0 +1,75 @@
import { getCellHeight } from './fit-scale'
import { routeScrollLines, shouldRouteScrollToTerminalInput } from './mouse-input-encoding'
import { getTotalScale } from './viewport-transform'
import { dispatcherShouldBlockSurface } from './tap-dispatch'
import {
enqueueNormalBufferScrollDelta,
resetSmoothScrollOffset
} from './normal-buffer-smooth-scroll'
import { scope } from './document-scope'
scope.wheelAccumDeltaY = 0
export function wheelEventPixelDeltaY(e: WheelEvent) {
const delta = e.deltaY
if (typeof delta !== 'number' || !Number.isFinite(delta) || delta === 0) {
return 0
}
// DOM_DELTA_LINE / DOM_DELTA_PAGE: Android WebView reports line-mode deltas
// for external mouse wheels, iOS trackpads report pixels.
if (e.deltaMode === 1) {
return delta * getCellHeight() * getTotalScale()
}
if (e.deltaMode === 2) {
return delta * window.innerHeight
}
return delta
}
export function attachSurfaceWheelHandler(targetSurface: HTMLElement) {
targetSurface.addEventListener(
'wheel',
function (e) {
if (dispatcherShouldBlockSurface()) {
return
}
if (!scope.term) {
return
}
// Why: xterm's own wheel handler scrolls its hidden viewport or emits
// cursor keys through onData, which the mobile query-reply gate drops.
// Claim the event so indirect pointers share the touch scroll router.
e.preventDefault()
e.stopPropagation()
// Why: a trackpad pinch arrives as ctrl+wheel. Swallow it rather than
// firing cursor keys at the TUI; two-finger pinch still drives text size.
if (e.ctrlKey) {
return
}
const deltaY = wheelEventPixelDeltaY(e)
if (deltaY === 0) {
return
}
if (shouldRouteScrollToTerminalInput()) {
resetSmoothScrollOffset()
const effectiveCellH = getCellHeight() * getTotalScale()
if (!(effectiveCellH > 0)) {
return
}
scope.wheelAccumDeltaY += deltaY
const lines = Math.trunc(scope.wheelAccumDeltaY / effectiveCellH)
if (lines !== 0) {
scope.wheelAccumDeltaY -= lines * effectiveCellH
routeScrollLines(lines, e.clientX, e.clientY)
}
return
}
scope.wheelAccumDeltaY = 0
enqueueNormalBufferScrollDelta(deltaY)
},
{ capture: true, passive: false }
)
}
@@ -1,12 +1,16 @@
import { describe, expect, it } from 'vitest'
import { TERMINAL_HTML_WRITE_QUEUE } from './write-queue'
import {
documentScopePreamble,
generatedDocumentModule
} from './generated-document-region.test-support'
// Why: this slice is JS text injected into the WebView document, so the tests evaluate the
// emitted source against the same surrounding vars the document declares rather than asserting
// on the source string. The pre-change source is derived from the shipped one and kept as the
// differential oracle so the two cannot drift apart.
const CLEARED_SLOT_STATEMENT = ' writeQueue[writeQueueHead] = undefined;\n'
const PREVIOUS_WRITE_QUEUE_SOURCE = TERMINAL_HTML_WRITE_QUEUE.replace(CLEARED_SLOT_STATEMENT, '')
// Why: this block runs inside the WebView document, so the tests evaluate the document's own text
// against the scope object the document builds rather than asserting on the source string. The
// pre-change source is derived from the shipped one and kept as the differential oracle so the two
// cannot drift apart.
const WRITE_QUEUE_SOURCE = await generatedDocumentModule('write-queue')
const CLEARED_SLOT_STATEMENT = ' scope.writeQueue[scope.writeQueueHead] = void 0;\n'
const PREVIOUS_WRITE_QUEUE_SOURCE = WRITE_QUEUE_SOURCE.replace(CLEARED_SLOT_STATEMENT, '')
type QueueSnapshot = { slots: unknown[]; head: number }
@@ -31,35 +35,22 @@ type WriteQueueHarness = WriteQueueRuntime & {
function createWriteQueue(source: string): WriteQueueHarness {
const writes: string[] = []
const pendingWrites: Array<() => void> = []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the body's return literal names exactly the eight entries below.
const factory = new Function(
'recordWrite',
// Mirrors document-shell.ts and runtime-state-and-text-scaling.ts.
`var CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa);
var TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e);
var EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f);
var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g');
var ESC = String.fromCharCode(27);
var C1_CSI = String.fromCharCode(155);
var PRIVATE_MODE_SCAN_TAIL_LIMIT = 256;
var statusDotPendingSelector = false;
var writeQueue = [];
var writeQueueHead = 0;
var writesDraining = false;
var afterDrainCallbacks = [];
var termObserverDisposables = [];
var ready = true;
var terminalGeneration = 0;
var term = { write: function(data, done) { recordWrite(data, done); } };
`${documentScopePreamble()}
scope.ready = true;
scope.term = { write: function(data, done) { recordWrite(data, done); } };
${source}
return {
enqueue: enqueueWrite,
enqueueBoundary: enqueueWriteBoundary,
next: nextQueuedWrite,
pump: function() { pumpWrites(terminalGeneration); },
pump: function() { pumpWrites(scope.terminalGeneration); },
reset: resetWriteQueue,
afterDrained: afterWritesDrained,
setGeneration: function(next) { terminalGeneration = next; },
snapshot: function() { return { slots: writeQueue.slice(), head: writeQueueHead }; }
setGeneration: function(next) { scope.terminalGeneration = next; },
snapshot: function() { return { slots: scope.writeQueue.slice(), head: scope.writeQueueHead }; }
};`
) as (recordWrite: (data: string, done: () => void) => void) => WriteQueueRuntime
const runtime = factory((data, done) => {
@@ -97,16 +88,18 @@ function drain(queue: WriteQueueHarness): void {
}
const IMPLEMENTATIONS: Array<[string, string]> = [
['shipped', TERMINAL_HTML_WRITE_QUEUE],
['shipped', WRITE_QUEUE_SOURCE],
['previous', PREVIOUS_WRITE_QUEUE_SOURCE]
]
describe('terminal WebView write queue', () => {
it('keeps the pre-change oracle distinct from the shipped source', () => {
expect(TERMINAL_HTML_WRITE_QUEUE).toContain(CLEARED_SLOT_STATEMENT)
expect(PREVIOUS_WRITE_QUEUE_SOURCE).not.toContain('writeQueue[writeQueueHead] = undefined;')
expect(WRITE_QUEUE_SOURCE).toContain(CLEARED_SLOT_STATEMENT)
expect(PREVIOUS_WRITE_QUEUE_SOURCE).not.toContain(
'scope.writeQueue[scope.writeQueueHead] = void 0;'
)
expect(PREVIOUS_WRITE_QUEUE_SOURCE.length).toBe(
TERMINAL_HTML_WRITE_QUEUE.length - CLEARED_SLOT_STATEMENT.length
WRITE_QUEUE_SOURCE.length - CLEARED_SLOT_STATEMENT.length
)
})
@@ -143,7 +136,7 @@ describe('terminal WebView write queue', () => {
}
expect(measure(PREVIOUS_WRITE_QUEUE_SOURCE)).toBe(CHUNK_CODE_UNITS * CHUNK_COUNT)
expect(measure(TERMINAL_HTML_WRITE_QUEUE)).toBe(CHUNK_CODE_UNITS)
expect(measure(WRITE_QUEUE_SOURCE)).toBe(CHUNK_CODE_UNITS)
})
// Compaction is gated on writeQueueHead * 2 > writeQueue.length, so the pre-change retention
@@ -166,7 +159,7 @@ describe('terminal WebView write queue', () => {
}
const previous = measure(PREVIOUS_WRITE_QUEUE_SOURCE)
const shipped = measure(TERMINAL_HTML_WRITE_QUEUE)
const shipped = measure(WRITE_QUEUE_SOURCE)
// No compaction has run yet at this depth, in either implementation.
expect(previous.head).toBe(dequeues)
expect(shipped.head).toBe(dequeues)
@@ -300,6 +293,6 @@ describe('terminal WebView write queue', () => {
// A 200-deep backlog is shallow enough that head * 2 > length trips at 129; deeper
// backlogs (see the table above) do not reach the gate and the two diverge.
expect(measure(PREVIOUS_WRITE_QUEUE_SOURCE)).toBe(1_024 * 71)
expect(measure(TERMINAL_HTML_WRITE_QUEUE)).toBe(1_024 * 71)
expect(measure(WRITE_QUEUE_SOURCE)).toBe(1_024 * 71)
})
})
+135
View File
@@ -0,0 +1,135 @@
import { scope } from './document-scope'
export function resetWriteQueue() {
scope.writeQueue = []
scope.writeQueueHead = 0
}
export function isStatusDotPresentationSelector(value: string) {
return value === scope.TEXT_PRESENTATION_SELECTOR || value === scope.EMOJI_PRESENTATION_SELECTOR
}
export function endsWithStatusDotPresentationSequence(data: string) {
let i = data.length - 1
while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) {
i--
}
return i >= 0 && data.charAt(i) === scope.CLAUDE_STATUS_DOT
}
// Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph.
export function normalizeStatusDotPresentation(data: string) {
if (typeof data !== 'string' || data.length === 0) {
return data
}
if (scope.statusDotPendingSelector) {
scope.statusDotPendingSelector = false
let strippedPendingSelectors = false
while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) {
data = data.slice(1)
}
strippedPendingSelectors = data.length === 0
if (strippedPendingSelectors) {
scope.statusDotPendingSelector = true
return ''
}
}
const normalized = data.replace(
scope.CLAUDE_STATUS_DOT_PATTERN,
scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR
)
scope.statusDotPendingSelector = endsWithStatusDotPresentationSequence(data)
return normalized
}
export function enqueueWrite(data: string) {
scope.writeQueue.push(normalizeStatusDotPresentation(data))
}
export function enqueueWriteBoundary(callback: () => void) {
scope.writeQueue.push(callback)
}
export function nextQueuedWrite() {
if (scope.writeQueueHead >= scope.writeQueue.length) {
resetWriteQueue()
return undefined
}
const next = scope.writeQueue[scope.writeQueueHead]
scope.writeQueue[scope.writeQueueHead] = undefined
scope.writeQueueHead++
// Why: high-throughput terminals can enqueue faster than xterm parses;
// compact consumed slots so drain work stays O(1) without retaining old chunks.
if (scope.writeQueueHead > 128 && scope.writeQueueHead * 2 > scope.writeQueue.length) {
scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead)
scope.writeQueueHead = 0
}
return next
}
export function disposeTermObservers() {
const disposables = scope.termObserverDisposables
scope.termObserverDisposables = []
for (let i = 0; i < disposables.length; i++) {
try {
// oxlint-disable-next-line no-unused-expressions -- the guard is the call's own condition; the document's text is pinned token for token
disposables[i] && disposables[i].dispose && disposables[i].dispose!()
} catch {}
}
}
export function extractMouseModeScanTail(input: string) {
const start = Math.max(input.lastIndexOf(scope.ESC), input.lastIndexOf(scope.C1_CSI))
if (start === -1) {
return ''
}
const tail = input.slice(start)
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
if (tail.length > scope.PRIVATE_MODE_SCAN_TAIL_LIMIT) {
return ''
}
if (tail === scope.ESC || tail === scope.ESC + '[' || tail === scope.C1_CSI) {
return tail
}
if (tail.indexOf(scope.ESC + '[?') === 0) {
return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''
}
if (tail.indexOf(scope.C1_CSI + '?') === 0) {
return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''
}
return ''
}
export function pumpWrites(gen: number): void {
if (!scope.ready || !scope.term || scope.writesDraining || gen !== scope.terminalGeneration) {
return
}
const next = nextQueuedWrite()
if (typeof next !== 'string') {
if (typeof next === 'function') {
return (next(), pumpWrites(gen))
}
const callbacks = scope.afterDrainCallbacks
scope.afterDrainCallbacks = []
for (let i = 0; i < callbacks.length; i++) {
callbacks[i]()
}
return
}
scope.writesDraining = true
// Why: xterm.write() parses asynchronously. Row adjustment/resizing must
// wait until replayed SGR attributes have landed in the buffer.
scope.term.write(next, function () {
if (gen !== scope.terminalGeneration) {
return
}
scope.writesDraining = false
pumpWrites(gen)
})
}
export function afterWritesDrained(callback: () => void) {
scope.afterDrainCallbacks.push(callback)
pumpWrites(scope.terminalGeneration)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import {
ENGINE_CSS_PLACEHOLDER,
ENGINE_JS_PLACEHOLDER,
TERMINAL_DOCUMENT_FIXTURE_PATH,
terminalDocumentFixture
} from '../../scripts/build-terminal-document-fixture.mjs'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { XTERM_HTML } from './terminal-webview-html'
/**
* The emitted WebView document, byte for byte, against a committed copy of itself.
*
* `terminal-webview-payload-hash.test.ts` pins the same bytes as a digest, which answers whether
* the document moved. This answers where: the whole document is one assertion, so a slice that
* gained a character, lost an indent or changed order arrives as a diff of the line rather than as
* two hexadecimal strings. Both are kept — the digest also covers the generated engine, which this
* fixture deliberately does not.
*
* C7.1 moves the document's hand-written script into modules the web page can import, and a
* generator rebuilds the document from them. This is the instrument that says the native screen
* kept the document it had. Regenerate the fixture with
* `node scripts/build-terminal-document-fixture.mjs` only when the emitted document was meant to
* change; the diff in that commit is the evidence, and reviewing it is the point.
*/
const fixture = readFileSync(TERMINAL_DOCUMENT_FIXTURE_PATH, 'utf8')
describe('the terminal WebView document', () => {
it('is byte for byte the document the fixture holds', () => {
// Rebuilt through the script's own substitution rather than a second copy of it: a fixture
// written by a different rule than the one that reads it agrees with itself and with nothing.
expect(terminalDocumentFixture(XTERM_HTML, XTERM_ENGINE_JS, XTERM_ENGINE_CSS)).toBe(fixture)
})
it('holds the generated engine as placeholders, so an xterm bump is not a diff here', () => {
// Without this the fixture could lose a placeholder — inlining the engine, or dropping the
// section entirely — and the assertion above would still pass against whatever it became.
for (const placeholder of [ENGINE_JS_PLACEHOLDER, ENGINE_CSS_PLACEHOLDER]) {
expect(fixture.split(placeholder)).toHaveLength(2)
}
expect(fixture).not.toContain(XTERM_ENGINE_JS)
expect(fixture).not.toContain(XTERM_ENGINE_CSS)
})
it('is the whole document once the engine is put back', () => {
// The placeholder round trip, which is what makes the first case a claim about the document
// and not only about the hand-written part of it.
const restored = fixture
.replace(ENGINE_JS_PLACEHOLDER, () => XTERM_ENGINE_JS)
.replace(ENGINE_CSS_PLACEHOLDER, () => XTERM_ENGINE_CSS)
expect(restored).toBe(XTERM_HTML)
})
})
File diff suppressed because it is too large Load Diff
@@ -1,43 +0,0 @@
export const TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS = `
function lineHasVisibleContent(line, cell) {
if (line.translateToString(true).trim().length > 0) return true;
if (!cell || !line.getCell) return false;
var limit = Math.min(term.cols || 0, line.length || 0);
for (var x = 0; x < limit; x++) {
var current = line.getCell(x, cell);
if (!current) continue;
if (!current.isBgDefault() || current.isInverse()) return true;
if (typeof current.isUnderline === 'function' && current.isUnderline()) return true;
if (typeof current.isStrikethrough === 'function' && current.isStrikethrough()) return true;
if (typeof current.isOverline === 'function' && current.isOverline()) return true;
}
return false;
}
function computeContentBottomRow() {
if (!term || !term.buffer || !term.buffer.active) return 0;
var buffer = term.buffer.active;
var top = buffer.viewportY || 0;
var cell = buffer.getNullCell ? buffer.getNullCell() : null;
for (var y = (term.rows || 0) - 1; y >= 0; y--) {
try {
var line = buffer.getLine(top + y);
if (line && lineHasVisibleContent(line, cell)) return y;
} catch (e) {}
}
return 0;
}
function emitKeyboardAvoidanceMetrics() {
if (!term) return;
var alt = false;
try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {}
notify({
type: 'keyboard-avoidance-metrics',
cursorY: term.buffer && term.buffer.active ? term.buffer.active.cursorY : 0,
contentBottomRow: alt ? 0 : computeContentBottomRow(),
rows: term.rows || 0,
altScreen: alt
});
}
`
@@ -1,16 +1,16 @@
import { readFileSync } from 'node:fs'
import { Script } from 'node:vm'
import { Terminal } from '@xterm/xterm'
import { describe, expect, it, vi } from 'vitest'
import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from './terminal-keyboard-avoidance-metrics-injected'
import {
documentScopePreamble,
generatedDocumentModule
} from './document/generated-document-region.test-support'
import { parseTerminalKeyboardAvoidanceMetrics } from './terminal-webview-contract'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
import { XTERM_HTML } from './terminal-webview-html'
const terminalHtmlSource = readTerminalWebViewHtmlSource()
const reflowSource = readFileSync(
new URL('./terminal-webview-reflow-injected.ts', import.meta.url),
'utf8'
)
const terminalHtmlSource = XTERM_HTML
// The scope object plus the metrics block, exactly as the document carries them.
const keyboardAvoidanceMetricsScript = `${documentScopePreamble()}\nscope.term = term;\n${await generatedDocumentModule('keyboard-avoidance-metrics')}`
type Cell = { isBgDefault: () => boolean; isInverse: () => number }
type MetricsNotification = {
@@ -48,17 +48,15 @@ function runMetrics(lines: (ReturnType<typeof makeLine> | undefined)[], altScree
notify: (message: Record<string, unknown>) => notifications.push(message),
term: { buffer: { active: buffer }, cols: 10, rows: lines.length }
}
new Script(
`${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();`
).runInNewContext(context)
new Script(`${keyboardAvoidanceMetricsScript}\nemitKeyboardAvoidanceMetrics();`).runInNewContext(
context
)
return notifications[0] as MetricsNotification
}
function runTerminalMetrics(term: Terminal) {
const notifications: Record<string, unknown>[] = []
new Script(
`${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();`
).runInNewContext({
new Script(`${keyboardAvoidanceMetricsScript}\nemitKeyboardAvoidanceMetrics();`).runInNewContext({
notify: (message: Record<string, unknown>) => notifications.push(message),
term
})
@@ -193,17 +191,19 @@ describe('terminal keyboard-avoidance WebView metrics', () => {
it('refreshes metrics after every buffer geometry reset', () => {
const resizeStart = terminalHtmlSource.indexOf(' function resize(cols, rows)')
const resizeEnd = terminalHtmlSource.indexOf('\n // reflow()', resizeStart)
const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {")
const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart)
const resizeEnd = terminalHtmlSource.indexOf('\n function reflow(', resizeStart)
const clearStart = terminalHtmlSource.indexOf('} else if (msg.type === "clear") {')
const clearEnd = terminalHtmlSource.indexOf('} else if (msg.type === "measure")', clearStart)
const textScaleStart = terminalHtmlSource.indexOf(' function applyTextScale(scale)')
const textScaleEnd = terminalHtmlSource.indexOf('\n var panX', textScaleStart)
const textScaleEnd = terminalHtmlSource.indexOf('\n scope.panX', textScaleStart)
const reflowStart = terminalHtmlSource.indexOf(' function reflow(cols, rows)')
const reflowEnd = terminalHtmlSource.indexOf('\n function notify(', reflowStart)
for (const block of [
terminalHtmlSource.slice(resizeStart, resizeEnd),
terminalHtmlSource.slice(clearStart, clearEnd),
terminalHtmlSource.slice(textScaleStart, textScaleEnd),
reflowSource
terminalHtmlSource.slice(reflowStart, reflowEnd)
]) {
expect(block.indexOf('emitKeyboardAvoidanceMetrics()')).toBeGreaterThan(
block.includes('term.resize') ? block.indexOf('term.resize') : block.indexOf('term.reset')
@@ -1,134 +0,0 @@
// Plain-JS file-path-under-tap detection, injected verbatim into the terminal
// WebView's xterm script (XTERM_HTML). It is interpolated with ${...}, so the
// regex backslashes here are single (the real runtime form) — not the doubled
// form a backtick template literal would otherwise require.
//
// This mirrors the unit-tested mobile/src/terminal/terminal-path-tap.ts; keep
// the two in sync. The TS module is the source of truth for the algorithm and
// has the regression tests; this string only exists because the WebView can't
// import RN modules.
//
// Matches both slash-bearing paths AND bare filenames with an extension
// (README.md, src/index.ts:5) — like desktop, we propose candidates and let the
// host's files.resolveTerminalPath existence check reject non-files. Agents
// often print a bare filename (the markdown link target is consumed, leaving
// only the label text), so requiring a slash would miss the common case.
export const TERMINAL_PATH_TAP_JS = String.raw`
var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g;
var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g;
var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };
function parsePathLineCol(value) {
var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value);
if (!m) return null;
var pathText = m[1];
var last = pathText.charAt(pathText.length - 1);
if (!pathText || last === '/' || last === '\\') return null;
var line = m[2] ? parseInt(m[2], 10) : null;
var column = m[3] ? parseInt(m[3], 10) : null;
if ((line !== null && line < 1) || (column !== null && column < 1)) return null;
return { pathText: pathText, line: line, column: column };
}
function trimPathBoundaryPunctuation(raw, rawStart) {
var start = 0, end = raw.length;
while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1;
while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1;
if (start >= end) return null;
return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end };
}
function hasSeparatorAfterWhitespace(text) {
var sawWhitespace = false;
for (var i = 0; i < text.length; i++) {
var ch = text.charAt(i);
if (/\s/.test(ch)) { sawWhitespace = true; continue; }
if (sawWhitespace && (ch === '/' || ch === '\\')) return true;
}
return false;
}
function trimSpacedPathTrailingProse(range, col) {
// A line-end extension token only extends the span when the added segment
// is path-like (contains a separator) — prose must not be swallowed.
var selected = null;
var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g;
var match;
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
var end = match.index + match[0].length;
var text = range.text.slice(0, end);
if (countPathStarts(text) > 1) continue;
if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) {
selected = text;
}
}
if (selected) {
if (col !== undefined && col >= range.startIndex + selected.length) return null;
return { text: selected, startIndex: range.startIndex, endIndex: range.startIndex + selected.length };
}
var text = range.text.replace(/\s+$/, '');
return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length };
}
function countPathStarts(text) {
var count = 0;
var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g;
while (pathStartPattern.exec(text) !== null) count += 1;
return count;
}
function hasSpacedPathExtension(text) {
var range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length });
if (!range) return false;
var trimmed = range.text.replace(/\s+$/, '');
return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed);
}
function matchSpacedFilePathAtColumn(lineText, col) {
SPACED_PATH_RE.lastIndex = 0;
var match;
while ((match = SPACED_PATH_RE.exec(lineText)) !== null) {
var trimmed = trimPathBoundaryPunctuation(match[0], match.index);
if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue;
var candidate = trimSpacedPathTrailingProse(trimmed, col);
if (!candidate) continue;
if (col < candidate.startIndex || col >= candidate.endIndex) continue;
var parsed = parsePathLineCol(candidate.text);
if (parsed) return parsed;
}
return null;
}
function matchFilePathAtColumn(lineText, col) {
var spaced = matchSpacedFilePathAtColumn(lineText, col);
if (spaced) return spaced;
FILE_PATH_RE.lastIndex = 0;
var match;
while ((match = FILE_PATH_RE.exec(lineText)) !== null) {
var raw = match[0];
if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; }
var trimmed = trimPathBoundaryPunctuation(raw, match.index);
if (!trimmed) continue;
if (col < trimmed.startIndex || col >= trimmed.endIndex) continue;
var parsed = parsePathLineCol(trimmed.text);
if (parsed) return parsed;
}
return null;
}
// Returns the path candidate under the tap, or null. Query-only so the tap
// handler can try file detection before forwarding a mouse click — which lets
// file paths open even inside a mouse-tracking TUI. Relies on viewportToCell/
// getLineText from the host script scope.
function filePathAtViewportPoint(originX, originY) {
var tapCell = viewportToCell(originX, originY);
if (!tapCell) return null;
// Map the cell column to a string index so wide chars (emoji/CJK) earlier on
// the line don't shift the match column off the tapped path.
return matchFilePathAtColumn(
getLineText(tapCell.row),
cellColToStringIndex(tapCell.row, tapCell.col)
);
}
`
@@ -4,9 +4,11 @@ import {
TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES,
columnForTerminalFileLinkTap
} from '../../../src/shared/terminal-file-link-conformance'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import { generatedDocumentModule } from './document/generated-document-region.test-support'
import { matchFilePathAtColumn, parsePathWithOptionalLineColumn } from './terminal-path-tap'
const pathTapSource = await generatedDocumentModule('path-tap')
type InjectedPathMatcher = typeof matchFilePathAtColumn
// Returns the column of the first occurrence of `needle` in `line` (+offset).
@@ -182,7 +184,7 @@ describe('injected matchFilePathAtColumn', () => {
function createInjectedPathMatcher(): InjectedPathMatcher {
const context = createContext({})
new Script(
`${TERMINAL_PATH_TAP_JS}\nthis.__matchFilePathAtColumn = matchFilePathAtColumn;`
`${pathTapSource}\nthis.__matchFilePathAtColumn = matchFilePathAtColumn;`
).runInContext(context)
return (context as { __matchFilePathAtColumn: InjectedPathMatcher }).__matchFilePathAtColumn
}
@@ -2,22 +2,18 @@ import { Script } from 'node:vm'
import { parse } from 'acorn'
import { describe, expect, it, vi } from 'vitest'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { documentScopePreamble } from './document/generated-document-region.test-support'
import { XTERM_HTML } from './terminal-webview-html'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
// Assert against the assembled document so extracted fragments cannot silently
// disappear from the WebView while source-level checks still pass.
const terminalHtmlSource = readTerminalWebViewHtmlSource()
const terminalHtmlSource = XTERM_HTML
function createWebglRecoveryHarness(failSecondAttach = false) {
const variablesStart = terminalHtmlSource.indexOf(' var webglAddon = null;')
const variablesEnd = terminalHtmlSource.indexOf(
'\n',
terminalHtmlSource.indexOf(' var webglRecoveryTimer = null;')
)
expect(variablesStart).toBeGreaterThanOrEqual(0)
expect(variablesEnd).toBeGreaterThan(variablesStart)
const recoveryStart = terminalHtmlSource.indexOf(' function refreshTerminalSurface()')
const recoveryEnd = terminalHtmlSource.indexOf(' function init(', recoveryStart)
expect(recoveryStart).toBeGreaterThanOrEqual(0)
expect(recoveryEnd).toBeGreaterThan(recoveryStart)
const timers: Array<() => void> = []
const addons: Array<{
@@ -74,8 +70,11 @@ function createWebglRecoveryHarness(failSecondAttach = false) {
terminalThemeInput,
window: { WebglAddon: { WebglAddon } }
}
new Script(`${terminalHtmlSource.slice(variablesStart, variablesEnd)}
${TERMINAL_WEBGL_RECOVERY_JS}
new Script(`${documentScopePreamble()}
scope.term = term;
scope.terminalGeneration = terminalGeneration;
scope.terminalThemeInput = terminalThemeInput;
${terminalHtmlSource.slice(recoveryStart, recoveryEnd)}
attachWebglAddon(true);`).runInNewContext(context)
return {
addons,
@@ -154,14 +153,14 @@ describe('terminal WebView bundled engine', () => {
it('reports WebView message handler failures instead of swallowing them', () => {
const start = terminalHtmlSource.indexOf('function handleIncomingMessage')
const end = terminalHtmlSource.indexOf("window.addEventListener('resize'", start)
const end = terminalHtmlSource.indexOf('window.addEventListener("resize"', start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
const handlerSource = terminalHtmlSource.slice(start, end)
expect(handlerSource).toContain('reportEngineError(')
expect(handlerSource).toContain("'terminal init failed'")
expect(handlerSource).toContain("'terminal message failed'")
expect(handlerSource).toContain('"terminal init failed"')
expect(handlerSource).toContain('"terminal message failed"')
expect(handlerSource).not.toContain('catch(ex) {}')
})
@@ -170,11 +169,11 @@ describe('terminal WebView bundled engine', () => {
// old surface visible meanwhile), so the fatal default and the init-catch must
// key off `everReady` — otherwise a transient reflow error blanks a live
// terminal behind the fatal overlay. The latch stays set for the document.
expect(terminalHtmlSource).toContain('var everReady = false;')
expect(terminalHtmlSource).toContain('everReady = true;')
expect(terminalHtmlSource).toContain('fatal === undefined ? !everReady : !!fatal')
expect(terminalHtmlSource).toContain("msg.type === 'init' && !everReady")
expect(terminalHtmlSource).not.toMatch(/fatal === undefined \? !ready\b/)
expect(terminalHtmlSource).toContain('scope.everReady = false;')
expect(terminalHtmlSource).toContain('scope.everReady = true;')
expect(terminalHtmlSource).toContain('fatal === void 0 ? !scope.everReady : !!fatal')
expect(terminalHtmlSource).toContain('msg.type === "init" && !scope.everReady')
expect(terminalHtmlSource).not.toMatch(/fatal === void 0 \? !scope\.ready\b/)
})
it('bounds error capture and non-fatal reporting on a degraded engine', () => {
@@ -235,7 +234,7 @@ describe('terminal WebView bundled engine', () => {
})
it('answers native readiness probes from the live document', () => {
expect(terminalHtmlSource).toContain("if (msg.type === 'ping')")
expect(terminalHtmlSource).toContain("notify({ type: 'pong', pingId: msg.id })")
expect(terminalHtmlSource).toContain('if (msg.type === "ping")')
expect(terminalHtmlSource).toContain('notify({ type: "pong", pingId: msg.id })')
})
})
@@ -1,31 +0,0 @@
import { readFileSync } from 'node:fs'
const COMPOSER_FILE = './terminal-webview-html.ts'
const SLICE_IMPORT_RE = /^import \{[^}]*\} from '(\.\/terminal-webview-html\/[\w-]+)'$/gm
const COMPOSED_ENTRY_RE = /^ {2}TERMINAL_HTML_\w+,?$/gm
function readSource(relativePath: string): string {
return readFileSync(new URL(relativePath, import.meta.url), 'utf8')
}
/**
* Reads the TypeScript source that assembles the in-WebView document.
*
* Why: the slice list is derived from the composer's own imports rather than duplicated, so a
* new slice cannot join the emitted document while staying invisible to the tests that search
* this source. The count cross-check catches an import shape the regex cannot see.
*/
export function readTerminalWebViewHtmlSource(): string {
const composer = readSource(COMPOSER_FILE)
const slices = [...composer.matchAll(SLICE_IMPORT_RE)].map((match) => `${match[1]}.ts`)
const composedCount = [...composer.matchAll(COMPOSED_ENTRY_RE)].length
if (composedCount === 0) {
throw new Error('no composed WebView document slices found')
}
if (slices.length !== composedCount) {
throw new Error(
`WebView document slice imports (${slices.length}) do not match composed entries (${composedCount})`
)
}
return [composer, ...slices.map(readSource)].join('\n')
}
+7 -29
View File
@@ -1,38 +1,16 @@
import { TERMINAL_DOCUMENT_SCRIPT } from './terminal-webview-document-script.generated'
import { TERMINAL_HTML_DOCUMENT_CLOSE } from './terminal-webview-html/document-close'
import { TERMINAL_HTML_DOCUMENT_SHELL } from './terminal-webview-html/document-shell'
import { TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING } from './terminal-webview-html/runtime-state-and-text-scaling'
import { TERMINAL_HTML_FIT_SCALE } from './terminal-webview-html/terminal-fit-scale'
import { TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN } from './terminal-webview-html/mouse-mode-decset-scan'
import { TERMINAL_HTML_WRITE_QUEUE } from './terminal-webview-html/write-queue'
import { TERMINAL_HTML_INIT_AND_WRITE } from './terminal-webview-html/terminal-init-and-write'
import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from './terminal-webview-html/host-message-router'
import { TERMINAL_HTML_SELECTION_STATE_AND_EVICTION } from './terminal-webview-html/selection-state-and-eviction'
import { TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING } from './terminal-webview-html/term-observers-and-mode-mirroring'
import { TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING } from './terminal-webview-html/mouse-report-and-scroll-routing'
import { TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY } from './terminal-webview-html/smooth-scroll-and-cell-geometry'
import { TERMINAL_HTML_SELECTION_OVERLAY } from './terminal-webview-html/selection-overlay'
import { TERMINAL_HTML_SURFACE_TOUCH_GESTURES } from './terminal-webview-html/surface-touch-gestures'
import { TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE } from './terminal-webview-html/message-bridge-and-document-close'
export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme'
// Why: keep the document source stable while each script/style concern remains independently
// reviewable. Boundaries can only fall where the emitted document allows, so a few modules
// carry a second concern noted at the top of the file.
// Why: the script the WebView runs is generated from `src/terminal/document/`, the same modules the
// web page imports, so there is one source for both. The shell and the close are still text: they
// are markup, not program.
export const XTERM_HTML = [
TERMINAL_HTML_DOCUMENT_SHELL,
TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING,
TERMINAL_HTML_FIT_SCALE,
TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN,
TERMINAL_HTML_WRITE_QUEUE,
TERMINAL_HTML_INIT_AND_WRITE,
TERMINAL_HTML_HOST_MESSAGE_ROUTER,
TERMINAL_HTML_SELECTION_STATE_AND_EVICTION,
TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING,
TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING,
TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY,
TERMINAL_HTML_SELECTION_OVERLAY,
TERMINAL_HTML_SURFACE_TOUCH_GESTURES,
TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE
TERMINAL_DOCUMENT_SCRIPT,
TERMINAL_HTML_DOCUMENT_CLOSE
].join('')
export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }
@@ -0,0 +1,5 @@
// Closes the document after the generated script.
export const TERMINAL_HTML_DOCUMENT_CLOSE = `
</script>
</body>
</html>`
@@ -161,13 +161,4 @@ window.onerror = function(msg) {
</div>
<script>${XTERM_ENGINE_JS}</script>
<script>
(function() {
var surface = document.getElementById('terminal-surface');
var ESC = String.fromCharCode(27);
var C1_CSI = String.fromCharCode(155);
var CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa);
var TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e);
var EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f);
var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g');
var statusDotPendingSelector = false;
`
@@ -1,189 +0,0 @@
import { TERMINAL_REFLOW_JS } from '../terminal-webview-reflow-injected'
export const TERMINAL_HTML_HOST_MESSAGE_ROUTER = ` ${TERMINAL_REFLOW_JS}
function notify(msg) {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify(msg));
}
}
function engineErrorText(err) {
if (!err) return '';
if (typeof err === 'string') return err;
if (err && typeof err.message === 'string') return err.message;
try { return String(err); } catch (e) { return ''; }
}
function chromeVersionText() {
var match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\\/([0-9.]+)/);
return match ? 'Chrome ' + match[1] : 'Chrome version unknown';
}
var nonFatalErrorNotifies = 0;
function reportEngineError(context, err, fatal) {
var isFatal = fatal === undefined ? !everReady : !!fatal;
if (!isFatal) {
// Why: a constructed-but-degraded engine can throw per frame; cap
// non-fatal notifies so RN isn't flooded. Fatal reports always emit.
nonFatalErrorNotifies++;
if (nonFatalErrorNotifies > 5) return;
}
var parts = [context];
var errText = engineErrorText(err);
if (errText) parts.push(errText);
if (window.__engineErrors && window.__engineErrors.length) {
parts.push('captured: ' + window.__engineErrors.join(' | '));
}
parts.push(chromeVersionText());
notify({
type: 'error',
fatal: isFatal,
message: parts.join(' - ')
});
}
window.onerror = function(msg, source, line, column, err) {
if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg));
reportEngineError('terminal runtime error', err || msg);
};
function measureFitDimensions(containerHeightPx, retriesLeft) {
if (typeof retriesLeft !== 'number') retriesLeft = 30;
// Why: init and measure are posted back-to-back from React, but
// init has an async rAF chain. A measure that runs synchronously
// after init can find term null, disposed, lacking element, or
// with cells size 0. Retry the whole gate for ~500ms.
var notReady = !term || !term.element;
var cellWidth = 0;
var cellHeight = 0;
if (!notReady) {
var core = term._core;
if (core && core._renderService && core._renderService.dimensions) {
cellWidth = core._renderService.dimensions.css.cell.width;
cellHeight = core._renderService.dimensions.css.cell.height;
}
}
if (notReady || cellWidth <= 0 || cellHeight <= 0) {
if (retriesLeft > 0) {
requestAnimationFrame(function() {
measureFitDimensions(containerHeightPx, retriesLeft - 1);
});
return;
}
flog('measure-fail', {
notReady: notReady,
cellWidth: cellWidth,
cellHeight: cellHeight,
retriesLeft: retriesLeft
});
notify({ type: 'measure-result', cols: null, rows: null });
return;
}
var vpWidth = window.innerWidth;
// Why: prefer the container height passed from React Native over
// window.innerHeight. The RN layout system knows the exact pixel
// height of the terminal frame after the accessory/input bars are
// subtracted, whereas innerHeight can overstate the visible area
// due to layout timing or safe-area insets.
var vpHeight = (typeof containerHeightPx === 'number' && containerHeightPx > 0)
? containerHeightPx
: window.innerHeight;
var cols = Math.floor(vpWidth / cellWidth);
if (cols < MIN_FIT_COLS) {
flog('measure-skip-small-width', {
vpWidth: vpWidth,
cellWidth: cellWidth,
cols: cols
});
notify({ type: 'measure-result', cols: null, rows: null });
return;
}
// Why: the rows we report become the PTY's actual row count after the
// server fits to viewport, and xterm renders exactly that many lines
// anchored top-left of the WebView. Subtracting rows here would leave
// dead xterm-background space at the bottom of the container and make
// the last PTY rows visually appear above an "invisible line." Any
// safety margin between the prompt and the accessory bar must come
// from RN layout (terminalFrame's flex bounds), not from undersizing
// the PTY.
var rows = Math.max(8, Math.floor(vpHeight / cellHeight));
notify({ type: 'measure-result', cols: cols, rows: rows });
}
function handleMsg(msg) {
if (typeof msg.id === 'number') {
if (handledMessageIds.indexOf(msg.id) !== -1) return;
handledMessageIds.push(msg.id);
if (handledMessageIds.length > 256) handledMessageIds.shift();
}
if (msg.type === 'ping') {
notify({ type: 'pong', pingId: msg.id });
} else if (msg.type === 'init') {
init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll, msg.oscLinks);
} else if (msg.type === 'set-font-scale') {
// Why: ignore RN echoing back the value a pinch just set (msg.fontScale ===
// currentTextScale) so the post-pinch state isn't reset; only apply changes.
if (typeof msg.fontScale === 'number' && msg.fontScale > 0 && msg.fontScale !== currentTextScale) {
userScale = 1;
panX = 0;
panY = 0;
applyTextScale(msg.fontScale);
}
} else if (msg.type === 'resize') {
resize(msg.cols, msg.rows);
} else if (msg.type === 'reflow') { reflow(msg.cols, msg.rows);
} else if (msg.type === 'write') {
write(msg.data);
} else if (msg.type === 'clear') {
terminalGeneration++;
resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary.
statusDotPendingSelector = false;
afterDrainCallbacks = [];
writesDraining = false;
mouseModeScanTail = '';
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
initialOscLinks = [];
initialOscLinkRowOffset = 0;
initialOscLinkEvictionReady = false;
if (term) { term.clear(); term.reset(); }
emitModesIfChanged();
emitKeyboardAvoidanceMetrics();
resetEvictionCounter();
if (selMode === 'select') {
notify({ type: 'selection-evicted' });
cancelSelect();
}
} else if (msg.type === 'measure') {
measureFitDimensions(msg.containerHeight);
} else if (msg.type === 'reset-zoom') {
applyFitScale('reset-zoom-msg');
} else if (msg.type === 'set-theme') {
applyTerminalTheme(msg.terminalTheme);
} else if (msg.type === 'cancel-select') {
if (selMode === 'select') cancelSelect();
} else if (msg.type === 'do-select-all') {
if (term) {
try {
term.selectAll();
var b = term.buffer.active;
if (selMode !== 'select') {
selMode = 'select';
selectionOverlay.classList.add('active');
notify({ type: 'set-select-mode', enabled: true });
}
sel = {
anchor: { col: 0, row: 0 },
focus: { col: term.cols - 1, row: b.length - 1 },
activeHandle: null
};
repositionOverlay();
} catch (e) {}
}
}
}
`
@@ -1,43 +0,0 @@
export const TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE = ` function handleIncomingMessage(e) {
var msg;
try {
msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
} catch (ex) {
return;
}
try {
handleMsg(msg);
} catch(ex) {
reportEngineError(
msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed',
ex,
msg && msg.type === 'init' && !everReady
);
}
}
window.addEventListener('message', handleIncomingMessage);
document.addEventListener('message', handleIncomingMessage);
window.addEventListener('resize', function() {
// Why: viewport changed (keyboard open/close, orientation, RN container
// size update). Re-fit so the scale matches the new vpWidth — without
// this, opening the keyboard leaves the terminal at the old scale even
// though there's now less vertical room and the fit ratio may differ.
applyFitScale('window-resize');
adjustRowsForViewport();
repositionOverlay();
clampPan();
updateTransform();
});
if (window.Terminal) {
notify({ type: 'web-ready' });
} else {
reportEngineError('terminal engine missing', 'xterm failed to load', true);
}
})();
</script>
</body>
</html>`
@@ -1,52 +0,0 @@
export const TERMINAL_HTML_MOUSE_MODE_DECSET_SCAN = ` function isAltScreenActive(data) {
if (typeof data !== 'string') return false;
var on = data.lastIndexOf(ESC + '[?1049h');
var off = data.lastIndexOf(ESC + '[?1049l');
return on !== -1 && on > off;
}
function normalizeInitialData(data) {
if (!isAltScreenActive(data)) return data;
var on = data.lastIndexOf(ESC + '[?1049h');
// Why: SerializeAddon can include normal-buffer scrollback before the
// active alternate-screen snapshot. Replaying both into a fresh mobile
// xterm duplicates TUI frames and can flatten SGR attributes.
return on > 0 ? data.slice(on) : data;
}
function updateMouseModeFromData(data) {
if (typeof data !== 'string' || data.length === 0) return;
var input = mouseModeScanTail + data;
mouseModeScanTail = extractMouseModeScanTail(input);
var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g');
var match;
while ((match = re.exec(input)) !== null) {
if (match[0] === ESC + 'c') {
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
continue;
}
var enabled = (match[2] || match[4]) === 'h';
var params = (match[1] || match[3]).split(';');
for (var i = 0; i < params.length; i++) {
if (params[i] === '') continue;
var param = Number(params[i]);
if (!Number.isInteger(param)) continue;
if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none';
if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none';
if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none';
if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none';
if (param === 1006) {
sgrMouseMode = enabled;
sgrMousePixelsMode = false;
}
if (param === 1016) {
sgrMouseMode = false;
sgrMousePixelsMode = enabled;
}
}
}
}
`
@@ -1,188 +0,0 @@
import { TERMINAL_MOUSE_REPORT_CELL_JS } from '../terminal-webview-mouse-report-cell-injected'
export const TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING = ` function viewportToCell(clientX, clientY) {
if (!term) return null;
var cellW = getCellWidth();
var cellH = getCellHeight();
if (cellW <= 0 || cellH <= 0) return null;
var total = getTotalScale();
if (total <= 0) total = 1;
var sx = (clientX - panX) / total;
var sy = (clientY - panY) / total;
var col = Math.floor(sx / cellW);
var viewportRow = Math.floor(sy / cellH);
if (col < 0) col = 0;
if (col > term.cols - 1) col = term.cols - 1;
if (viewportRow < 0) viewportRow = 0;
if (viewportRow > term.rows - 1) viewportRow = term.rows - 1;
var viewportY = term.buffer.active.viewportY;
return { col: col, row: viewportRow + viewportY };
}
${TERMINAL_MOUSE_REPORT_CELL_JS}
function isAlternateBufferActive() {
try {
return !!(term && term.buffer && term.buffer.active && term.buffer.active.type === 'alternate');
} catch (e) {
return false;
}
}
function getMouseTrackingMode() {
try {
if (term && term.modes && typeof term.modes.mouseTrackingMode === 'string') {
var mode = term.modes.mouseTrackingMode;
if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') return mode;
return 'none';
}
} catch (e) {}
if (
trackedMouseTrackingMode === 'x10' ||
trackedMouseTrackingMode === 'vt200' ||
trackedMouseTrackingMode === 'drag' ||
trackedMouseTrackingMode === 'any'
) {
return trackedMouseTrackingMode;
}
return 'none';
}
function repeatSequence(sequence, count) {
var out = '';
for (var i = 0; i < count; i++) out += sequence;
return out;
}
function buildArrowScrollSequence(lines) {
var prefix = '[';
try {
if (term && term.modes && term.modes.applicationCursorKeysMode) prefix = 'O';
} catch (e) {}
return ESC + prefix + (lines < 0 ? 'A' : 'B');
}
function buildMouseWheelSequence(lines, clientX, clientY) {
var cell = viewportToMouseReportCell(clientX, clientY);
if (!cell) return '';
var eventCode = lines < 0 ? 64 : 65;
if (sgrMousePixelsMode) {
if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return '';
return ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M';
}
if (sgrMouseMode) {
// Why: xterm increments zero-based mouse cells before encoding reports.
var sgrCol = cell.col + 1;
var sgrRow = cell.row + 1;
if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return '';
return ESC + '[<' + eventCode + ';' + sgrCol + ';' + sgrRow + 'M';
}
// Why: xterm increments zero-based mouse cells before encoding reports.
var button = eventCode + 32;
var col = cell.col + 1 + 32;
var row = cell.row + 1 + 32;
// Why: non-SGR mouse bytes above ASCII are not preserved reliably through
// the mobile JSON/RPC string path. Fall back to keys for wide terminals.
if (button > 126 || col > 126 || row > 126) return '';
return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row);
}
function isSafeSgrMouseCoordinate(value) {
return Number.isInteger(value) && value >= 0 && value <= 9999;
}
function buildMouseClickInput(clientX, clientY) {
var mouseTrackingMode = getMouseTrackingMode();
if (!isClickMouseTrackingMode(mouseTrackingMode)) return '';
var cell = viewportToMouseReportCell(clientX, clientY);
if (!cell) return '';
if (sgrMousePixelsMode) {
// Why: xterm 1016 keeps SGR syntax but reports raw zero-based pixel positions.
var pixelX = cell.x;
var pixelY = cell.y;
if (!isSafeSgrMouseCoordinate(pixelX) || !isSafeSgrMouseCoordinate(pixelY)) return '';
var pixelPress = ESC + '[<0;' + pixelX + ';' + pixelY + 'M';
if (mouseTrackingMode === 'x10') return pixelPress;
return pixelPress + ESC + '[<0;' + pixelX + ';' + pixelY + 'm';
}
if (sgrMouseMode) {
// Why: xterm increments zero-based mouse cells before encoding reports.
var sgrCol = cell.col + 1;
var sgrRow = cell.row + 1;
if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return '';
var sgrPress = ESC + '[<0;' + sgrCol + ';' + sgrRow + 'M';
if (mouseTrackingMode === 'x10') return sgrPress;
return sgrPress + ESC + '[<0;' + sgrCol + ';' + sgrRow + 'm';
}
// Why: non-SGR click coordinates use printable ASCII bytes on the mobile
// bridge; unsafe wide-terminal cells must not turn into corrupted input.
var col = cell.col + 1 + 32;
var row = cell.row + 1 + 32;
if (col > 126 || row > 126) return '';
var press = ESC + '[M' + String.fromCharCode(32) + String.fromCharCode(col) + String.fromCharCode(row);
if (mouseTrackingMode === 'x10') return press;
return press + ESC + '[M' + String.fromCharCode(35) + String.fromCharCode(col) + String.fromCharCode(row);
}
function isClickMouseTrackingMode(mode) {
return mode !== 'none';
}
function isWheelMouseTrackingMode(mode) {
return mode !== 'none' && mode !== 'x10';
}
function shouldRouteScrollToTerminalInput() {
return isWheelMouseTrackingMode(getMouseTrackingMode()) || isAlternateBufferActive();
}
function buildMouseWheelScrollInput(lines, clientX, clientY) {
var count = Math.min(Math.abs(lines), 32);
if (count === 0) return '';
var sequence = buildMouseWheelSequence(lines, clientX, clientY);
if (!sequence) return '';
return repeatSequence(sequence, count);
}
function buildTuiScrollInput(lines, clientX, clientY) {
var count = Math.min(Math.abs(lines), 32);
if (count === 0) return '';
var mouseTrackingMode = getMouseTrackingMode();
var sequence = '';
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
sequence = buildMouseWheelSequence(lines, clientX, clientY);
}
if (!sequence) sequence = buildArrowScrollSequence(lines);
return repeatSequence(sequence, count);
}
function routeScrollLines(lines, clientX, clientY) {
if (!term || lines === 0) return;
var mouseTrackingMode = getMouseTrackingMode();
var alternateBufferActive = isAlternateBufferActive();
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
// Why: xterm sends wheel events to mouse-aware TUIs before considering
// scrollback, even if the app stays on the normal buffer.
var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY);
if (mouseInput) {
notify({ type: 'terminal-input', bytes: mouseInput });
return;
}
// Why: default mouse encoding can be unrepresentable in our ASCII-safe
// RPC path on wide terminals. Send bounded arrows instead of local
// scrollback/no-op while a mouse-aware app owns scroll gestures.
var fallbackInput = buildTuiScrollInput(lines, clientX, clientY);
if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput });
return;
}
if (alternateBufferActive) {
// Why: alternate-screen TUIs own their scroll state and xterm has no
// scrollback there, so mobile scroll gestures must become terminal input.
var input = buildTuiScrollInput(lines, clientX, clientY);
if (input) notify({ type: 'terminal-input', bytes: input });
return;
}
term.scrollLines(lines);
}
`
@@ -1,181 +0,0 @@
import { TERMINAL_QUERY_REPLY_JS } from '../terminal-webview-query-reply-injected'
import { TERMINAL_SURFACE_SWAP_JS } from '../terminal-webview-surface-swap-injected'
import { TERMINAL_TEXT_SCALES } from '../../storage/preferences'
import { DEFAULT_TERMINAL_THEME } from './theme'
// Also carries the scroll-indicator painter, which reads the scale state declared here.
export const TERMINAL_HTML_RUNTIME_STATE_AND_TEXT_SCALING = ` var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096;
var term = null; ${TERMINAL_QUERY_REPLY_JS}
${TERMINAL_SURFACE_SWAP_JS}
var scrollIndicator = document.getElementById('scroll-indicator');
var scrollThumb = document.getElementById('scroll-thumb');
var scrollIndicatorHideTimer = null;
var writeQueue = [];
var writeQueueHead = 0;
var writesDraining = false;
var afterDrainCallbacks = [];
var termObserverDisposables = [];
var ready = false;
// Why: init() flips ready false on every re-init (live width reflow included)
// while the old surface stays visible; a document-scoped latch drives the
// fatal/non-fatal decision so a transient reflow cannot blank a live terminal.
var everReady = false;
var currentScale = 1;
// Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a
// gesture only; it resets to 1 on release. The persistent "text size" is the
// real xterm fontSize (currentTextScale × BASE_FONT_PX), so changing it
// reflows the grid: a bigger cell means fewer columns fit, and RN re-measures
// and resizes the PTY (terminal.updateViewport) so the shell rewraps to the
// new width. A finished pinch snaps to the nearest preset and reports it to RN.
var userScale = 1;
var BASE_FONT_PX = 13;
var MIN_FONT_PX = 6;
var MIN_FIT_COLS = 20;
var currentTextScale = 1;
var TEXT_SCALE_PRESETS = ${JSON.stringify([...TERMINAL_TEXT_SCALES])};
var MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0];
var MAX_TEXT_SCALE = TEXT_SCALE_PRESETS[TEXT_SCALE_PRESETS.length - 1];
function snapToTextScalePreset(value) {
var best = TEXT_SCALE_PRESETS[0], bestDelta = Infinity;
for (var i = 0; i < TEXT_SCALE_PRESETS.length; i++) {
var delta = Math.abs(TEXT_SCALE_PRESETS[i] - value);
if (delta < bestDelta) { bestDelta = delta; best = TEXT_SCALE_PRESETS[i]; }
}
return best;
}
function fontPxForScale(scale) {
return Math.max(MIN_FONT_PX, Math.round(BASE_FONT_PX * scale));
}
function isIOSWebView() {
if (/iP(ad|hone|od)/.test(navigator.userAgent)) return true;
return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
}
// Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can
// fall to a non-monospace face; lead with the ui-monospace generic to avoid that.
var TERMINAL_FONT_FALLBACKS = '"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace';
var terminalFontFamily = (isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS;
// Why: change the real font size, then resize the grid to fit the viewport at
// the new cell metrics so the text shows at its true size immediately. RN's
// refit (measure → updateViewport) then makes the server reflow the PTY to the
// same column count so the shell rewraps. cell metrics update on the frame
// after fontSize changes, so the resize/fit is deferred one rAF.
function applyTextScale(scale) {
currentTextScale = scale;
if (!term) return;
var px = fontPxForScale(scale);
if (term.options.fontSize === px) return;
term.options.fontSize = px;
requestAnimationFrame(function() {
if (!term) return;
var cellW = getCellWidth();
var cellH = getCellHeight();
if (cellW > 0 && cellH > 0) {
var cols = Math.floor(window.innerWidth / cellW);
if (cols < MIN_FIT_COLS) return;
var rows = Math.max(8, Math.floor(window.innerHeight / cellH));
term.resize(cols, rows);
emitKeyboardAvoidanceMetrics();
}
applyFitScale('text-scale');
});
}
var panX = 0, panY = 0;
var smoothScrollOffsetY = 0;
var pendingNormalScrollDeltaY = 0;
var normalScrollFrameId = null;
var initRows = 24;
var terminalGeneration = 0;
var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)};
var terminalThemeInput = null;
var terminalTheme = defaultTheme;
var terminalMinimumContrastRatio = 3;
var webglAddon = null;
var webglRecoveryTimer = null;
var activeAltScreenSnapshot = false;
var trackedMouseTrackingMode = 'none';
var sgrMouseMode = false;
var sgrMousePixelsMode = false;
var initialOscLinks = [], initialOscLinkRowOffset = 0;
var initialOscLinkEvictionReady = false;
var mouseModeScanTail = '';
var handledMessageIds = [];
// Why: after init() the initial scrollback applyFitScale may have run
// against an empty buffer (or one without the widest line yet). Re-fit
// once when the first live data chunk arrives so a wider line that pushes
// scrollWidth past the previously-measured value gets re-scaled to fit.
var firstDataPending = false;
// Diagnostic logger — bridges WebView console.log to RN via postMessage.
// Tag with [fit] so it's easy to filter in the Expo/Metro logs.
function flog(tag, payload) {
try {
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'log', tag: '[fit]' + tag, payload: payload
}));
}
} catch (e) {}
}
function getCellWidth() {
if (!term || !term._core) return 0;
var core = term._core;
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.width || 0;
}
return 0;
}
// Why: width measurement strategy.
// 1. Prefer cellWidth × term.cols — this is what xterm's renderer uses
// to lay out and is independent of buffer content. It's the "logical
// width" of the terminal grid.
// 2. Fall back to term.element.scrollWidth — the actual rendered DOM
// width — only when cellWidth isn't available yet (renderer not
// initialized). This is content-dependent (reflects widest row),
// but better than nothing.
// 3. If both are 0, return 1 (no scale change). The retry loop in
// applyFitScale will keep trying until one is positive.
function computeFitScale() {
if (!term) return 1;
var cellW = getCellWidth();
var termWidth = cellW > 0 ? cellW * term.cols : (term.element ? term.element.scrollWidth : 0);
if (termWidth <= 0) return 1;
var vpWidth = window.innerWidth;
return Math.min(1, vpWidth / termWidth);
}
function getTotalScale() { return currentScale * userScale; }
function updateTransform() {
surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')';
updateScrollIndicator(false);
if (selMode === 'select') repositionOverlay();
}
function updateScrollIndicator(reveal) {
if (!scrollIndicator || !scrollThumb || !term || !term.buffer || !term.buffer.active) return;
var buffer = term.buffer.active;
var maxViewportY = buffer.baseY || 0;
if (maxViewportY <= 0 || shouldRouteScrollToTerminalInput()) {
scrollIndicator.classList.remove('visible');
return;
}
var trackHeight = Math.max(0, window.innerHeight - 8);
var totalRows = maxViewportY + (term.rows || 0);
if (trackHeight <= 0 || totalRows <= 0) return;
var thumbHeight = Math.max(24, trackHeight * (term.rows || 0) / totalRows);
var maxTop = Math.max(0, trackHeight - thumbHeight);
var top = maxViewportY > 0 ? (buffer.viewportY / maxViewportY) * maxTop : 0;
scrollThumb.style.height = thumbHeight + 'px';
scrollThumb.style.transform = 'translateY(' + top + 'px)';
if (!reveal) return;
scrollIndicator.classList.add('visible');
if (scrollIndicatorHideTimer) clearTimeout(scrollIndicatorHideTimer);
scrollIndicatorHideTimer = setTimeout(function() {
scrollIndicator.classList.remove('visible');
scrollIndicatorHideTimer = null;
}, 550);
}
`
@@ -1,195 +0,0 @@
import { TERMINAL_PATH_TAP_JS } from '../terminal-path-tap-injected'
import { URL_TAP_WEBVIEW_JS } from '../terminal-webview-url-tap'
// Opens with the path/url tap matchers: they land at this point in the emitted document.
export const TERMINAL_HTML_SELECTION_OVERLAY = ` ${TERMINAL_PATH_TAP_JS}
${URL_TAP_WEBVIEW_JS}
function seedWordSelection(col, absRow) {
var line = getLineText(absRow);
if (!line) {
sel = { anchor: { col: col, row: absRow }, focus: { col: col, row: absRow }, activeHandle: null };
applyXtermSelection();
return;
}
var s = col;
var e = col;
if (col >= 0 && col < line.length && WORD_RE.test(line[col])) {
while (s > 0 && WORD_RE.test(line[s - 1])) s--;
while (e < line.length - 1 && WORD_RE.test(line[e + 1])) e++;
}
sel = {
anchor: { col: s, row: absRow },
focus: { col: e, row: absRow },
activeHandle: null
};
applyXtermSelection();
}
function isStartFirst(a, b) {
if (a.row !== b.row) return a.row < b.row;
return a.col <= b.col;
}
function selRange() {
if (!sel) return null;
if (isStartFirst(sel.anchor, sel.focus)) return { start: sel.anchor, end: sel.focus };
return { start: sel.focus, end: sel.anchor };
}
function applyXtermSelection() {
if (!term || !sel) return;
var r = selRange();
if (!r) return;
// Why: term.select(col, row, length) takes a buffer-absolute row,
// not a viewport-relative one. Subtracting viewportY here drifts the
// selection by the scrollback height — handles render where the user
// pressed (their math is independent), but xterm highlights an
// off-screen scrollback region and copies the wrong text.
var length;
if (r.start.row === r.end.row) {
length = Math.max(1, r.end.col - r.start.col + 1);
} else {
var first = term.cols - r.start.col;
var middle = Math.max(0, r.end.row - r.start.row - 1) * term.cols;
var last = r.end.col + 1;
length = first + middle + last;
}
try { term.select(r.start.col, r.start.row, length); } catch (e) {}
}
function cancelSelect() {
selMode = 'navigate';
sel = null;
stopEdgeScroll();
if (term) {
try { term.clearSelection(); } catch (e) {}
// Why: some xterm renderers cache cells and skip repaint on
// clearSelection alone, leaving the previously-highlighted cells
// visually selected. Force a full refresh so the selection layer
// actually clears on screen.
try { term.refresh(0, term.rows - 1); } catch (e) {}
}
selectionOverlay.classList.remove('active');
notify({ type: 'set-select-mode', enabled: false });
}
function enterSelect(col, absRow) {
selMode = 'select';
seedWordSelection(col, absRow);
selectionOverlay.classList.add('active');
notify({ type: 'set-select-mode', enabled: true });
notify({ type: 'haptic', kind: 'selection' });
repositionOverlay();
}
function repositionOverlay() {
if (selMode !== 'select' || !sel || !term) return;
var r = selRange();
var sPx = cellToViewportPx(r.start.col, r.start.row);
var ePx = cellToViewportPx(r.end.col + 1, r.end.row);
var cellH = getCellHeight() * getTotalScale();
// Why: native iOS pattern — start handle anchors at the TOP of the
// first selected cell (dot above, stem covers the cell going down);
// end handle anchors at the BOTTOM of the last selected cell (dot
// below, stem covers the cell going up).
handleStart.style.left = sPx.x + 'px';
handleStart.style.top = sPx.y + 'px';
handleEnd.style.left = ePx.x + 'px';
handleEnd.style.top = (ePx.y + cellH) + 'px';
var startVisible = sPx.y >= 0 && sPx.y <= window.innerHeight;
var endVisible = ePx.y >= 0 && ePx.y <= window.innerHeight;
handleStart.style.visibility = startVisible ? 'visible' : 'hidden';
handleEnd.style.visibility = endVisible ? 'visible' : 'hidden';
var menuCenterX, menuY, vTransform, marginTop;
if (startVisible && sPx.y > 56) {
menuCenterX = sPx.x; menuY = sPx.y;
vTransform = 'translateY(-100%)';
marginTop = '-12px';
} else if (endVisible && ePx.y + cellH + 56 < window.innerHeight) {
menuCenterX = ePx.x; menuY = ePx.y + cellH;
vTransform = 'translateY(0)';
marginTop = '12px';
} else {
// selection covers full viewport — pin to visible center
menuCenterX = window.innerWidth / 2;
menuY = window.innerHeight / 2;
vTransform = 'translateY(-50%)';
marginTop = '0';
}
// Why: clamp horizontally so the pill stays fully visible when the
// selection sits near a screen edge. We position via plain left
// (no horizontal translate) so the clamp math is straightforward.
selMenu.style.transform = vTransform;
selMenu.style.marginTop = marginTop;
selMenu.style.top = menuY + 'px';
selMenu.style.left = '0px';
var EDGE_MARGIN = 8;
var menuW = selMenu.offsetWidth || 0;
var minLeft = EDGE_MARGIN;
var maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - menuW - EDGE_MARGIN);
var desiredLeft = menuCenterX - menuW / 2;
var clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft));
selMenu.style.left = clampedLeft + 'px';
}
function syncSelectionHandleToViewportPoint(handle, clientX, clientY) {
var c = viewportToCell(clientX, clientY);
if (!c || !sel) return false;
if (handle === 'start') sel.anchor = c;
else sel.focus = c;
applyXtermSelection();
return true;
}
function syncEdgeScrollSelectionEndpoint() {
if (!sel || !sel.activeHandle) return false;
// Why: WebView may not emit new touchmove events while a handle is held
// at the edge; resample the stored finger point after each viewport scroll.
return syncSelectionHandleToViewportPoint(
sel.activeHandle,
edgeScrollClientX,
edgeScrollClientY
);
}
function startEdgeScroll(dir) {
if (edgeScrollDir === dir) return;
stopEdgeScroll();
edgeScrollDir = dir;
edgeScrollTimer = setInterval(function() {
if (!term || edgeScrollDir === 0) return;
var beforeY = term.buffer.active.viewportY;
term.scrollLines(edgeScrollDir);
var afterY = term.buffer.active.viewportY;
if (beforeY === afterY) {
notify({ type: 'haptic', kind: 'edge-bump' });
stopEdgeScroll();
return;
}
syncEdgeScrollSelectionEndpoint();
repositionOverlay();
}, EDGE_SCROLL_INTERVAL);
}
function stopEdgeScroll() {
if (edgeScrollTimer) {
clearInterval(edgeScrollTimer);
edgeScrollTimer = null;
}
edgeScrollDir = 0;
}
function handleDragMove(handle, clientX, clientY) {
edgeScrollClientX = clientX;
edgeScrollClientY = clientY;
if (!syncSelectionHandleToViewportPoint(handle, clientX, clientY)) return;
repositionOverlay();
if (clientY < EDGE_SCROLL_PX) startEdgeScroll(-1);
else if (clientY > window.innerHeight - EDGE_SCROLL_PX) startEdgeScroll(1);
else stopEdgeScroll();
}
// Latching document-level touch dispatcher: see
// terminal-webview-tap-dispatch-injected.ts (extracted for max-lines).
`
@@ -1,71 +0,0 @@
export const TERMINAL_HTML_SELECTION_STATE_AND_EVICTION = ` // ============================================================
// SELECTION MODE (long-press → handles → Copy)
// ============================================================
var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u;
var LONG_PRESS_MS = 500;
var LONG_PRESS_SLOP = 10;
// Why: a tap that opens a link/path must survive small finger jitter. The
// long-press slop (10px) only cancels the press-to-select timer; reusing it
// to gate the tap dropped any URL/file tap that wandered >10px — at fit scale
// a few screen px of jitter is a normal tap. Use a wider, time-bounded tap
// window so deliberate scrolls/pans still don't fire a tap.
var TAP_SLOP = 24;
var TAP_MAX_MS = 700;
var EDGE_SCROLL_PX = 40;
var EDGE_SCROLL_INTERVAL = 60;
var selectionOverlay = document.getElementById('selection-overlay');
var handleStart = document.getElementById('sel-handle-start');
var handleEnd = document.getElementById('sel-handle-end');
var selMenu = document.getElementById('sel-menu');
var btnCopy = document.getElementById('sel-menu-copy');
var btnSelAll = document.getElementById('sel-menu-all');
// mode: 'navigate' | 'select'
var selMode = 'navigate';
var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' }
var longPressTimer = null;
var longPressOrigin = null; // {x,y, identifier}
// Why: tap detection is tracked separately from the long-press timer so a
// small jitter that cancels the press-to-select timer does not also cancel
// the tap (which opens links/paths). {x,y,t,identifier} or null once the
// gesture is disqualified as a tap (moved too far or held too long).
var tapCandidate = null;
var edgeScrollTimer = null;
var edgeScrollDir = 0;
var edgeScrollClientX = 0;
var edgeScrollClientY = 0;
// Eviction watchdog: linesEverWritten counts onLineFeed since last init.
// Once buffer is full, every onLineFeed evicts the top row in xterm and
// we mirror that by decrementing stored absolute rows.
var linesEverWritten = 0;
function resetEvictionCounter() { linesEverWritten = 0; }
function isBufferFull() {
if (!term) return false;
return linesEverWritten >= 5000 + (term.rows || 0);
}
function checkEviction() {
if (selMode !== 'select' || !sel) return;
var oldest = Math.min(sel.anchor.row, sel.focus.row);
if (oldest < 0) {
notify({ type: 'selection-evicted' });
cancelSelect();
}
}
function logFeedAndEvict() {
linesEverWritten++;
if (initialOscLinkEvictionReady && isBufferFull()) initialOscLinkRowOffset += 1;
if (selMode === 'select' && sel && isBufferFull()) {
sel.anchor.row -= 1;
sel.focus.row -= 1;
checkEviction();
repositionOverlay();
}
}
`
@@ -1,110 +0,0 @@
export const TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY = ` function clampNormalScrollLines(lines) {
if (!term || !term.buffer || !term.buffer.active || lines === 0) return 0;
var buffer = term.buffer.active;
if (lines > 0) {
return Math.min(lines, Math.max(0, buffer.baseY - buffer.viewportY));
}
return Math.max(lines, -buffer.viewportY);
}
function canScrollNormalBufferDelta(deltaY) {
if (!term || !term.buffer || !term.buffer.active || deltaY === 0) return false;
var buffer = term.buffer.active;
if (deltaY > 0) return buffer.viewportY < buffer.baseY;
return buffer.viewportY > 0;
}
function applyNormalBufferScrollDelta(deltaY) {
if (!term || deltaY === 0) return false;
var effectiveCellH = getCellHeight() * getTotalScale();
if (effectiveCellH <= 0) return false;
if (!canScrollNormalBufferDelta(deltaY)) {
resetSmoothScrollOffset();
return false;
}
smoothScrollOffsetY -= deltaY;
var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);
if (lines !== 0) {
var applied = clampNormalScrollLines(lines);
if (applied !== 0) {
term.scrollLines(applied);
// Why: xterm's renderer is row-based. Buffer touch pixels and only
// commit whole rows so TUI canvas layers do not shimmer between
// fractional transforms and xterm repaints.
smoothScrollOffsetY += applied * effectiveCellH;
}
if (applied !== lines) smoothScrollOffsetY = 0;
}
var limit = effectiveCellH - 1;
if (smoothScrollOffsetY > limit) smoothScrollOffsetY = limit;
if (smoothScrollOffsetY < -limit) smoothScrollOffsetY = -limit;
updateScrollIndicator(true);
return true;
}
function enqueueNormalBufferScrollDelta(deltaY) {
if (!term || deltaY === 0) return false;
if (!canScrollNormalBufferDelta(deltaY)) {
resetSmoothScrollOffset();
return false;
}
pendingNormalScrollDeltaY += deltaY;
if (normalScrollFrameId !== null) return true;
// Why: dense terminal rows are expensive to repaint. Coalesce touchmove
// deltas into one xterm row-scroll per frame instead of repainting from
// the input event stream.
normalScrollFrameId = requestAnimationFrame(function() {
normalScrollFrameId = null;
var delta = pendingNormalScrollDeltaY;
pendingNormalScrollDeltaY = 0;
if (!applyNormalBufferScrollDelta(delta)) {
resetSmoothScrollOffset();
}
});
return true;
}
function resetSmoothScrollOffset() {
pendingNormalScrollDeltaY = 0;
if (normalScrollFrameId !== null) {
cancelAnimationFrame(normalScrollFrameId);
normalScrollFrameId = null;
}
if (smoothScrollOffsetY === 0) return;
smoothScrollOffsetY = 0;
updateScrollIndicator(false);
}
function cellToViewportPx(col, absRow) {
if (!term) return { x: 0, y: 0 };
var cellW = getCellWidth();
var cellH = getCellHeight();
var viewportRow = absRow - term.buffer.active.viewportY;
var sx = col * cellW;
var sy = viewportRow * cellH;
var total = getTotalScale();
return { x: sx * total + panX, y: sy * total + panY };
}
function getLineText(absRow) {
if (!term) return '';
var line = term.buffer.active.getLine(absRow);
if (!line) return '';
return line.translateToString(false);
}
// Why: getLineText collapses wide chars (emoji, CJK) to one string char, so a
// tap's CELL column no longer equals the STRING index that url/path matchers use.
// Convert by measuring the string length up to the tapped cell (the count of
// string chars before it). Without this, taps on lines with a leading wide char
// (e.g. agent output prefixed with ⏺) resolve to the wrong column and miss.
function cellColToStringIndex(absRow, col) {
if (!term) return col;
var line = term.buffer.active.getLine(absRow);
if (!line) return col;
return line.translateToString(false, 0, col).length;
}
// File-path-under-tap detection (matchFilePathAtColumn). See
// terminal-path-tap-injected.ts; mirrors the unit-tested terminal-path-tap.ts.
`
@@ -1,228 +0,0 @@
import { TERMINAL_TAP_DISPATCH_JS } from '../terminal-webview-tap-dispatch-injected'
import { TERMINAL_WHEEL_SCROLL_JS } from '../terminal-webview-wheel-scroll-injected'
import { TERMINAL_MOUSE_CLICK_DRAG_JS } from '../terminal-webview-mouse-click-drag-injected'
// Also wires the selection menu's Copy/Select All buttons, which sit here in the emitted document.
export const TERMINAL_HTML_SURFACE_TOUCH_GESTURES = ` ${TERMINAL_TAP_DISPATCH_JS}
// External mouse / trackpad scroll: see
// terminal-webview-wheel-scroll-injected.ts (extracted for max-lines).
${TERMINAL_WHEEL_SCROLL_JS}
// External mouse click/drag: see
// terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines).
${TERMINAL_MOUSE_CLICK_DRAG_JS}
btnCopy.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (!term) return;
var text = term.getSelection ? term.getSelection() : '';
if (text && text.length > 0) {
notify({ type: 'selection', text: text });
} else {
cancelSelect();
}
});
btnSelAll.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (!term) return;
try {
term.selectAll();
var b = term.buffer.active;
sel = {
anchor: { col: 0, row: 0 },
focus: { col: term.cols - 1, row: b.length - 1 },
activeHandle: null
};
repositionOverlay();
} catch (err) {}
});
var ts = {
lastX: 0, lastY: 0, lastTime: 0, velY: 0,
accumDelta: 0, momentumId: null, isPinching: false,
pinchDist: 0, pinchScale: 0, pinchSurfX: 0, pinchSurfY: 0
};
function updateTouchVelocity(deltaY, dt) {
if (dt <= 0) return;
var instantVelocity = deltaY / dt;
if (!isFinite(instantVelocity)) return;
// Why: touchmove cadence is uneven in WebView. Blend recent samples so
// momentum launch doesn't inherit a one-frame spike or stall.
ts.velY = ts.velY === 0 ? instantVelocity : ts.velY * 0.55 + instantVelocity * 0.45;
}
function getDistance(a, b) {
var dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function attachSurfaceEventHandlers(targetSurface) {
if (!targetSurface || targetSurface.__orcaSurfaceHandlersAttached) return;
targetSurface.__orcaSurfaceHandlersAttached = true;
// Why: init() swaps in a new hidden surface to avoid flicker; each
// replacement needs gesture handlers or tab-switch replays stop scrolling.
targetSurface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true);
attachSurfaceWheelHandler(targetSurface);
attachSurfaceMouseClickDragHandler(targetSurface);
targetSurface.addEventListener('touchstart', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (ts.momentumId) {
cancelAnimationFrame(ts.momentumId);
ts.momentumId = null;
}
if (e.touches.length === 2) {
ts.isPinching = true;
smoothScrollOffsetY = 0;
ts.pinchDist = getDistance(e.touches[0], e.touches[1]);
ts.pinchScale = userScale;
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var total = getTotalScale();
ts.pinchSurfX = (mx - panX) / total;
ts.pinchSurfY = (my - panY) / total;
} else if (e.touches.length === 1) {
ts.isPinching = false;
ts.lastX = e.touches[0].clientX;
ts.lastY = e.touches[0].clientY;
ts.lastTime = Date.now();
ts.velY = 0;
ts.accumDelta = 0;
}
}, { capture: true, passive: true });
targetSurface.addEventListener('touchmove', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
e.preventDefault();
e.stopPropagation();
if (e.touches.length === 2) {
ts.isPinching = true;
var dist = getDistance(e.touches[0], e.touches[1]);
var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
var ratio = dist / ts.pinchDist;
// Why: userScale is a CSS multiplier on the current font size; bound it so
// the resulting apparent size (currentTextScale × userScale) stays within
// the preset range, since release snaps to one of those presets.
var loScale = MIN_TEXT_SCALE / currentTextScale;
var hiScale = MAX_TEXT_SCALE / currentTextScale;
userScale = Math.max(loScale, Math.min(hiScale, ts.pinchScale * ratio));
var total = getTotalScale();
panX = mx - ts.pinchSurfX * total;
panY = my - ts.pinchSurfY * total;
clampPan();
updateTransform();
} else if (e.touches.length === 1 && !ts.isPinching) {
var x = e.touches[0].clientX, y = e.touches[0].clientY;
var now = Date.now(), dt = now - ts.lastTime;
// Why: pan horizontally only when content overflows the viewport (larger
// than fit) — same check clampPan() uses. Vertical always drives buffer
// scroll so scrollback stays reachable at any text size; calling the
// never-defined contentWiderThanViewport() here threw and killed all
// single-finger scrolling, scrollback included.
if (term.element && term.element.scrollWidth * getTotalScale() > window.innerWidth + 1) {
panX += x - ts.lastX;
clampPan();
updateTransform();
}
var deltaY = ts.lastY - y;
ts.lastTime = now;
if (shouldRouteScrollToTerminalInput()) {
updateTouchVelocity(deltaY, dt);
resetSmoothScrollOffset();
var effectiveCellH = getCellHeight() * getTotalScale();
ts.accumDelta += deltaY;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, x, y);
}
} else {
if (enqueueNormalBufferScrollDelta(deltaY)) {
updateTouchVelocity(deltaY, dt);
} else {
ts.velY = 0;
}
}
ts.lastX = x;
ts.lastY = y;
}
}, { capture: true, passive: false });
targetSurface.addEventListener('touchend', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
if (ts.isPinching && e.touches.length < 2) {
ts.isPinching = false;
// Why: a finished pinch snaps to the nearest preset and becomes the new
// font size (reflowing the grid), so pinch-to-zoom IS the in-terminal way
// to set the text size. The CSS pinch zoom (userScale) is reset; the real
// size change reflows columns and RN persists + resizes the PTY to match.
var target = snapToTextScalePreset(currentTextScale * userScale);
var changed = target !== currentTextScale;
userScale = 1;
panX = 0; panY = 0;
applyTextScale(target);
updateTransform();
notify({ type: 'font-scale-changed', fontScale: target });
if (changed) notify({ type: 'haptic', kind: 'selection' });
if (e.touches.length === 1) {
ts.lastX = e.touches[0].clientX;
ts.lastY = e.touches[0].clientY;
ts.lastTime = Date.now();
ts.velY = 0;
ts.accumDelta = 0;
}
return;
}
if (e.touches.length === 0) {
var vel = ts.velY;
var FRICTION = 0.972;
var MIN_VEL = 0.012;
function momentumStep() {
vel *= FRICTION;
if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; }
var delta = vel * 16;
if (shouldRouteScrollToTerminalInput()) {
resetSmoothScrollOffset();
var effectiveCellH = getCellHeight() * getTotalScale();
ts.accumDelta += delta;
var lines = Math.trunc(ts.accumDelta / effectiveCellH);
if (lines !== 0) {
ts.accumDelta -= lines * effectiveCellH;
routeScrollLines(lines, ts.lastX, ts.lastY);
}
} else {
if (!applyNormalBufferScrollDelta(delta)) {
ts.momentumId = null;
return;
}
}
ts.momentumId = requestAnimationFrame(momentumStep);
}
if (Math.abs(vel) > MIN_VEL) {
ts.momentumId = requestAnimationFrame(momentumStep);
}
}
}, { capture: true, passive: true });
}
attachSurfaceEventHandlers(surface);
`
@@ -1,67 +0,0 @@
import { TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS } from '../terminal-keyboard-avoidance-metrics-injected'
export const TERMINAL_HTML_OBSERVERS_AND_MODE_MIRRORING = ` function emitModesIfChanged() {
if (!term) return;
var bp = !!(term.modes && term.modes.bracketedPasteMode);
var alt = false;
var mouseTrackingMode = getMouseTrackingMode();
try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {}
if (
bp !== lastEmittedModes.bracketedPasteMode ||
alt !== lastEmittedModes.altScreen ||
mouseTrackingMode !== lastEmittedModes.mouseTrackingMode ||
sgrMouseMode !== lastEmittedModes.sgrMouseMode ||
sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode
) {
lastEmittedModes = {
bracketedPasteMode: bp,
altScreen: alt,
mouseTrackingMode: mouseTrackingMode,
sgrMouseMode: sgrMouseMode,
sgrMousePixelsMode: sgrMousePixelsMode
};
notify({
type: 'modes',
bracketedPasteMode: bp,
altScreen: alt,
mouseTrackingMode: mouseTrackingMode,
sgrMouseMode: sgrMouseMode,
sgrMousePixelsMode: sgrMousePixelsMode
});
}
}
var lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
};
${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}
function attachTermObservers() {
if (!term) return;
disposeTermObservers();
try { termObserverDisposables.push(term.onLineFeed(logFeedAndEvict)); } catch (e) {}
try {
termObserverDisposables.push(term.onScroll(function() { updateScrollIndicator(false); }));
} catch (e) {}
// Why: emit modes on every parsed write so RN's mirror stays current
// without round-trip; covers \\x1b[?2004h/l and alt-screen toggles.
try {
if (term.onWriteParsed) {
termObserverDisposables.push(term.onWriteParsed(function() {
emitModesIfChanged();
emitKeyboardAvoidanceMetrics();
}));
}
} catch (e) {}
// Initial emit once buffer settles.
afterWritesDrained(function() {
emitModesIfChanged();
emitKeyboardAvoidanceMetrics();
});
}
`
@@ -1,130 +0,0 @@
import { TERMINAL_WEBVIEW_THEME_JS } from '../terminal-webview-theme-injected'
// Opens with the injected theme block: it lands at this point in the emitted document.
export const TERMINAL_HTML_FIT_SCALE = `${TERMINAL_WEBVIEW_THEME_JS}
function getCellHeight() {
if (!term || !term._core) return 15;
var core = term._core;
if (core._renderService && core._renderService.dimensions) {
return core._renderService.dimensions.css.cell.height || 15;
}
return 15;
}
// Why: clamp pan so the terminal content always covers the viewport
// when zoomed in. When content is smaller than viewport in a
// dimension, pin to top-left (no floating in the middle).
function clampPan() {
if (!term || !term.element) return;
var ts = getTotalScale();
var cw = term.element.scrollWidth * ts;
var ch = term.element.scrollHeight * ts;
var vpW = window.innerWidth;
var vpH = window.innerHeight;
if (cw > vpW) {
panX = Math.min(0, Math.max(vpW - cw, panX));
} else {
panX = 0;
}
if (ch > vpH) {
panY = Math.min(0, Math.max(vpH - ch, panY));
} else {
panY = 0;
}
}
// Why: intentional no-op. Mobile replays a live PTY snapshot then applies
// live cursor-relative chunks from that same PTY; resizing only the WebView
// xterm changes cursor coordinates and makes TUI repaint chunks duplicate or
// overlap. Kept as a no-op so its call sites stay legible.
function adjustRowsForViewport() {}
// Why: cold-start fit. After init() opens xterm, the renderer needs
// several frames before cell dimensions are computed. Reading too early
// gives cellWidth=0 (renderer service not ready) or scrollWidth=0 (DOM
// not laid out), and computeFitScale returns 1 → no zoom.
//
// Gate: cellWidth × cols is the canonical "logical width" of the grid
// and reflects xterm's layout decision, independent of buffer content.
// We commit when cellWidth becomes positive (renderer ready). Fallback:
// if cellWidth never becomes available, gate on stable positive
// scrollWidth (xterm rendered something). Cap at 60 frames (~1s @60Hz)
// so a backgrounded WebView never spins forever.
var FIT_RETRY_MAX_FRAMES = 60;
var fitRetryToken = 0;
function applyFitScale(reason) {
if (!term || !term.element) return;
var token = ++fitRetryToken;
var attempts = 0;
var lastScrollWidth = -1;
function attempt() {
if (token !== fitRetryToken) return;
if (!term || !term.element) return;
attempts++;
var cellW = getCellWidth();
if (cellW > 0 && term.cols > 0) {
commitFitScale(reason, attempts, 'cellW');
return;
}
var w = term.element.scrollWidth;
if (w > 0 && w === lastScrollWidth) {
commitFitScale(reason, attempts, 'stableSW');
return;
}
lastScrollWidth = w;
if (attempts >= FIT_RETRY_MAX_FRAMES) {
flog('commit-timeout', {
reason: reason,
attempts: attempts,
cellW: cellW,
scrollWidth: w,
cols: term.cols
});
commitFitScale(reason, attempts, 'timeout');
return;
}
requestAnimationFrame(attempt);
}
requestAnimationFrame(attempt);
}
function commitFitScale(reason, attempts, gate) {
if (!term || !term.element) return;
var preSnapScale = computeFitScale();
currentScale = preSnapScale;
// Why: when scale is very close to 1 (e.g. 0.97 from xterm scrollbar
// sub-pixels) snap to 1 to avoid imperceptible shrinkage that prevents
// a second applyFitScale from observing a "no-op needed" state.
if (currentScale >= 0.95) currentScale = 1;
userScale = 1;
panX = 0;
panY = 0;
smoothScrollOffsetY = 0;
updateTransform();
adjustRowsForViewport();
var cellW = getCellWidth();
var sw = term.element.scrollWidth;
var vpW = window.innerWidth;
var expectedW = cellW * term.cols;
var suspect =
currentScale === 1 && term.cols > 0 && expectedW > vpW + 1; // expected wider than viewport but no zoom
if (suspect) {
flog('commit-SUSPECT', {
reason: reason,
attempts: attempts,
gate: gate,
preSnapScale: preSnapScale,
finalScale: currentScale,
cellW: cellW,
cols: term.cols,
expectedW: expectedW,
scrollWidth: sw,
vpWidth: vpW
});
}
repositionOverlay();
}
`
@@ -1,139 +0,0 @@
import { TERMINAL_WEBGL_RECOVERY_JS } from '../terminal-webview-webgl-recovery-injected'
import { MOBILE_TERMINAL_CARET_OPTIONS } from './theme'
export const TERMINAL_HTML_INIT_AND_WRITE = `${TERMINAL_WEBGL_RECOVERY_JS}
function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) {
if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale;
// Why: a width-reflow re-stream rewraps the same content at new cols.
// Distance-from-bottom (rows) is the only stable anchor across reflow,
// since line counts and cell positions change. null = stay pinned to bottom.
var prevB = preserveScroll && term && term.buffer && term.buffer.active ? term.buffer.active : null;
var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1;
terminalGeneration++;
var gen = terminalGeneration;
// Why: snapshot replay can contain old queries whose replies must never
// re-enter the live PTY. Each replacement terminal earns authority anew.
resetTerminalDataReplyAuthority();
cancelWebglContextRecovery();
webglAddon = null;
ready = false;
resetWriteQueue();
statusDotPendingSelector = false;
writesDraining = false;
afterDrainCallbacks = [];
initRows = rows || 24;
firstDataPending = true;
smoothScrollOffsetY = 0;
wheelAccumDeltaY = 0;
mouseModeScanTail = '';
trackedMouseTrackingMode = 'none';
sgrMouseMode = false;
sgrMousePixelsMode = false;
lastEmittedModes = {
bracketedPasteMode: false,
altScreen: false,
mouseTrackingMode: 'none',
sgrMouseMode: false,
sgrMousePixelsMode: false
};
var replayData = normalizeInitialData(initialData);
// Why: normalizeInitialData can discard pre-alt-screen bytes. Keep the
// mirrored modes aligned with exactly what this mobile xterm replays.
updateMouseModeFromData(replayData);
activeAltScreenSnapshot = isAltScreenActive(replayData);
initialOscLinks = Array.isArray(nextOscLinks) ? nextOscLinks : [];
initialOscLinkRowOffset = 0;
initialOscLinkEvictionReady = false;
var surfaceSwap = beginTerminalSurfaceSwap();
var nextSurface = surfaceSwap.nextSurface;
applyTerminalTheme(nextTheme);
term = new Terminal({
cols: cols || 80,
rows: rows || 24,
theme: terminalTheme,
minimumContrastRatio: terminalMinimumContrastRatio,
fontFamily: terminalFontFamily,
fontSize: fontPxForScale(currentTextScale),
fontWeight: '300',
fontWeightBold: '500',
scrollback: 5000,
// Why: xterm suppresses parser-generated query replies when disableStdin
// is true. Native accepts only validated reply grammars from onData.
disableStdin: false,
cursorBlink: ${MOBILE_TERMINAL_CARET_OPTIONS.cursorBlink},
cursorStyle: ${JSON.stringify(MOBILE_TERMINAL_CARET_OPTIONS.cursorStyle)},
// Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret.
showCursorImmediately: ${MOBILE_TERMINAL_CARET_OPTIONS.showCursorImmediately},
// A full inactive cell remains visible under the terminal's phone-fit scale.
cursorInactiveStyle: ${JSON.stringify(MOBILE_TERMINAL_CARET_OPTIONS.cursorInactiveStyle)},
convertEol: false,
allowProposedApi: true
});
var nextTerm = term;
pendingTerm = nextTerm;
term.open(surface);
attachWebglAddon(true);
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
if (typeof replayData === 'string' && replayData.length > 0) {
// Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output.
enqueueWrite(ESC + '[0m' + replayData);
}
// Why: reset eviction tracking + attach observers for the new term.
resetEvictionCounter();
cancelSelect();
attachTermObservers();
attachTerminalQueryReplyBridge(term, gen);
requestAnimationFrame(function() {
if (gen !== terminalGeneration) return;
ready = true;
everReady = true;
afterWritesDrained(function() {
if (gen !== terminalGeneration) return;
commitTerminalSurfaceSwap(surfaceSwap, nextTerm);
// Why: restore the reader's place after the rewrapped buffer replays.
// Replay lands at bottom, so only act when they were scrolled up (rows>0).
if (scrollAnchorRows > 0 && term && term.buffer && term.buffer.active) {
try { term.scrollToLine(Math.max(0, (term.buffer.active.baseY || 0) - scrollAnchorRows)); } catch (e) {}
}
captureInitialOscLinkTexts();
initialOscLinkRowOffset = 0;
initialOscLinkEvictionReady = true;
applyFitScale('init-replay');
notify({ type: 'ready', cols: cols, rows: rows });
});
});
}
function write(data) {
updateMouseModeFromData(data);
enqueueWrite(data);
pumpWrites(terminalGeneration);
// Why: first live data chunk after init may widen the buffer past
// what the post-replay applyFitScale measured. Re-fit once after this
// chunk drains to catch the wider line. Subsequent chunks don't re-fit
// (the user's manual zoom is sticky after that).
if (firstDataPending) {
firstDataPending = false;
var gen = terminalGeneration;
afterWritesDrained(function() {
if (gen !== terminalGeneration) return;
applyFitScale('first-data');
});
}
}
function resize(cols, rows) {
if (!term) return;
initRows = rows || initRows;
term.resize(cols || term.cols, rows || term.rows);
emitKeyboardAvoidanceMetrics();
applyFitScale('resize-msg');
notify({ type: 'ready', cols: cols, rows: rows });
}
// reflow(): see terminal-webview-reflow-injected.ts (extracted for max-lines).
`
@@ -1,114 +0,0 @@
// Also carries disposeTermObservers() and extractMouseModeScanTail(): both belong to
// other concerns, but emitted-document order pins them inside this queue.
// nextQueuedWrite() clears each slot before advancing the head; otherwise consumed slots keep
// already-submitted chunks reachable until compaction, which is up to half a backlog away.
// Kept out of the template literal below: anything inside it ships to every device.
export const TERMINAL_HTML_WRITE_QUEUE = ` function resetWriteQueue() {
writeQueue = [];
writeQueueHead = 0;
}
function isStatusDotPresentationSelector(value) {
return value === TEXT_PRESENTATION_SELECTOR || value === EMOJI_PRESENTATION_SELECTOR;
}
function endsWithStatusDotPresentationSequence(data) {
var i = data.length - 1;
while (i >= 0 && isStatusDotPresentationSelector(data.charAt(i))) i--;
return i >= 0 && data.charAt(i) === CLAUDE_STATUS_DOT;
}
// Why: iOS WebKit promotes Claude's record/status dot to a colorful emoji glyph.
function normalizeStatusDotPresentation(data) {
if (typeof data !== 'string' || data.length === 0) return data;
if (statusDotPendingSelector) {
statusDotPendingSelector = false;
var strippedPendingSelectors = false;
while (data.length > 0 && isStatusDotPresentationSelector(data.charAt(0))) data = data.slice(1);
strippedPendingSelectors = data.length === 0;
if (strippedPendingSelectors) {
statusDotPendingSelector = true;
return '';
}
}
var normalized = data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR);
statusDotPendingSelector = endsWithStatusDotPresentationSequence(data);
return normalized;
}
function enqueueWrite(data) {
writeQueue.push(normalizeStatusDotPresentation(data));
}
function enqueueWriteBoundary(callback) {
writeQueue.push(callback);
}
function nextQueuedWrite() {
if (writeQueueHead >= writeQueue.length) {
resetWriteQueue();
return undefined;
}
var next = writeQueue[writeQueueHead];
writeQueue[writeQueueHead] = undefined;
writeQueueHead++;
// Why: high-throughput terminals can enqueue faster than xterm parses;
// compact consumed slots so drain work stays O(1) without retaining old chunks.
if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) {
writeQueue = writeQueue.slice(writeQueueHead);
writeQueueHead = 0;
}
return next;
}
function disposeTermObservers() {
var disposables = termObserverDisposables;
termObserverDisposables = [];
for (var i = 0; i < disposables.length; i++) {
try { disposables[i] && disposables[i].dispose && disposables[i].dispose(); } catch (e) {}
}
}
function extractMouseModeScanTail(input) {
var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI));
if (start === -1) return '';
var tail = input.slice(start);
// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l.
// Keep parser state far beyond normal mode lists while still bounding memory.
if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return '';
if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail;
if (tail.indexOf(ESC + '[?') === 0) {
return /^[0-9;]*$/.test(tail.slice(3)) ? tail : '';
}
if (tail.indexOf(C1_CSI + '?') === 0) {
return /^[0-9;]*$/.test(tail.slice(2)) ? tail : '';
}
return '';
}
function pumpWrites(gen) {
if (!ready || !term || writesDraining || gen !== terminalGeneration) return;
var next = nextQueuedWrite();
if (typeof next !== 'string') {
if (typeof next === 'function') return next(), pumpWrites(gen);
var callbacks = afterDrainCallbacks;
afterDrainCallbacks = [];
for (var i = 0; i < callbacks.length; i++) callbacks[i]();
return;
}
writesDraining = true;
// Why: xterm.write() parses asynchronously. Row adjustment/resizing must
// wait until replayed SGR attributes have landed in the buffer.
term.write(next, function() {
if (gen !== terminalGeneration) return;
writesDraining = false;
pumpWrites(gen);
});
}
function afterWritesDrained(callback) {
afterDrainCallbacks.push(callback);
pumpWrites(terminalGeneration);
}
`
@@ -1,198 +0,0 @@
// Indirect-pointer (external mouse / trackpad) click and drag for the terminal
// surface, injected into XTERM_HTML. Extracted from terminal-webview-html.ts to
// keep that file within its max-lines budget. Companion to
// terminal-webview-wheel-scroll-injected.ts, which owns the wheel half (#11247);
// this owns the click/drag half of #8818. Closes over host-IIFE state/functions:
// term, ESC, sel, selMode, selectionOverlay, TAP_SLOP, getMouseTrackingMode,
// viewportToCell, viewportToMouseReportCell, isSafeSgrMouseCoordinate,
// sgrMouseMode, sgrMousePixelsMode, notify, notifyTerminalSurfaceTap,
// cancelSelect, applyXtermSelection, repositionOverlay, handleDragMove,
// stopEdgeScroll, and dispatcherShouldBlockSurface.
//
// Why pointer events: a hardware mouse on Android/iPadOS raises pointer events
// with pointerType 'mouse' and NO touch events, while a finger raises
// pointerType 'touch' plus the touch events the document dispatcher owns. The
// capture-phase mousedown/click suppression in attachSurfaceEventHandlers stays:
// it is what keeps xterm's own mouse handling inert (its onData output is
// dropped by the mobile bridge), and pointer events are unaffected by it.
export const TERMINAL_MOUSE_CLICK_DRAG_JS = `
var mouseGesture = null;
// One report per transition, built with the same encoding ladder as
// buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns ''
// when the mode does not report this transition (x10 has no release, only
// drag/any report motion) or the cell is not encodable.
function buildMouseButtonReport(kind, clientX, clientY) {
var mouseTrackingMode = getMouseTrackingMode();
if (mouseTrackingMode === 'none') return '';
if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') return '';
if (kind === 'release' && mouseTrackingMode === 'x10') return '';
var cell = viewportToMouseReportCell(clientX, clientY);
if (!cell) return '';
var sgrButton = kind === 'motion' ? 32 : 0;
var sgrFinal = kind === 'release' ? 'm' : 'M';
if (sgrMousePixelsMode) {
if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return '';
return ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal;
}
if (sgrMouseMode) {
// Why: xterm increments zero-based mouse cells before encoding reports.
var sgrCol = cell.col + 1;
var sgrRow = cell.row + 1;
if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return '';
return ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal;
}
var button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32;
var col = cell.col + 1 + 32;
var row = cell.row + 1 + 32;
// Why: non-SGR mouse bytes above ASCII are not preserved reliably through
// the mobile JSON/RPC string path; drop instead of corrupting input.
if (col > 126 || row > 126) return '';
return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row);
}
function mouseReportCellKey(clientX, clientY) {
var cell = viewportToMouseReportCell(clientX, clientY);
return cell ? cell.col + ',' + cell.row : null;
}
function abandonMouseGesture() {
var gesture = mouseGesture;
mouseGesture = null;
if (!gesture) return;
if (gesture.mode === 'tracking') {
// Why: the press report already went to the TUI; a lost pointer must not
// leave the button latched down on the far side.
var release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY);
if (release) notify({ type: 'terminal-input', bytes: release });
} else if (gesture.mode === 'selecting') {
if (sel) sel.activeHandle = null;
stopEdgeScroll();
}
}
function beginMouseDrag(gesture) {
gesture.moved = true;
if (getMouseTrackingMode() !== 'none') {
gesture.mode = 'tracking';
gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY);
var press = buildMouseButtonReport('press', gesture.startX, gesture.startY);
if (press) notify({ type: 'terminal-input', bytes: press });
return;
}
var anchor = viewportToCell(gesture.startX, gesture.startY);
if (!anchor) {
gesture.mode = 'cancelled';
return;
}
// Why: mouse drags select character-anchored ranges like desktop terminals,
// not the word-seeded long-press selection; reuse the touch handle-drag
// plumbing (edge scroll included) by acting as a live 'end' handle.
gesture.mode = 'selecting';
selMode = 'select';
sel = { anchor: anchor, focus: anchor, activeHandle: 'end' };
selectionOverlay.classList.add('active');
notify({ type: 'set-select-mode', enabled: true });
applyXtermSelection();
repositionOverlay();
}
function attachSurfaceMouseClickDragHandler(targetSurface) {
targetSurface.addEventListener('pointerdown', function(e) {
if (e.pointerType !== 'mouse' || e.button !== 0) return;
if (dispatcherShouldBlockSurface() || !term) return;
// Why: a pointerup lost outside the WebView must not leave the previous
// gesture latched (tracking press with no release) when the next one lands.
if (mouseGesture) abandonMouseGesture();
// Why: mouse pointers have no implicit capture; without it a drag that
// leaves the surface drops pointermove/pointerup and strands the gesture.
try {
if (targetSurface.setPointerCapture) targetSurface.setPointerCapture(e.pointerId);
} catch (err) {}
mouseGesture = {
startX: e.clientX, startY: e.clientY,
lastX: e.clientX, lastY: e.clientY,
lastCellKey: null,
moved: false,
mode: 'pending',
dismissedSelection: false
};
if (selMode === 'select') {
// Why: touch parity — pressing outside the pill dismisses the current
// selection; the same press may still start a new drag selection.
cancelSelect();
mouseGesture.dismissedSelection = true;
}
}, true);
targetSurface.addEventListener('pointermove', function(e) {
var gesture = mouseGesture;
if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return;
if (!term) return;
gesture.lastX = e.clientX;
gesture.lastY = e.clientY;
if ((e.buttons & 1) === 0) {
// Why: a pointerup lost outside the WebView (capture unavailable) must
// end the gesture here, or a tracked press stays latched at the TUI.
// Coordinates first, so the synthesized release lands where the
// pointer re-entered rather than at the previous cell.
abandonMouseGesture();
return;
}
if (!gesture.moved) {
var dx = Math.abs(e.clientX - gesture.startX);
var dy = Math.abs(e.clientY - gesture.startY);
if (dx + dy <= TAP_SLOP) return;
beginMouseDrag(gesture);
}
if (gesture.mode === 'tracking') {
// Why: one motion report per cell keeps drags bounded by grid size, not
// by pointermove cadence, so the RN rate limiter is never the bottleneck.
var cellKey = mouseReportCellKey(e.clientX, e.clientY);
if (cellKey && cellKey !== gesture.lastCellKey) {
gesture.lastCellKey = cellKey;
var motion = buildMouseButtonReport('motion', e.clientX, e.clientY);
if (motion) notify({ type: 'terminal-input', bytes: motion });
}
} else if (gesture.mode === 'selecting') {
handleDragMove('end', e.clientX, e.clientY);
}
}, true);
targetSurface.addEventListener('pointerup', function(e) {
var gesture = mouseGesture;
if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) return;
mouseGesture = null;
if (gesture.mode === 'cancelled' || !term) return;
if (gesture.mode === 'tracking') {
var release = buildMouseButtonReport('release', e.clientX, e.clientY);
if (release) notify({ type: 'terminal-input', bytes: release });
return;
}
if (gesture.mode === 'selecting') {
if (sel) sel.activeHandle = null;
stopEdgeScroll();
repositionOverlay();
return;
}
if (dispatcherShouldBlockSurface()) return;
// Why: a dismissing tap only clears the selection (touch parity); it must
// not also open a link or focus the keyboard underneath.
if (gesture.dismissedSelection) return;
// Pointer clicks keep their current link, file, TUI mouse, and focus priority.
notifyTerminalSurfaceTap(e.clientX, e.clientY, false);
}, true);
targetSurface.addEventListener('pointercancel', function(e) {
if (e.pointerType !== 'mouse') return;
abandonMouseGesture();
}, true);
// Why: Android input injection can pair a mouse-flavored pointerdown with
// real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives,
// the document touch dispatcher owns the gesture.
targetSurface.addEventListener('touchstart', function() {
if (mouseGesture) abandonMouseGesture();
}, true);
}
`
@@ -1,29 +0,0 @@
// Mouse-report coordinate mapping injected into XTERM_HTML. Closes over term,
// panX/panY, getCellWidth/Height, and getTotalScale.
export const TERMINAL_MOUSE_REPORT_CELL_JS = `
function viewportToMouseReportCell(clientX, clientY) {
if (!term) return null;
var cellW = getCellWidth();
var cellH = getCellHeight();
if (cellW <= 0 || cellH <= 0) return null;
if (typeof clientX !== 'number') clientX = window.innerWidth / 2;
if (typeof clientY !== 'number') clientY = window.innerHeight / 2;
var total = getTotalScale();
if (total <= 0) total = 1;
var sx = (clientX - panX) / total;
var sy = (clientY - panY) / total;
var maxX = Math.max(0, term.cols * cellW - 1);
var maxY = Math.max(0, term.rows * cellH - 1);
if (sx < 0) sx = 0;
if (sx > maxX) sx = maxX;
if (sy < 0) sy = 0;
if (sy > maxY) sy = maxY;
var col = Math.floor(sx / cellW);
var row = Math.floor(sy / cellH);
if (col < 0) col = 0;
if (col > term.cols - 1) col = term.cols - 1;
if (row < 0) row = 0;
if (row > term.rows - 1) row = term.rows - 1;
return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) };
}
`
@@ -6,8 +6,8 @@ import { XTERM_HTML } from './terminal-webview-html'
// uncovered region ships silently. A diff here means the emitted WebView source changed —
// update these values only when that change is deliberate, and only after checking the
// document still runs. Refactors that merely move slice boundaries must leave them alone.
const EXPECTED_SHA256 = '25b800f342c972f0b8eaba54367bd8b02b7518e9ea6a25e04ab89b3a2ad7d21b'
const EXPECTED_LENGTH = 730472
const EXPECTED_SHA256 = 'c84ce5fc7343546427ad875aeebea90e54560579a1d18b3b700076a1c4b4623f'
const EXPECTED_LENGTH = 723480
describe('terminal WebView payload', () => {
it('composes the expected document', () => {
@@ -1,44 +0,0 @@
// Kept as one injectable unit so tests execute the same replay/generation gate
// that the WebView document runs, rather than a TypeScript reimplementation.
export const TERMINAL_QUERY_REPLY_JS = `
var terminalDataRepliesEnabled = false;
function resetTerminalDataReplyAuthority() {
terminalDataRepliesEnabled = false;
}
function resumeTerminalDataReplyAuthority() {
terminalDataRepliesEnabled = true;
}
function forwardTerminalDataReply(data) {
if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data });
}
function enqueueTerminalDataReplyBoundary(gen) {
enqueueWriteBoundary(function() {
if (gen === terminalGeneration) terminalDataRepliesEnabled = true;
});
}
function attachTerminalQueryReplyBridge(term, gen) {
// Why: parser replies require stdin enabled, but mobile input is owned by
// native controls. Keep xterm's textarea inert for touch/hardware keys.
try {
term.attachCustomKeyEventHandler(function() { return false; });
if (term.textarea) {
term.textarea.readOnly = true;
term.textarea.tabIndex = -1;
term.textarea.setAttribute('inputmode', 'none');
}
} catch (e) {}
try {
termObserverDisposables.push(term.onData(function(data) {
forwardTerminalDataReply(data);
}));
} catch (e) {}
// Why: live output can queue before initial replay finishes. Enable replies
// at the replay boundary so those live queries are answered, never replayed ones.
enqueueTerminalDataReplyBoundary(gen);
}
`
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
documentScopePreamble,
generatedDocumentModule
} from './document/generated-document-region.test-support'
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected'
const queryReplySource = await generatedDocumentModule('query-reply')
type QueryReplyGate = {
forward: (data: string) => void
@@ -15,17 +20,18 @@ function createQueryReplyGate(notify: (message: unknown) => void): {
queuedBoundaries: Array<() => void>
} {
const queuedBoundaries: Array<() => void> = []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the body's return literal names exactly the five entries below.
const factory = new Function(
'notify',
'enqueueWriteBoundary',
`var terminalGeneration = 0;
${TERMINAL_QUERY_REPLY_JS}
`${documentScopePreamble()}
${queryReplySource}
return {
forward: forwardTerminalDataReply,
queueBoundary: enqueueTerminalDataReplyBoundary,
reset: resetTerminalDataReplyAuthority,
resume: resumeTerminalDataReplyAuthority,
setGeneration: function(next) { terminalGeneration = next; }
setGeneration: function(next) { scope.terminalGeneration = next; }
};`
) as (
notify: (message: unknown) => void,
@@ -39,7 +45,7 @@ describe('mobile terminal query replies', () => {
it('forwards xterm-generated data only after initial replay drains', () => {
const listenerIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('term.onData(function(data)')
const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
'attachTerminalQueryReplyBridge(term, gen)',
'attachTerminalQueryReplyBridge(scope.term, gen)',
listenerIndex
)
const notifyIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
@@ -52,9 +58,9 @@ describe('mobile terminal query replies', () => {
expect(notifyIndex).toBeGreaterThan(listenerIndex)
expect(XTERM_WEBVIEW_SOURCE.html).toContain('disableStdin: false')
expect(XTERM_WEBVIEW_SOURCE.html).toContain(
'term.attachCustomKeyEventHandler(function() { return false; })'
'term.attachCustomKeyEventHandler(function() {\n return false;\n });'
)
expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true')
expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true;')
})
it('mutes a replacement terminal until its own replay drains', () => {
@@ -64,7 +70,7 @@ describe('mobile terminal query replies', () => {
initIndex
)
const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf(
'attachTerminalQueryReplyBridge(term, gen)',
'attachTerminalQueryReplyBridge(scope.term, gen)',
disableIndex
)
@@ -114,9 +120,9 @@ describe('mobile terminal query replies', () => {
gate.forward('\x1b[3;4R')
expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[3;4R' }])
const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf("} else if (msg.type === 'clear') {")
const clearStart = XTERM_WEBVIEW_SOURCE.html.indexOf('} else if (msg.type === "clear") {')
const clearEnd = XTERM_WEBVIEW_SOURCE.html.indexOf(
"} else if (msg.type === 'measure')",
'} else if (msg.type === "measure")',
clearStart
)
expect(XTERM_WEBVIEW_SOURCE.html.slice(clearStart, clearEnd)).toContain(
@@ -1,33 +0,0 @@
// In-WebView reflow routine, injected into XTERM_HTML. Extracted from
// terminal-webview-html.ts to keep that file within its max-lines budget.
// Closes over term / isAlternateBufferActive / applyFitScale /
// updateScrollIndicator / initRows defined in the host IIFE.
export const TERMINAL_REFLOW_JS = `
// Why: rewrap the local xterm buffer (scrollback included) to a new width
// after a server PTY reflow. Skip the alternate screen: those snapshots are
// fully repainted by the PTY and a local resize there can drop SGR attributes
// (see init's alt-screen handling), which shows as white text.
function reflow(cols, rows) {
if (!term || isAlternateBufferActive()) return;
var nextCols = cols || term.cols;
var nextRows = rows || term.rows;
if (nextCols === term.cols && nextRows === term.rows) return;
var buffer = term.buffer.active;
// Why: anchor reflow on whether the user was pinned to the live bottom so
// their scroll position survives the rewrap — if they were scrolled up,
// hold the same distance from the bottom; if at the bottom, stay there.
var wasAtBottom = buffer.viewportY >= buffer.baseY;
var distanceFromBottom = buffer.baseY - buffer.viewportY;
initRows = nextRows;
term.resize(nextCols, nextRows);
var rewrapped = term.buffer.active;
if (wasAtBottom) {
term.scrollToBottom();
} else {
term.scrollLines(rewrapped.baseY - distanceFromBottom - rewrapped.viewportY);
}
applyFitScale('reflow-msg');
updateScrollIndicator(false);
emitKeyboardAvoidanceMetrics();
}
`
@@ -1,17 +1,14 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { generatedDocumentModule } from './document/generated-document-region.test-support'
import { XTERM_HTML } from './terminal-webview-html'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
// The reflow logic lives as injected in-WebView JS; the message dispatch and
// handle wiring live in terminal-webview-html.ts / TerminalWebView.tsx. Assert
// the load-bearing invariants from source, mirroring the other tests here.
const reflowSource = readFileSync(
new URL('./terminal-webview-reflow-injected.ts', import.meta.url),
'utf8'
)
// Use the assembled document so the test covers the fragments that run in the WebView.
const htmlSource = readTerminalWebViewHtmlSource()
// The reflow logic runs inside the WebView document; the message dispatch and handle wiring live
// in terminal-webview-html.ts / TerminalWebView.tsx. Assert the load-bearing invariants from the
// document the WebView runs, mirroring the other tests here.
const reflowSource = await generatedDocumentModule('reflow')
// Use the assembled document so the test covers what the WebView actually runs.
const htmlSource = XTERM_HTML
const handleSource = readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8')
function reflowFnBody(): string {
@@ -24,57 +21,53 @@ describe('terminal WebView reflow', () => {
it('skips the alternate screen so TUI snapshots are not mutated', () => {
// Why: alt-screen snapshots are repainted by the PTY; a local resize there
// can drop SGR attributes (white text). Reflow must early-return.
expect(reflowFnBody()).toContain('if (!term || isAlternateBufferActive()) return;')
expect(reflowFnBody()).toContain('if (!scope.term || isAlternateBufferActive()) {')
})
it('rewraps the local buffer via term.resize to the new cols', () => {
expect(reflowFnBody()).toContain('term.resize(nextCols, nextRows);')
expect(reflowFnBody()).toContain('scope.term.resize(nextCols, nextRows);')
})
it('preserves the user scroll position across the rewrap', () => {
const body = reflowFnBody()
// At the live bottom -> stay pinned; scrolled up -> hold distance-from-bottom.
expect(body).toContain('var wasAtBottom = buffer.viewportY >= buffer.baseY;')
expect(body).toContain('term.scrollToBottom();')
expect(body).toContain('const wasAtBottom = buffer.viewportY >= buffer.baseY;')
expect(body).toContain('scope.term.scrollToBottom();')
expect(body).toContain('rewrapped.baseY - distanceFromBottom - rewrapped.viewportY')
})
it('is no-op when the dimensions are unchanged', () => {
expect(reflowFnBody()).toContain(
'if (nextCols === term.cols && nextRows === term.rows) return;'
'if (nextCols === scope.term.cols && nextRows === scope.term.rows) {'
)
})
it('is dispatched by the reflow WebView message and exposed on the handle', () => {
expect(htmlSource).toContain("} else if (msg.type === 'reflow') {")
expect(htmlSource).toContain('} else if (msg.type === "reflow") {')
expect(htmlSource).toContain('reflow(msg.cols, msg.rows);')
expect(handleSource).toContain("postMessage({ type: 'reflow', cols, rows })")
})
it('does not locally resize hidden WebViews to a one-column grid', () => {
expect(htmlSource).toContain('var MIN_FIT_COLS = 20;')
expect(htmlSource).toContain('if (cols < MIN_FIT_COLS) return;')
expect(htmlSource).toContain("flog('measure-skip-small-width'")
expect(htmlSource).toContain("notify({ type: 'measure-result', cols: null, rows: null });")
expect(htmlSource).toContain('scope.MIN_FIT_COLS = 20;')
expect(htmlSource).toContain('if (cols < scope.MIN_FIT_COLS) {')
expect(htmlSource).toContain('flog("measure-skip-small-width"')
expect(htmlSource).toContain('notify({ type: "measure-result", cols: null, rows: null });')
})
// Why: the raw-source assertions above pass even if the reflow module is
// dropped from the XTERM_HTML concatenation (a broken/removed import or an
// emptied TERMINAL_REFLOW_JS leaves the `${...}` placeholder in the template
// but never injects the routine). That was the regression class reported when
// a sibling refactor extracted the tap dispatcher next to the reflow inject.
// Guard the *assembled* document so the routine and its dispatch are really
// present in what the WebView runs.
// Why: the assertions above read the reflow module's own emission, which still reads whole if
// the generator drops the module from the document or emits it twice. That was the regression
// class reported when a sibling refactor extracted the tap dispatcher next to reflow. Guard the
// assembled document so the routine, once, and its dispatch are really in what the WebView runs.
describe('assembled XTERM_HTML', () => {
it('still injects the reflow routine (placeholder fully expanded)', () => {
it('carries the reflow routine exactly once', () => {
expect(XTERM_HTML).toContain('function reflow(cols, rows) {')
expect(XTERM_HTML).toContain('term.resize(nextCols, nextRows);')
// No unexpanded template placeholder for the injected reflow JS.
expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}')
expect(XTERM_HTML).toContain('scope.term.resize(nextCols, nextRows);')
expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1)
})
it('still routes the reflow message to the injected routine', () => {
expect(XTERM_HTML).toContain("} else if (msg.type === 'reflow') {")
expect(XTERM_HTML).toContain('} else if (msg.type === "reflow") {')
expect(XTERM_HTML).toContain('reflow(msg.cols, msg.rows);')
})
@@ -84,8 +77,8 @@ describe('terminal WebView reflow', () => {
// between them; if its IIFE-time code threw, the listener below would
// never bind and reflow messages would silently no-op.
const reflowAt = XTERM_HTML.indexOf('function reflow(cols, rows) {')
const dispatchAt = XTERM_HTML.indexOf("var dispatch = { mode: 'idle'")
const listenerAt = XTERM_HTML.indexOf("window.addEventListener('message'")
const dispatchAt = XTERM_HTML.indexOf('const dispatch = {\n mode: "idle"')
const listenerAt = XTERM_HTML.indexOf('window.addEventListener("message"')
expect(reflowAt).toBeGreaterThanOrEqual(0)
expect(dispatchAt).toBeGreaterThan(reflowAt)
expect(listenerAt).toBeGreaterThan(dispatchAt)
@@ -1,15 +1,13 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
import { XTERM_HTML } from './terminal-webview-html'
// The in-WebView JS lives in terminal-webview-html.ts; the RN wrapper in
// TerminalWebView.tsx. Concatenate both so assertions resolve regardless of file.
// The RN wrapper and the pending-message queue are TypeScript; everything the WebView runs is the
// generated document. Concatenated so assertions resolve regardless of file.
const source =
readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-url-tap.ts', import.meta.url), 'utf8') +
readFileSync(new URL('./terminal-webview-tap-dispatch-injected.ts', import.meta.url), 'utf8') +
readTerminalWebViewHtmlSource()
XTERM_HTML
const sessionSource = readFileSync(
new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url),
'utf8'
@@ -33,9 +31,11 @@ describe('TerminalWebView scroll routing', () => {
})
it('maps a downward pull at the bottom to older scrollback rows', () => {
expect(source).toContain('var deltaY = ts.lastY - y;')
expect(source).toContain('smoothScrollOffsetY -= deltaY;')
expect(source).toContain('var lines = Math.trunc(-smoothScrollOffsetY / effectiveCellH);')
expect(source).toContain('const deltaY = ts.lastY - y;')
expect(source).toContain('scope.smoothScrollOffsetY -= deltaY;')
expect(source).toContain(
'const lines = Math.trunc(-scope.smoothScrollOffsetY / effectiveCellH);'
)
const nextViewportY = simulateNormalBufferPull({
baseY: 120,
@@ -54,15 +54,18 @@ describe('TerminalWebView scroll routing', () => {
)
const touchMoveBlock = sliceBetween(
"targetSurface.addEventListener('touchmove'",
'}, { capture: true, passive: false });'
'targetSurface.addEventListener(\n "touchmove"',
'{ capture: true, passive: false }'
)
expect(touchMoveBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan(
touchMoveBlock.indexOf('if (enqueueNormalBufferScrollDelta(deltaY))')
)
expect(touchMoveBlock).toContain('routeScrollLines(lines, x, y);')
const momentumBlock = sliceBetween('function momentumStep()', 'if (Math.abs(vel) > MIN_VEL)')
const momentumBlock = sliceBetween(
'let momentumStep = function()',
'if (Math.abs(vel) > MIN_VEL)'
)
expect(momentumBlock.indexOf('if (shouldRouteScrollToTerminalInput())')).toBeLessThan(
momentumBlock.indexOf('if (!applyNormalBufferScrollDelta(delta))')
)
@@ -81,13 +84,16 @@ describe('TerminalWebView scroll routing', () => {
expect(smoothScrollBlock).toContain('return true;')
const touchMoveBlock = sliceBetween(
"targetSurface.addEventListener('touchmove'",
'}, { capture: true, passive: false });'
'targetSurface.addEventListener(\n "touchmove"',
'{ capture: true, passive: false }'
)
expect(touchMoveBlock).toContain('if (enqueueNormalBufferScrollDelta(deltaY))')
expect(touchMoveBlock).toContain('ts.velY = 0;')
const momentumBlock = sliceBetween('function momentumStep()', 'if (Math.abs(vel) > MIN_VEL)')
const momentumBlock = sliceBetween(
'let momentumStep = function()',
'if (Math.abs(vel) > MIN_VEL)'
)
expect(momentumBlock).toContain('if (!applyNormalBufferScrollDelta(delta))')
expect(momentumBlock).toContain('ts.momentumId = null;')
})
@@ -97,24 +103,24 @@ describe('TerminalWebView scroll routing', () => {
'function enqueueNormalBufferScrollDelta(deltaY)',
'function resetSmoothScrollOffset()'
)
expect(enqueueBlock).toContain('pendingNormalScrollDeltaY += deltaY;')
expect(enqueueBlock).toContain('if (normalScrollFrameId !== null) return true;')
expect(enqueueBlock).toContain('normalScrollFrameId = requestAnimationFrame(function()')
expect(enqueueBlock).toContain('scope.pendingNormalScrollDeltaY += deltaY;')
expect(enqueueBlock).toContain('if (scope.normalScrollFrameId !== null) {')
expect(enqueueBlock).toContain('scope.normalScrollFrameId = requestAnimationFrame(function()')
expect(enqueueBlock).toContain('applyNormalBufferScrollDelta(delta)')
const resetBlock = sliceBetween(
'function resetSmoothScrollOffset()',
'function cellToViewportPx'
)
expect(resetBlock).toContain('pendingNormalScrollDeltaY = 0;')
expect(resetBlock).toContain('cancelAnimationFrame(normalScrollFrameId);')
expect(resetBlock).toContain('scope.pendingNormalScrollDeltaY = 0;')
expect(resetBlock).toContain('cancelAnimationFrame(scope.normalScrollFrameId);')
})
it('drains terminal writes without shifting the queued array', () => {
expect(source).toContain('var writeQueueHead = 0;')
expect(source).toContain('scope.writeQueueHead = 0;')
expect(source).toContain('function nextQueuedWrite()')
expect(source).toContain('writeQueueHead++;')
expect(source).toContain('writeQueue = writeQueue.slice(writeQueueHead);')
expect(source).toContain('scope.writeQueueHead++;')
expect(source).toContain('scope.writeQueue = scope.writeQueue.slice(scope.writeQueueHead);')
expect(source).not.toContain('writeQueue.shift()')
})
@@ -159,67 +165,67 @@ describe('TerminalWebView scroll routing', () => {
'function updateScrollIndicator(reveal)'
)
expect(updateTransformBlock).toContain(
"surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')';"
'scope.surface.style.transform = "translate(" + scope.panX + "px," + scope.panY + "px) scale(" + getTotalScale() + ")"'
)
expect(source).not.toContain("querySelector('.xterm-screen')")
expect(source).not.toContain('updateTerminalScreenTransform')
expect(updateTransformBlock).not.toContain("getVisualPanY() + 'px) scale('")
expect(updateTransformBlock).not.toContain('getVisualPanY() + "px) scale("')
expect(updateTransformBlock).not.toContain('smoothScrollOffsetY')
})
it('smooths velocity samples and uses lower friction for mobile momentum', () => {
expect(source).toContain('function updateTouchVelocity(deltaY, dt)')
expect(source).toContain('ts.velY * 0.55 + instantVelocity * 0.45')
expect(source).toContain('var FRICTION = 0.972;')
expect(source).toContain('var MIN_VEL = 0.012;')
expect(source).toContain('const FRICTION = 0.972;')
expect(source).toContain('const MIN_VEL = 0.012;')
})
it('keeps selection edge autoscroll active and extends the dragged endpoint', () => {
const startBlock = sliceBetween('function startEdgeScroll(dir)', 'function stopEdgeScroll()')
expect(startBlock.indexOf('stopEdgeScroll();')).toBeLessThan(
startBlock.indexOf('edgeScrollDir = dir;')
startBlock.indexOf('scope.edgeScrollDir = dir;')
)
expect(startBlock.indexOf('term.scrollLines(edgeScrollDir);')).toBeLessThan(
expect(startBlock.indexOf('scope.term.scrollLines(scope.edgeScrollDir);')).toBeLessThan(
startBlock.indexOf('syncEdgeScrollSelectionEndpoint();')
)
const dragMoveBlock = sliceBetween(
'function handleDragMove(handle, clientX, clientY)',
' // Latching document-level touch dispatcher: see'
'function attachSurfaceEventHandlers('
)
expect(dragMoveBlock).toContain('edgeScrollClientX = clientX;')
expect(dragMoveBlock).toContain('edgeScrollClientY = clientY;')
expect(dragMoveBlock).toContain('scope.edgeScrollClientX = clientX;')
expect(dragMoveBlock).toContain('scope.edgeScrollClientY = clientY;')
expect(dragMoveBlock).toContain('syncSelectionHandleToViewportPoint(handle, clientX, clientY)')
})
it('opens links and paths from surface taps before mouse/focus fallback', () => {
expect(source).toContain('function buildMouseClickInput(clientX, clientY)')
expect(source).toContain('function isClickMouseTrackingMode(mode)')
expect(source).toContain("return mode !== 'none';")
expect(source).toContain('var pixelX = cell.x;')
expect(source).toContain('var pixelY = cell.y;')
expect(source).toContain('return mode !== "none";')
expect(source).toContain('const pixelX = cell.x;')
expect(source).toContain('const pixelY = cell.y;')
expect(source).toContain(
'if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return'
'if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) {'
)
expect(source).toContain(
'if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return'
'if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) {'
)
expect(source).toContain("if (mouseTrackingMode === 'x10') return pixelPress;")
expect(source).toContain("if (mouseTrackingMode === 'x10') return sgrPress;")
expect(source).toContain("if (mouseTrackingMode === 'x10') return press;")
expect(source).toContain("if (col > 126 || row > 126) return '';")
expect(source).toContain('if (mouseTrackingMode === "x10") {\n return pixelPress;')
expect(source).toContain('if (mouseTrackingMode === "x10") {\n return sgrPress;')
expect(source).toContain('if (mouseTrackingMode === "x10") {\n return press;')
expect(source).toContain('if (col > 126 || row > 126) {\n return "";')
const touchEndBlock = sliceBetween(
"document.addEventListener('touchend'",
'}, { capture: true, passive: true });'
'document.addEventListener(\n "touchend"',
'{ capture: true, passive: true }'
)
expect(touchEndBlock).toContain(
'notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true)'
'notifyTerminalSurfaceTap(scope.tapCandidate.x, scope.tapCandidate.y, true)'
)
const tapHandlerBlock = sliceBetween(
'function notifyTerminalSurfaceTap(originX, originY, focusKeyboard)',
"document.addEventListener('touchstart'"
'document.addEventListener(\n "touchstart"'
)
expect(tapHandlerBlock.indexOf('oscLinkAtViewportPoint')).toBeLessThan(
tapHandlerBlock.indexOf('urlAtViewportPoint')
@@ -228,14 +234,14 @@ describe('TerminalWebView scroll routing', () => {
tapHandlerBlock.indexOf('filePathAtViewportPoint')
)
expect(tapHandlerBlock.indexOf('filePathAtViewportPoint')).toBeLessThan(
tapHandlerBlock.indexOf('var clickInput = buildMouseClickInput')
tapHandlerBlock.indexOf('const clickInput = buildMouseClickInput')
)
expect(tapHandlerBlock).toContain("notify({ type: 'open-url', url: tappedUrl });")
expect(tapHandlerBlock).toContain("notify({ type: 'terminal-input', bytes: clickInput });")
expect(tapHandlerBlock).toContain('notify({ type: "open-url", url: tappedUrl });')
expect(tapHandlerBlock).toContain('notify({ type: "terminal-input", bytes: clickInput });')
expect(tapHandlerBlock).toContain(
'if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode()))'
)
expect(tapHandlerBlock).toContain("notify({ type: 'terminal-tap' });")
expect(tapHandlerBlock).toContain('notify({ type: "terminal-tap" });')
})
it('allows x10 mouse gesture reports through the mobile session gate', () => {
@@ -1,49 +0,0 @@
export const TERMINAL_SURFACE_SWAP_JS = String.raw`
// Why: phone-fit startup can issue several init() calls before xterm finishes
// replaying. Track the last painted surface separately from its replacement.
var committedTerm = null;
var committedSurface = surface;
var pendingTerm = null;
var pendingSurface = null;
function beginTerminalSurfaceSwap() {
// Why: a superseded hidden replacement must not remain between the last
// painted surface and the newest one, or the newest commits below the viewport.
if (pendingSurface) {
try { pendingSurface.remove(); } catch (e) {}
if (pendingTerm) try { pendingTerm.dispose(); } catch (e) {}
pendingSurface = null;
pendingTerm = null;
}
var swap = {
oldTerm: committedTerm,
oldSurface: committedSurface,
nextSurface: document.createElement('div')
};
disposeTermObservers();
swap.nextSurface.id = 'terminal-surface';
swap.nextSurface.style.visibility = 'hidden';
swap.nextSurface.style.position = 'absolute';
swap.nextSurface.style.left = '0';
swap.nextSurface.style.top = '0';
document.getElementById('terminal-container').appendChild(swap.nextSurface);
surface = swap.nextSurface;
pendingSurface = swap.nextSurface;
attachSurfaceEventHandlers(surface);
swap.oldSurface.removeAttribute('id');
return swap;
}
function commitTerminalSurfaceSwap(swap, nextTerm) {
swap.nextSurface.style.visibility = 'visible';
swap.nextSurface.style.position = '';
swap.nextSurface.style.left = '';
swap.nextSurface.style.top = '';
swap.oldSurface.remove();
if (swap.oldTerm) swap.oldTerm.dispose();
committedTerm = nextTerm;
committedSurface = swap.nextSurface;
pendingTerm = null;
pendingSurface = null;
}
`
@@ -1,188 +0,0 @@
// Document-level latching touch dispatcher, injected into XTERM_HTML. Extracted
// from terminal-webview-html.ts to keep that file within its max-lines budget.
// Closes over host-IIFE state/functions: dispatch/tapCandidate/longPress*,
// viewportToCell, enterSelect, cancelSelect, handleDragMove, stopEdgeScroll,
// notify, notifyTerminalSurfaceTap, surface/handle/overlay elements, sel/selMode,
// and the LONG_PRESS_*/TAP_* constants.
export const TERMINAL_TAP_DISPATCH_JS = `
// ============================================================
// LATCHING TOUCH DISPATCHER (document-level)
// ============================================================
var dispatch = { mode: 'idle', touchId: null, touchIds: null, longPressFingerInsideOverlay: false };
function touchById(touches, id) {
for (var i = 0; i < touches.length; i++) {
if (touches[i].identifier === id) return touches[i];
}
return null;
}
function targetInside(target, el) {
if (!target || !el) return false;
return el.contains(target);
}
function clearLongPress() {
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
longPressOrigin = null;
}
function armLongPress(touch) {
longPressOrigin = { x: touch.clientX, y: touch.clientY, identifier: touch.identifier };
longPressTimer = setTimeout(function() {
longPressTimer = null;
if (!longPressOrigin) return;
var c = viewportToCell(longPressOrigin.x, longPressOrigin.y);
if (!c) return;
enterSelect(c.col, c.row);
}, LONG_PRESS_MS);
}
function touchSlopExceeded(t) {
if (!longPressOrigin) return false;
var dx = Math.abs(t.clientX - longPressOrigin.x);
var dy = Math.abs(t.clientY - longPressOrigin.y);
return (dx + dy) > LONG_PRESS_SLOP;
}
// Why: existing surface handlers stay attached to surface but we wrap
// their entry to no-op when the dispatcher latches into select-drag.
function dispatcherShouldBlockSurface() {
return dispatch.mode === 'select-drag';
}
document.addEventListener('touchstart', function(e) {
var t = e.touches[0];
var target = e.target;
var onHandle = target === handleStart || target === handleEnd;
var inOverlay = targetInside(target, selectionOverlay);
var inSurface = targetInside(target, surface);
// Why: clear any stale tap candidate up front; only a fresh single-finger
// surface touch (below) re-arms it, so handle drags / pinches / dismiss
// taps never resolve as a link tap on touchend.
tapCandidate = null;
if (e.touches.length === 2) {
// pinch latch
if (selMode === 'select') {
notify({ type: 'mobile-clip-cancel-by-pinch' });
cancelSelect();
}
dispatch.mode = 'pinch';
dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier];
clearLongPress();
return;
}
if (onHandle && selMode === 'select') {
// start handle drag
var handleName = (target === handleStart) ? 'start' : 'end';
sel.activeHandle = handleName;
dispatch.mode = 'select-drag';
dispatch.touchId = t.identifier;
e.preventDefault();
return;
}
if (inOverlay) {
// tap on menu pill — let the buttons' own handlers fire
return;
}
if (inSurface && selMode === 'select') {
// Why: tap-to-dismiss matches native iOS/Android — touching outside the
// selection clears it. We cancel immediately and latch to 'surface' so
// the same gesture still drives scroll/pan without a second touch.
cancelSelect();
dispatch.mode = 'surface';
dispatch.touchId = t.identifier;
return;
}
if (inSurface) {
dispatch.mode = 'surface';
dispatch.touchId = t.identifier;
tapCandidate = { x: t.clientX, y: t.clientY, t: Date.now(), identifier: t.identifier };
armLongPress(t);
}
}, { capture: true, passive: false });
document.addEventListener('touchmove', function(e) {
if (dispatch.mode === 'select-drag') {
var t = touchById(e.touches, dispatch.touchId);
if (!t || !sel || !sel.activeHandle) return;
e.preventDefault();
handleDragMove(sel.activeHandle, t.clientX, t.clientY);
return;
}
if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') {
// long-press slop check
if (longPressTimer && e.touches.length === 1) {
if (touchSlopExceeded(e.touches[0])) clearLongPress();
}
// Why: disqualify the tap only once the finger travels past TAP_SLOP
// (a scroll/pan), independent of the long-press timer — so a tap that
// jitters under TAP_SLOP still opens the link/path under the finger.
if (tapCandidate && e.touches.length === 1) {
var mt = e.touches[0];
if (mt.identifier === tapCandidate.identifier) {
var dx = Math.abs(mt.clientX - tapCandidate.x);
var dy = Math.abs(mt.clientY - tapCandidate.y);
if (dx + dy > TAP_SLOP) tapCandidate = null;
}
} else if (e.touches.length !== 1) {
tapCandidate = null;
}
// existing surface handler will run from its own listener
}
}, { capture: true, passive: false });
document.addEventListener('touchend', function(e) {
if (dispatch.mode === 'select-drag') {
if (sel) sel.activeHandle = null;
stopEdgeScroll();
dispatch.mode = 'idle';
dispatch.touchId = null;
return;
}
if (dispatch.mode === 'pinch') {
if (e.touches.length < 2) {
dispatch.mode = (e.touches.length === 1) ? 'surface' : 'idle';
dispatch.touchIds = null;
if (e.touches.length === 1) dispatch.touchId = e.touches[0].identifier;
}
return;
}
if (dispatch.mode === 'surface') {
// Why: fire the tap from the tap-candidate origin (survives jitter under
// TAP_SLOP) rather than longPressOrigin, which the press-to-select slop
// can null mid-tap — that was dropping URL/file taps that moved a few px.
if (
e.touches.length === 0 &&
tapCandidate &&
selMode !== 'select' &&
Date.now() - tapCandidate.t <= TAP_MAX_MS
) {
notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true);
}
clearLongPress();
tapCandidate = null;
if (e.touches.length === 0) {
dispatch.mode = 'idle';
dispatch.touchId = null;
}
}
}, { capture: true, passive: true });
document.addEventListener('touchcancel', function() {
clearLongPress();
tapCandidate = null;
stopEdgeScroll();
if (dispatch.mode === 'select-drag') {
if (sel) sel.activeHandle = null;
}
dispatch.mode = 'idle';
dispatch.touchId = null;
dispatch.touchIds = null;
}, { capture: true, passive: true });
`
@@ -1,7 +1,11 @@
import { readFileSync } from 'node:fs'
import { Script } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { readTerminalWebViewHtmlSource } from './terminal-webview-html-source.test-support'
import {
documentScopePreamble,
generatedDocumentModule
} from './document/generated-document-region.test-support'
import { XTERM_HTML } from './terminal-webview-html'
const terminalWebViewSource = readFileSync(
new URL('./TerminalWebView.tsx', import.meta.url),
@@ -15,24 +19,22 @@ const terminalHtmlDocumentShellSource = readFileSync(
new URL('./terminal-webview-html/document-shell.ts', import.meta.url),
'utf8'
)
// Read behavior from the assembled document; the module source only contains
// fragment imports and cannot prove the injected code is present.
const terminalHtmlSource = readTerminalWebViewHtmlSource()
const terminalWebglRecoverySource = readFileSync(
new URL('./terminal-webview-webgl-recovery-injected.ts', import.meta.url),
'utf8'
)
// Read behavior from the assembled document: it is what the WebView runs, and the module source
// alone cannot prove the generated script carries the code.
const terminalHtmlSource = XTERM_HTML
const terminalWebglRecoverySource = await generatedDocumentModule('webgl-recovery')
function extractStatusDotNormalizer() {
const declarationStart = terminalHtmlSource.indexOf(' var CLAUDE_STATUS_DOT =')
const declarationEnd = terminalHtmlSource.indexOf(' var PRIVATE_MODE_SCAN_TAIL_LIMIT')
const declarationStart = terminalHtmlSource.indexOf(' scope.CLAUDE_STATUS_DOT =')
const declarationEnd = terminalHtmlSource.indexOf(' scope.PRIVATE_MODE_SCAN_TAIL_LIMIT')
const functionStart = terminalHtmlSource.indexOf(' function isStatusDotPresentationSelector')
const functionEnd = terminalHtmlSource.indexOf('\n\n function enqueueWrite', functionStart)
const functionEnd = terminalHtmlSource.indexOf('\n function enqueueWrite', functionStart)
expect(declarationStart).toBeGreaterThanOrEqual(0)
expect(declarationEnd).toBeGreaterThan(declarationStart)
expect(functionStart).toBeGreaterThan(declarationEnd)
expect(functionEnd).toBeGreaterThan(functionStart)
return `${terminalHtmlSource.slice(declarationStart, declarationEnd)}\n${terminalHtmlSource.slice(functionStart, functionEnd)}`
return `${documentScopePreamble()}${terminalHtmlSource.slice(declarationStart, declarationEnd)}\n${terminalHtmlSource.slice(functionStart, functionEnd)}`
}
function normalizeStatusDotChunks(chunks: string[]) {
@@ -52,8 +54,8 @@ function resolveTerminalFontFamily(navigatorValue: {
// Slice only the font block itself (isIOSWebView + terminalFontFamily), anchored
// on font-related markers so unrelated edits below it can't break this extraction.
const functionStart = terminalHtmlSource.indexOf(' function isIOSWebView()')
const declarationLine = terminalHtmlSource.indexOf(' var terminalFontFamily =', functionStart)
const declarationEnd = terminalHtmlSource.indexOf('\n', declarationLine)
const declarationLine = terminalHtmlSource.indexOf(' scope.terminalFontFamily =', functionStart)
const declarationEnd = terminalHtmlSource.indexOf(';\n', declarationLine) + 1
expect(functionStart).toBeGreaterThanOrEqual(0)
expect(declarationLine).toBeGreaterThan(functionStart)
expect(declarationEnd).toBeGreaterThan(declarationLine)
@@ -61,8 +63,8 @@ function resolveTerminalFontFamily(navigatorValue: {
navigator: navigatorValue
}
new Script(`
${terminalHtmlSource.slice(functionStart, declarationEnd)}
output = terminalFontFamily;
${documentScopePreamble()}${terminalHtmlSource.slice(functionStart, declarationEnd)}
output = scope.terminalFontFamily;
`).runInNewContext(context)
return context.output ?? ''
}
@@ -92,16 +94,20 @@ describe('TerminalWebView text zoom', () => {
it('forces the Claude status dot to text presentation before xterm writes', () => {
expect(terminalHtmlSource).toContain('font-variant-emoji: text')
expect(terminalHtmlSource).toContain('var CLAUDE_STATUS_DOT = String.fromCharCode(0x23fa)')
expect(terminalHtmlSource).toContain('TEXT_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0e)')
expect(terminalHtmlSource).toContain('scope.CLAUDE_STATUS_DOT = String.fromCharCode(9210)')
expect(terminalHtmlSource).toContain(
'EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(0xfe0f)'
'scope.TEXT_PRESENTATION_SELECTOR = String.fromCharCode(65038)'
)
expect(terminalHtmlSource).toContain(
'scope.EMOJI_PRESENTATION_SELECTOR = String.fromCharCode(65039)'
)
expect(terminalHtmlSource).toContain('function normalizeStatusDotPresentation(data)')
expect(terminalHtmlSource).toContain(
'data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR)'
'data.replace(\n scope.CLAUDE_STATUS_DOT_PATTERN,\n scope.CLAUDE_STATUS_DOT + scope.TEXT_PRESENTATION_SELECTOR\n )'
)
expect(terminalHtmlSource).toContain(
'scope.writeQueue.push(normalizeStatusDotPresentation(data))'
)
expect(terminalHtmlSource).toContain('writeQueue.push(normalizeStatusDotPresentation(data))')
})
it('normalizes Claude status dots idempotently across write chunks', () => {
@@ -133,28 +139,28 @@ describe('TerminalWebView text zoom', () => {
it('resets pending Claude status dot selector state when the terminal lifecycle resets', () => {
const initStart = terminalHtmlSource.indexOf('function init(')
const initReplay = terminalHtmlSource.indexOf(
'var replayData = normalizeInitialData(initialData)'
'const replayData = normalizeInitialData(initialData)'
)
const clearStart = terminalHtmlSource.indexOf("} else if (msg.type === 'clear') {")
const clearEnd = terminalHtmlSource.indexOf("} else if (msg.type === 'measure')", clearStart)
const clearStart = terminalHtmlSource.indexOf('} else if (msg.type === "clear") {')
const clearEnd = terminalHtmlSource.indexOf('} else if (msg.type === "measure")', clearStart)
expect(initStart).toBeGreaterThanOrEqual(0)
expect(initReplay).toBeGreaterThan(initStart)
expect(clearStart).toBeGreaterThanOrEqual(0)
expect(clearEnd).toBeGreaterThan(clearStart)
expect(terminalHtmlSource.slice(initStart, initReplay)).toContain(
'statusDotPendingSelector = false'
'scope.statusDotPendingSelector = false'
)
expect(terminalHtmlSource.slice(clearStart, clearEnd)).toContain(
'statusDotPendingSelector = false'
'scope.statusDotPendingSelector = false'
)
})
it('loads Unicode 11 before replaying mobile terminal bytes', () => {
expect(terminalHtmlDocumentShellSource).toContain('XTERM_ENGINE_JS')
expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon')
const open = terminalHtmlSource.indexOf('term.open(surface)')
const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")
const replay = terminalHtmlSource.indexOf("enqueueWrite(ESC + '[0m' + replayData)")
const open = terminalHtmlSource.indexOf('scope.term.open(scope.surface)')
const unicode = terminalHtmlSource.indexOf('scope.term.unicode.activeVersion = "11"')
const replay = terminalHtmlSource.indexOf('enqueueWrite(scope.ESC + "[0m" + replayData)')
expect(open).toBeGreaterThanOrEqual(0)
expect(unicode).toBeGreaterThan(open)
expect(replay).toBeGreaterThan(unicode)
@@ -164,9 +170,9 @@ describe('TerminalWebView text zoom', () => {
expect(terminalHtmlSource).not.toContain('cdn.jsdelivr.net')
expect(terminalWebglRecoverySource).toContain('window.WebglAddon.WebglAddon')
expect(terminalHtmlSource).toContain('function isIOSWebView()')
expect(terminalHtmlSource).toContain('fontFamily: terminalFontFamily')
expect(terminalHtmlSource).toContain("fontWeight: '300'")
expect(terminalHtmlSource).toContain("fontWeightBold: '500'")
expect(terminalHtmlSource).toContain('fontFamily: scope.terminalFontFamily')
expect(terminalHtmlSource).toContain('fontWeight: "300"')
expect(terminalHtmlSource).toContain('fontWeightBold: "500"')
expect(terminalWebglRecoverySource).toContain('new window.WebglAddon.WebglAddon()')
})
@@ -1,122 +0,0 @@
import { colors } from '../theme/mobile-theme'
// Theme normalization and page-surface painting injected into the WebView IIFE.
// Mirrors the desktop minimumContrastRatio gate (src/renderer/src/lib/terminal-contrast-correction.ts,
// #7934/#10104): a dark composed background gets a mild floor of 3 to rescue near-background body text
// (e.g. Antigravity's #262b30 on #1e242a) without over-brightening vibrant ANSI colors; a light
// background keeps the WCAG-AA 4.5 floor. Gate on the composed background luminance, not app mode,
// because either theme slot can hold either kind of theme. An explicit desktop override published on
// the theme payload (#10754) wins over the luminance gate; older hosts simply omit it.
export const TERMINAL_WEBVIEW_THEME_JS = `
var DARK_BG_MIN_CONTRAST = 3;
var LIGHT_BG_MIN_CONTRAST = 4.5;
// Dark app surface a transparent terminal background composites over (matches desktop APP_SURFACE_COLORS.dark).
var CONTRAST_APP_SURFACE = { r: 10, g: 10, b: 10 };
function parseTerminalBackgroundRgba(value) {
if (typeof value !== 'string') return null;
var v = value.trim().toLowerCase();
if (!v) return null;
if (v === 'black') return { r: 0, g: 0, b: 0, a: 1 };
if (v === 'white') return { r: 255, g: 255, b: 255, a: 1 };
if (v === 'transparent') return { r: 0, g: 0, b: 0, a: 0 };
var hex = v.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
if (hex) {
var h = hex[1];
var ch;
if (h.length === 3 || h.length === 4) {
ch = h.split('').map(function (p) { return parseInt(p + p, 16); });
} else {
ch = [];
for (var i = 0; i < h.length; i += 2) ch.push(parseInt(h.slice(i, i + 2), 16));
}
return { r: ch[0], g: ch[1], b: ch[2], a: ch[3] === undefined ? 1 : ch[3] / 255 };
}
var rgb = v.match(/^rgba?\\(([^)]+)\\)$/);
if (!rgb) return null;
var parts = rgb[1].indexOf(',') >= 0 ? rgb[1].split(',') : rgb[1].split(/[\\s/]+/);
parts = parts.map(function (p) { return p.trim(); }).filter(function (p) { return p.length > 0; });
if (parts.length < 3) return null;
var channel = function (p) {
var n = p.charAt(p.length - 1) === '%' ? (parseFloat(p) / 100) * 255 : parseFloat(p);
return isFinite(n) ? Math.min(255, Math.max(0, Math.round(n))) : null;
};
var r = channel(parts[0]), g = channel(parts[1]), b = channel(parts[2]);
if (r === null || g === null || b === null) return null;
var a = 1;
if (parts[3] !== undefined) {
var raw = parts[3].charAt(parts[3].length - 1) === '%' ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]);
a = isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1;
}
return { r: r, g: g, b: b, a: a };
}
function terminalRelativeLuminance(rgb) {
var lin = function (c) {
var n = c / 255;
return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4);
};
return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);
}
function terminalContrastRatio(a, b) {
var la = terminalRelativeLuminance(a), lb = terminalRelativeLuminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
// Clamp an explicit desktop override to xterm's 1-21 range; null means "no usable override".
function normalizeTerminalContrastOverride(value) {
if (typeof value !== 'number' || !isFinite(value)) return null;
return Math.min(21, Math.max(1, value));
}
// Pick the xterm minimumContrastRatio floor from the composed terminal background.
// Unparseable input defaults to the dark floor so agent output never stays invisible.
function resolveTerminalContrastFloor(background) {
var color = parseTerminalBackgroundRgba(background);
if (!color) return DARK_BG_MIN_CONTRAST;
var composited = color.a < 1
? {
r: Math.round(color.r * color.a + CONTRAST_APP_SURFACE.r * (1 - color.a)),
g: Math.round(color.g * color.a + CONTRAST_APP_SURFACE.g * (1 - color.a)),
b: Math.round(color.b * color.a + CONTRAST_APP_SURFACE.b * (1 - color.a))
}
: color;
var isLight = terminalContrastRatio({ r: 0, g: 0, b: 0 }, composited) >=
terminalContrastRatio({ r: 255, g: 255, b: 255 }, composited);
return isLight ? LIGHT_BG_MIN_CONTRAST : DARK_BG_MIN_CONTRAST;
}
function normalizeTerminalTheme(input) {
var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object'
? input.theme
: null;
if (!source) return defaultTheme;
var next = {};
var keys = Object.keys(defaultTheme);
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
if (typeof source[key] === 'string') next[key] = source[key];
}
return Object.assign({}, defaultTheme, next);
}
function applyTerminalTheme(input) {
terminalThemeInput = input;
terminalTheme = normalizeTerminalTheme(input);
var background = terminalTheme.background || '${colors.terminalBg}';
document.documentElement.style.background = background;
document.body.style.background = background;
// Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754);
// an older host omits the field and the luminance gate stays authoritative.
var publishedFloor = normalizeTerminalContrastOverride(
input && typeof input === 'object' ? input.minimumContrastRatio : undefined
);
terminalMinimumContrastRatio =
publishedFloor === null ? resolveTerminalContrastFloor(background) : publishedFloor;
if (term) {
term.options.theme = terminalTheme;
term.options.minimumContrastRatio = terminalMinimumContrastRatio;
}
}
`
@@ -1,49 +1,69 @@
import { Script } from 'node:vm'
import { parse } from 'acorn'
import { describe, expect, it } from 'vitest'
import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
import {
documentDeclaredFunction,
documentScopePreamble,
generatedDocumentModule
} from './document/generated-document-region.test-support'
import type { TerminalDocumentThemeTarget } from './document/terminal-theme'
const themeSource = await generatedDocumentModule('terminal-theme')
const DARK_FLOOR = 3
const LIGHT_FLOOR = 4.5
// Eval the injected theme JS in a bare context so the declared helpers become
// callable properties on it (mirrors terminal-webview-engine.test.ts).
// Eval the theme block the document carries in a bare context so its declared helpers become
// callable properties on it (mirrors terminal-webview-engine.test.ts). The terminal it drives is
// a scope field in the document, so it is handed in through the scope rather than as a global.
function loadThemeInjected(extra: Record<string, unknown> = {}): Record<string, unknown> {
const context: Record<string, unknown> = {
defaultTheme: { background: '#1a1b26', foreground: '#c0caf5' },
...extra
}
new Script(TERMINAL_WEBVIEW_THEME_JS).runInNewContext(context)
const { term, ...globals } = extra
const context: Record<string, unknown> = { ...globals, hostTerm: term ?? null }
new Script(
`${documentScopePreamble()}
scope.defaultTheme = { background: "#1a1b26", foreground: "#c0caf5" };
scope.term = hostTerm;
${themeSource}`
).runInNewContext(context)
return context
}
function loadContrastFloorResolver(): (bg: unknown) => number {
return documentDeclaredFunction(loadThemeInjected(), 'resolveTerminalContrastFloor')
}
function loadThemeApplier(term: TerminalDocumentThemeTarget): (input: unknown) => void {
const context = loadThemeInjected({
term,
document: {
documentElement: { style: { background: '' } },
body: { style: { background: '' } }
}
})
return documentDeclaredFunction(context, 'applyTerminalTheme')
}
describe('mobile terminal-webview contrast floor gate', () => {
it('parses at the Chrome 74 syntax floor', () => {
expect(() => parse(TERMINAL_WEBVIEW_THEME_JS, { ecmaVersion: 2019 })).not.toThrow()
expect(() => parse(themeSource, { ecmaVersion: 2019 })).not.toThrow()
})
it('picks the dark floor for dark composed backgrounds', () => {
const { resolveTerminalContrastFloor } = loadThemeInjected() as {
resolveTerminalContrastFloor: (bg: unknown) => number
}
const resolveTerminalContrastFloor = loadContrastFloorResolver()
for (const bg of ['#1a1b26', '#1e242a', '#282828', '#000000', 'black']) {
expect(resolveTerminalContrastFloor(bg)).toBe(DARK_FLOOR)
}
})
it('picks the light floor for light composed backgrounds', () => {
const { resolveTerminalContrastFloor } = loadThemeInjected() as {
resolveTerminalContrastFloor: (bg: unknown) => number
}
const resolveTerminalContrastFloor = loadContrastFloorResolver()
for (const bg of ['#ffffff', '#fbf1c7', 'white', 'rgb(240 240 240)']) {
expect(resolveTerminalContrastFloor(bg)).toBe(LIGHT_FLOOR)
}
})
it('composites transparency over the dark app surface before deciding', () => {
const { resolveTerminalContrastFloor } = loadThemeInjected() as {
resolveTerminalContrastFloor: (bg: unknown) => number
}
const resolveTerminalContrastFloor = loadContrastFloorResolver()
// Fully transparent → app surface (dark) → dark floor.
expect(resolveTerminalContrastFloor('transparent')).toBe(DARK_FLOOR)
// Faint white over the dark surface stays dark; opaque-enough white flips light.
@@ -52,43 +72,28 @@ describe('mobile terminal-webview contrast floor gate', () => {
})
it('defaults unparseable backgrounds to the dark floor so output never stays invisible', () => {
const { resolveTerminalContrastFloor } = loadThemeInjected() as {
resolveTerminalContrastFloor: (bg: unknown) => number
}
const resolveTerminalContrastFloor = loadContrastFloorResolver()
for (const bg of [undefined, null, '', 'not-a-color', '#12', 42]) {
expect(resolveTerminalContrastFloor(bg)).toBe(DARK_FLOOR)
}
})
it('writes the resolved floor onto a live terminal when the theme changes', () => {
const term = { options: { theme: undefined as unknown, minimumContrastRatio: 1 } }
const context = loadThemeInjected({
term,
document: {
documentElement: { style: { background: '' } },
body: { style: { background: '' } }
}
}) as Record<string, unknown> & { applyTerminalTheme: (input: unknown) => void }
const term: TerminalDocumentThemeTarget = { options: { minimumContrastRatio: 1 } }
const applyTerminalTheme = loadThemeApplier(term)
context.applyTerminalTheme({ theme: { background: '#ffffff' } })
applyTerminalTheme({ theme: { background: '#ffffff' } })
expect(term.options.minimumContrastRatio).toBe(LIGHT_FLOOR)
context.applyTerminalTheme({ theme: { background: '#1e242a' } })
applyTerminalTheme({ theme: { background: '#1e242a' } })
expect(term.options.minimumContrastRatio).toBe(DARK_FLOOR)
})
// #10754: the desktop user can lower or disable the floor. Mobile mirrors the desktop gate, so the
// published value has to win here or the same session renders differently on the phone.
describe('published desktop override', () => {
function applyOn(term: { options: { minimumContrastRatio: number } }, input: unknown): void {
const context = loadThemeInjected({
term,
document: {
documentElement: { style: { background: '' } },
body: { style: { background: '' } }
}
}) as Record<string, unknown> & { applyTerminalTheme: (input: unknown) => void }
context.applyTerminalTheme(input)
function applyOn(term: TerminalDocumentThemeTarget, input: unknown): void {
loadThemeApplier(term)(input)
}
it('uses the published floor instead of the luminance gate', () => {
@@ -1,11 +1,13 @@
import { createContext, Script } from 'node:vm'
import { describe, expect, it } from 'vitest'
import type { TappedFilePath } from './terminal-path-tap'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import {
documentDeclaredFunction,
generatedDocumentModule
} from './document/generated-document-region.test-support'
import {
TERMINAL_HTTP_URL_MAX_LENGTH,
TERMINAL_HTTP_URL_REGEX_SOURCE,
URL_TAP_WEBVIEW_JS,
findFileUrlAtColumn,
findUrlAtColumn,
resolveTerminalOscFileTap,
@@ -13,6 +15,13 @@ import {
} from './terminal-webview-url-tap'
import { XTERM_HTML } from './terminal-webview-html'
// The three modules the document carries the URL-tap group as, in its own order.
const urlTapGroupSource = (
await Promise.all(
['path-tap', 'url-tap', 'osc-link-tap', 'surface-tap'].map(generatedDocumentModule)
)
).join('\n')
type FileTapResolverCase = {
name: string
uri: string
@@ -99,19 +108,15 @@ function createInjectedFileTapResolvers(): {
resolveTerminalFileUrlTap: InjectedFileTapResolver
resolveTerminalOscFileTap: InjectedFileTapResolver
} {
const context = createContext({ URL })
const context: Record<string, unknown> = createContext({ URL })
new Script(
`${TERMINAL_PATH_TAP_JS}\n${URL_TAP_WEBVIEW_JS}\n` +
`${urlTapGroupSource}\n` +
'this.__resolveTerminalFileUrlTap = resolveTerminalFileUrlTap;\n' +
'this.__resolveTerminalOscFileTap = resolveTerminalOscFileTap;'
).runInContext(context)
const injected = context as {
__resolveTerminalFileUrlTap: InjectedFileTapResolver
__resolveTerminalOscFileTap: InjectedFileTapResolver
}
return {
resolveTerminalFileUrlTap: injected.__resolveTerminalFileUrlTap,
resolveTerminalOscFileTap: injected.__resolveTerminalOscFileTap
resolveTerminalFileUrlTap: documentDeclaredFunction(context, '__resolveTerminalFileUrlTap'),
resolveTerminalOscFileTap: documentDeclaredFunction(context, '__resolveTerminalOscFileTap')
}
}
@@ -204,6 +209,6 @@ describe('findUrlAtColumn', () => {
expect(XTERM_HTML).toContain('function isLocalFileUriHostname(')
expect(XTERM_HTML).toContain('return parsePathLineCol(value);')
expect(XTERM_HTML).toContain('function notifyTerminalSurfaceTap(')
expect(XTERM_HTML).toContain("notify({ type: 'open-url', url: tappedUrl });")
expect(XTERM_HTML).toContain('notify({ type: "open-url", url: tappedUrl });')
})
})
@@ -43,212 +43,3 @@ function findTerminalUrlAtColumn(lineText: string, col: number, source: string):
}
return null
}
export const URL_TAP_WEBVIEW_JS = `
var URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_HTTP_URL_REGEX_SOURCE)};
var FILE_URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_FILE_URL_REGEX_SOURCE)};
var URL_TAP_MAX_LENGTH = ${TERMINAL_HTTP_URL_MAX_LENGTH};
function findUrlAtColumn(lineText, col) {
return findTerminalUrlAtColumn(lineText, col, URL_TAP_RE_SOURCE);
}
function findFileUrlAtColumn(lineText, col) {
return findTerminalUrlAtColumn(lineText, col, FILE_URL_TAP_RE_SOURCE);
}
function findTerminalUrlAtColumn(lineText, col, source) {
if (typeof lineText !== 'string' || lineText.length === 0) return null;
var re = new RegExp(source, 'gi');
var match;
while ((match = re.exec(lineText)) !== null) {
var end = match.index + match[0].length;
if (match[0].length <= URL_TAP_MAX_LENGTH && col >= match.index && col < end) return match[0];
if (match[0].length === 0) re.lastIndex++;
}
return null;
}
function fileUrlAtViewportPoint(clientX, clientY) {
var cell = viewportToCell(clientX, clientY);
if (!cell) return null;
return findFileUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col));
}
function urlAtViewportPoint(clientX, clientY) {
var cell = viewportToCell(clientX, clientY);
if (!cell) return null;
// Map the cell column to a string index so wide chars earlier on the line
// don't shift the match column off the tapped URL.
return findUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col));
}
// Why: OSC 8 links can render as labels like "#1234"; the URI lives in
// xterm's internal link service, so every access is guarded and falls through.
function oscLinkService() {
try {
var core = term && term._core;
if (!core) return null;
return core._oscLinkService
|| (core._inputHandler && core._inputHandler._oscLinkService)
|| null;
} catch (e) { return null; }
}
function oscLinkAtViewportPoint(clientX, clientY) {
try {
var cell = viewportToCell(clientX, clientY);
if (!cell) return null;
var line = term.buffer.active.getLine(cell.row);
if (!line) return null;
var urlId = oscLinkIdAtCell(line, cell.col);
if (!urlId) return initialOscLinkAtCell(cell.row, cell.col);
var svc = oscLinkService();
if (!svc || !svc.getLinkData) return initialOscLinkAtCell(cell.row, cell.col);
var data = svc.getLinkData(urlId);
var uri = data && data.uri;
return terminalOscLinkTarget(uri);
} catch (e) { return null; }
}
function initialOscLinkAtCell(row, col) {
for (var i = 0; i < initialOscLinks.length; i++) {
var link = initialOscLinks[i];
if (!link || typeof link.uri !== 'string') continue;
if (link.row < initialOscLinkRowOffset) continue;
var shiftedRow = link.row - initialOscLinkRowOffset;
if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return terminalOscLinkTarget(link.uri);
}
return null;
}
function terminalOscLinkTarget(uri) {
if (typeof uri !== 'string') return null;
if (/^https?:/i.test(uri)) return { kind: 'url', url: uri };
var fileTap = resolveTerminalOscFileTap(uri);
return fileTap ? { kind: 'file', fileTap: fileTap } : null;
}
function resolveTerminalOscFileTap(uri) {
return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri);
}
function resolveTerminalFileUrlTap(uri) {
var parsed;
try {
parsed = new URL(uri);
} catch (e) {
return null;
}
if (parsed.protocol !== 'file:') return null;
var filePath;
try {
filePath = decodeURIComponent(parsed.pathname || '');
} catch (e) {
return null;
}
if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) {
filePath = '//' + parsed.hostname + filePath;
} else if (/^\\/[A-Za-z]:\\//.test(filePath)) {
filePath = filePath.slice(1);
}
if (!filePath) return null;
var hashTarget = parseFileUrlLineHash(parsed.hash || '');
if (hashTarget) {
return { pathText: filePath, line: hashTarget.line, column: hashTarget.column };
}
if (/%3a/i.test(parsed.pathname || '')) {
return { pathText: filePath, line: null, column: null };
}
return parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null };
}
function isLocalFileUriHostname(hostname) {
var normalized = String(hostname).toLowerCase();
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]';
}
function parseOscPathLikeTarget(value) {
if (!/^(?:~[\\\\/]|[\\\\/]|\\.{1,2}[\\\\/]|[A-Za-z]:[\\\\/]|[A-Za-z0-9._-]+[\\\\/]|(?=[A-Za-z0-9._-]*\\.[A-Za-z0-9]))/.test(value)) return null;
return parsePathLineCol(value);
}
function parseFileUrlLineHash(hash) {
var match = /^#?L(\\d+)(?:C(\\d+))?$/i.exec(hash);
if (!match) return null;
var line = parseInt(match[1], 10);
var column = match[2] ? parseInt(match[2], 10) : null;
if (line < 1 || (column !== null && column < 1)) return null;
return { line: line, column: column };
}
function parseFilePathTrailingLineTarget(filePath) {
var match = /^(.*?)(?::(\\d+))(?::(\\d+))?$/.exec(filePath);
if (!match || !match[1] || match[1].charAt(match[1].length - 1) === '/' || match[1].charAt(match[1].length - 1) === '\\\\') return null;
var line = parseInt(match[2], 10);
var column = match[3] ? parseInt(match[3], 10) : null;
if (line < 1 || (column !== null && column < 1)) return null;
return { pathText: match[1], line: line, column: column };
}
function captureInitialOscLinkTexts() {
if (!Array.isArray(initialOscLinks)) return;
for (var i = 0; i < initialOscLinks.length; i++) {
var link = initialOscLinks[i];
if (!link || typeof link.text === 'string') continue;
link.text = initialOscLinkTextAtRow(link, link.row);
}
}
function initialOscLinkTextStillMatches(link, row) {
if (typeof link.text !== 'string') return false;
return link.text.length > 0 && initialOscLinkTextAtRow(link, row) === link.text;
}
function initialOscLinkTextAtRow(link, row) {
try {
var lineText = getLineText(row);
var start = cellColToStringIndex(row, link.startCol);
var end = cellColToStringIndex(row, link.endCol);
return lineText.slice(start, end);
} catch (e) {
return '';
}
}
function oscLinkIdAtCell(line, col) {
try {
var bufCell = line.getCell(col);
return bufCell && bufCell.extended && bufCell.extended.urlId ? bufCell.extended.urlId : 0;
} catch (e) { return 0; }
}
function notifyTerminalSurfaceTap(originX, originY, focusKeyboard) {
var tappedOscLink = oscLinkAtViewportPoint(originX, originY);
if (tappedOscLink && tappedOscLink.kind === 'file') {
notify({
type: 'terminal-file-tap',
pathText: tappedOscLink.fileTap.pathText,
line: tappedOscLink.fileTap.line,
column: tappedOscLink.fileTap.column
});
return;
}
var tappedFileUrl = fileUrlAtViewportPoint(originX, originY);
var tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null;
if (tappedFileUrlPath) {
notify({
type: 'terminal-file-tap',
pathText: tappedFileUrlPath.pathText,
line: tappedFileUrlPath.line,
column: tappedFileUrlPath.column
});
return;
}
var tappedUrl = tappedOscLink && tappedOscLink.kind === 'url' ? tappedOscLink.url : urlAtViewportPoint(originX, originY);
if (tappedUrl) {
notify({ type: 'open-url', url: tappedUrl });
return;
}
var tappedPath = filePathAtViewportPoint(originX, originY);
if (tappedPath) {
notify({
type: 'terminal-file-tap',
pathText: tappedPath.pathText,
line: tappedPath.line,
column: tappedPath.column
});
return;
}
var clickInput = buildMouseClickInput(originX, originY);
if (clickInput) {
notify({ type: 'terminal-input', bytes: clickInput });
}
// Touch still needs native input focus after the TUI consumes its mouse click.
if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) {
notify({ type: 'terminal-tap' });
}
}
`
@@ -1,62 +0,0 @@
// WebGL loss and visibility recovery injected into the terminal WebView IIFE.
// It closes over term, terminalGeneration, theme state, and xterm's addon global.
export const TERMINAL_WEBGL_RECOVERY_JS = `
function refreshTerminalSurface() {
if (!term) return;
try { term.refresh(0, Math.max(0, term.rows - 1)); } catch (e) {}
}
function cancelWebglContextRecovery() {
if (!webglRecoveryTimer) return;
clearTimeout(webglRecoveryTimer);
webglRecoveryTimer = null;
}
function attachWebglAddon(allowRecovery) {
if (!term || !window.WebglAddon || !window.WebglAddon.WebglAddon) return false;
var addon = null;
try {
addon = new window.WebglAddon.WebglAddon();
webglAddon = addon;
if (addon.onContextLoss) addon.onContextLoss(function() {
if (webglAddon !== addon) return;
flog('webgl-context-loss', { retry: allowRecovery });
webglAddon = null;
try { addon.dispose(); } catch (e) {}
refreshTerminalSurface();
if (!allowRecovery) return;
// Why: one delayed retry handles transient iOS context loss without
// entering a GPU crash loop; a second loss stays on the DOM renderer.
cancelWebglContextRecovery();
var recoveryTerm = term;
var recoveryGeneration = terminalGeneration;
webglRecoveryTimer = setTimeout(function() {
webglRecoveryTimer = null;
if (term !== recoveryTerm || terminalGeneration !== recoveryGeneration) return;
attachWebglAddon(false);
}, 100);
});
term.loadAddon(addon);
if (!allowRecovery) {
try { if (addon.clearTextureAtlas) addon.clearTextureAtlas(); } catch (e) {}
refreshTerminalSurface();
}
return true;
} catch (e) {
flog('webgl-attach-failed', { retry: !allowRecovery, message: String(e) });
if (webglAddon === addon) webglAddon = null;
try { if (addon) addon.dispose(); } catch (disposeError) {}
refreshTerminalSurface();
return false;
}
}
document.addEventListener('visibilitychange', function() {
if (document.visibilityState !== 'visible') return;
// Why: iOS may restore the xterm model while discarding GPU pixels/theme
// paint state, so visibility must rebuild the atlas and repaint every row.
applyTerminalTheme(terminalThemeInput);
try { if (webglAddon && webglAddon.clearTextureAtlas) webglAddon.clearTextureAtlas(); } catch (e) {}
refreshTerminalSurface();
});
`
@@ -1,53 +0,0 @@
// Indirect-pointer (external mouse / trackpad) scroll for the terminal surface,
// injected into XTERM_HTML. Extracted from terminal-webview-html.ts to keep that
// file within its max-lines budget. Closes over host-IIFE state/functions:
// term, getCellHeight, getTotalScale, shouldRouteScrollToTerminalInput,
// routeScrollLines, enqueueNormalBufferScrollDelta, resetSmoothScrollOffset,
// and dispatcherShouldBlockSurface.
export const TERMINAL_WHEEL_SCROLL_JS = `
var wheelAccumDeltaY = 0;
function wheelEventPixelDeltaY(e) {
var delta = e.deltaY;
if (typeof delta !== 'number' || !isFinite(delta) || delta === 0) return 0;
// DOM_DELTA_LINE / DOM_DELTA_PAGE: Android WebView reports line-mode deltas
// for external mouse wheels, iOS trackpads report pixels.
if (e.deltaMode === 1) return delta * getCellHeight() * getTotalScale();
if (e.deltaMode === 2) return delta * window.innerHeight;
return delta;
}
function attachSurfaceWheelHandler(targetSurface) {
targetSurface.addEventListener('wheel', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
// Why: xterm's own wheel handler scrolls its hidden viewport or emits
// cursor keys through onData, which the mobile query-reply gate drops.
// Claim the event so indirect pointers share the touch scroll router.
e.preventDefault();
e.stopPropagation();
// Why: a trackpad pinch arrives as ctrl+wheel. Swallow it rather than
// firing cursor keys at the TUI; two-finger pinch still drives text size.
if (e.ctrlKey) return;
var deltaY = wheelEventPixelDeltaY(e);
if (deltaY === 0) return;
if (shouldRouteScrollToTerminalInput()) {
resetSmoothScrollOffset();
var effectiveCellH = getCellHeight() * getTotalScale();
if (!(effectiveCellH > 0)) return;
wheelAccumDeltaY += deltaY;
var lines = Math.trunc(wheelAccumDeltaY / effectiveCellH);
if (lines !== 0) {
wheelAccumDeltaY -= lines * effectiveCellH;
routeScrollLines(lines, e.clientX, e.clientY);
}
return;
}
wheelAccumDeltaY = 0;
enqueueNormalBufferScrollDelta(deltaY);
}, { capture: true, passive: false });
}
`