From 9fbdfc592cbbdbe03a0ac7172064580c59bb275d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:33:03 -0400 Subject: [PATCH] refactor(mobile): generate the terminal WebView document from typed modules (OTA phase C, C7.1) (#21804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `.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 --- mobile/.gitignore | 1 + mobile/package.json | 2 +- .../build-terminal-document-fixture.mjs | 78 + .../build-terminal-document-script.mjs | 189 + .../build-terminal-document-script.test.ts | 105 + mobile/scripts/import-typescript-module.mjs | 21 + .../terminal-document-module-order.mjs | 48 + mobile/src/terminal/document/cell-geometry.ts | 46 + .../terminal/document/document-constants.ts | 46 + .../document/document-module-order.test.ts | 43 + .../src/terminal/document/document-scope.ts | 406 ++ mobile/src/terminal/document/fit-scale.ts | 146 + .../generated-document-region.test-support.ts | 51 + .../terminal/document/host-message-router.ts | 194 + mobile/src/terminal/document/host-notify.ts | 86 + .../document/keyboard-avoidance-metrics.ts | 70 + .../src/terminal/document/message-bridge.ts | 53 + .../src/terminal/document/mode-mirroring.ts | 46 + .../src/terminal/document/mouse-click-drag.ts | 283 ++ .../terminal/document/mouse-input-encoding.ts | 230 ++ .../document/mouse-mode-decset-scan.ts | 74 + .../terminal/document/mouse-report-cell.ts | 67 + .../document/normal-buffer-smooth-scroll.ts | 102 + mobile/src/terminal/document/osc-link-tap.ts | 221 ++ mobile/src/terminal/document/path-tap.ts | 222 ++ mobile/src/terminal/document/query-reply.ts | 70 + mobile/src/terminal/document/reflow.ts | 37 + .../terminal/document/runtime-constants.ts | 23 + .../document/selection-menu-buttons.ts | 36 + .../terminal/document/selection-overlay.ts | 141 + .../src/terminal/document/selection-range.ts | 115 + .../document/selection-state-and-eviction.ts | 82 + mobile/src/terminal/document/surface-swap.ts | 69 + mobile/src/terminal/document/surface-tap.ts | 59 + .../document/surface-touch-gestures.ts | 276 ++ mobile/src/terminal/document/tap-dispatch.ts | 248 ++ .../src/terminal/document/term-observers.ts | 40 + ...minal-document-equivalence.test-support.ts | 391 ++ .../terminal-document-equivalence.test.ts | 225 ++ .../document/terminal-document-flip.test.ts | 80 + .../terminal-document-tokens.test-support.ts | 94 + .../src/terminal/document/terminal-handle.ts | 10 + mobile/src/terminal/document/terminal-init.ts | 201 + .../src/terminal/document/terminal-theme.ts | 180 + mobile/src/terminal/document/text-scaling.ts | 94 + mobile/src/terminal/document/url-tap.ts | 55 + mobile/src/terminal/document/viewport-cell.ts | 36 + .../terminal/document/viewport-transform.ts | 142 + .../src/terminal/document/webgl-recovery.ts | 105 + mobile/src/terminal/document/wheel-scroll.ts | 75 + .../write-queue.test.ts | 61 +- mobile/src/terminal/document/write-queue.ts | 135 + .../src/terminal/terminal-document-golden.txt | 3290 +++++++++++++++++ .../terminal-document-identity.test.ts | 54 + .../terminal-document-pre-flip-script.txt | 2758 ++++++++++++++ ...nal-keyboard-avoidance-metrics-injected.ts | 43 - ...erminal-keyboard-avoidance-webview.test.ts | 38 +- .../terminal/terminal-path-tap-injected.ts | 134 - mobile/src/terminal/terminal-path-tap.test.ts | 6 +- .../terminal/terminal-webview-engine.test.ts | 43 +- ...rminal-webview-html-source.test-support.ts | 31 - mobile/src/terminal/terminal-webview-html.ts | 36 +- .../terminal-webview-html/document-close.ts | 5 + .../terminal-webview-html/document-shell.ts | 9 - .../host-message-router.ts | 189 - .../message-bridge-and-document-close.ts | 43 - .../mouse-mode-decset-scan.ts | 52 - .../mouse-report-and-scroll-routing.ts | 188 - .../runtime-state-and-text-scaling.ts | 181 - .../selection-overlay.ts | 195 - .../selection-state-and-eviction.ts | 71 - .../smooth-scroll-and-cell-geometry.ts | 110 - .../surface-touch-gestures.ts | 228 -- .../term-observers-and-mode-mirroring.ts | 67 - .../terminal-fit-scale.ts | 130 - .../terminal-init-and-write.ts | 139 - .../terminal-webview-html/write-queue.ts | 114 - ...minal-webview-mouse-click-drag-injected.ts | 198 - ...inal-webview-mouse-report-cell-injected.ts | 29 - .../terminal-webview-payload-hash.test.ts | 4 +- .../terminal-webview-query-reply-injected.ts | 44 - .../terminal-webview-query-reply.test.ts | 26 +- .../terminal-webview-reflow-injected.ts | 33 - .../terminal/terminal-webview-reflow.test.ts | 61 +- .../terminal-webview-scroll-routing.test.ts | 104 +- .../terminal-webview-surface-swap-injected.ts | 49 - .../terminal-webview-tap-dispatch-injected.ts | 188 - .../terminal-webview-text-zoom.test.ts | 70 +- .../terminal-webview-theme-injected.ts | 122 - ...test.ts => terminal-webview-theme.test.ts} | 85 +- .../terminal/terminal-webview-url-tap.test.ts | 27 +- .../src/terminal/terminal-webview-url-tap.ts | 209 -- ...erminal-webview-webgl-recovery-injected.ts | 62 - .../terminal-webview-wheel-scroll-injected.ts | 53 - 94 files changed, 12232 insertions(+), 3196 deletions(-) create mode 100644 mobile/scripts/build-terminal-document-fixture.mjs create mode 100644 mobile/scripts/build-terminal-document-script.mjs create mode 100644 mobile/scripts/build-terminal-document-script.test.ts create mode 100644 mobile/scripts/import-typescript-module.mjs create mode 100644 mobile/scripts/terminal-document-module-order.mjs create mode 100644 mobile/src/terminal/document/cell-geometry.ts create mode 100644 mobile/src/terminal/document/document-constants.ts create mode 100644 mobile/src/terminal/document/document-module-order.test.ts create mode 100644 mobile/src/terminal/document/document-scope.ts create mode 100644 mobile/src/terminal/document/fit-scale.ts create mode 100644 mobile/src/terminal/document/generated-document-region.test-support.ts create mode 100644 mobile/src/terminal/document/host-message-router.ts create mode 100644 mobile/src/terminal/document/host-notify.ts create mode 100644 mobile/src/terminal/document/keyboard-avoidance-metrics.ts create mode 100644 mobile/src/terminal/document/message-bridge.ts create mode 100644 mobile/src/terminal/document/mode-mirroring.ts create mode 100644 mobile/src/terminal/document/mouse-click-drag.ts create mode 100644 mobile/src/terminal/document/mouse-input-encoding.ts create mode 100644 mobile/src/terminal/document/mouse-mode-decset-scan.ts create mode 100644 mobile/src/terminal/document/mouse-report-cell.ts create mode 100644 mobile/src/terminal/document/normal-buffer-smooth-scroll.ts create mode 100644 mobile/src/terminal/document/osc-link-tap.ts create mode 100644 mobile/src/terminal/document/path-tap.ts create mode 100644 mobile/src/terminal/document/query-reply.ts create mode 100644 mobile/src/terminal/document/reflow.ts create mode 100644 mobile/src/terminal/document/runtime-constants.ts create mode 100644 mobile/src/terminal/document/selection-menu-buttons.ts create mode 100644 mobile/src/terminal/document/selection-overlay.ts create mode 100644 mobile/src/terminal/document/selection-range.ts create mode 100644 mobile/src/terminal/document/selection-state-and-eviction.ts create mode 100644 mobile/src/terminal/document/surface-swap.ts create mode 100644 mobile/src/terminal/document/surface-tap.ts create mode 100644 mobile/src/terminal/document/surface-touch-gestures.ts create mode 100644 mobile/src/terminal/document/tap-dispatch.ts create mode 100644 mobile/src/terminal/document/term-observers.ts create mode 100644 mobile/src/terminal/document/terminal-document-equivalence.test-support.ts create mode 100644 mobile/src/terminal/document/terminal-document-equivalence.test.ts create mode 100644 mobile/src/terminal/document/terminal-document-flip.test.ts create mode 100644 mobile/src/terminal/document/terminal-document-tokens.test-support.ts create mode 100644 mobile/src/terminal/document/terminal-handle.ts create mode 100644 mobile/src/terminal/document/terminal-init.ts create mode 100644 mobile/src/terminal/document/terminal-theme.ts create mode 100644 mobile/src/terminal/document/text-scaling.ts create mode 100644 mobile/src/terminal/document/url-tap.ts create mode 100644 mobile/src/terminal/document/viewport-cell.ts create mode 100644 mobile/src/terminal/document/viewport-transform.ts create mode 100644 mobile/src/terminal/document/webgl-recovery.ts create mode 100644 mobile/src/terminal/document/wheel-scroll.ts rename mobile/src/terminal/{terminal-webview-html => document}/write-queue.test.ts (80%) create mode 100644 mobile/src/terminal/document/write-queue.ts create mode 100644 mobile/src/terminal/terminal-document-golden.txt create mode 100644 mobile/src/terminal/terminal-document-identity.test.ts create mode 100644 mobile/src/terminal/terminal-document-pre-flip-script.txt delete mode 100644 mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts delete mode 100644 mobile/src/terminal/terminal-path-tap-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-html-source.test-support.ts create mode 100644 mobile/src/terminal/terminal-webview-html/document-close.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/host-message-router.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/selection-overlay.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts delete mode 100644 mobile/src/terminal/terminal-webview-html/write-queue.ts delete mode 100644 mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-query-reply-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-reflow-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-surface-swap-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-theme-injected.ts rename mobile/src/terminal/{terminal-webview-theme-injected.test.ts => terminal-webview-theme.test.ts} (62%) delete mode 100644 mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts delete mode 100644 mobile/src/terminal/terminal-webview-wheel-scroll-injected.ts diff --git a/mobile/.gitignore b/mobile/.gitignore index 0428cbb65a9..7c30714f74c 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -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/ diff --git a/mobile/package.json b/mobile/package.json index c4a977cd459..d89f04ff36d 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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", diff --git a/mobile/scripts/build-terminal-document-fixture.mjs b/mobile/scripts/build-terminal-document-fixture.mjs new file mode 100644 index 00000000000..885aca358d7 --- /dev/null +++ b/mobile/scripts/build-terminal-document-fixture.mjs @@ -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() +} diff --git a/mobile/scripts/build-terminal-document-script.mjs b/mobile/scripts/build-terminal-document-script.mjs new file mode 100644 index 00000000000..9fe3c9b0587 --- /dev/null +++ b/mobile/scripts/build-terminal-document-script.mjs @@ -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() +} diff --git a/mobile/scripts/build-terminal-document-script.test.ts b/mobile/scripts/build-terminal-document-script.test.ts new file mode 100644 index 00000000000..f84e30de688 --- /dev/null +++ b/mobile/scripts/build-terminal-document-script.test.ts @@ -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 { + 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) + }) +}) diff --git a/mobile/scripts/import-typescript-module.mjs b/mobile/scripts/import-typescript-module.mjs new file mode 100644 index 00000000000..54f84ab800b --- /dev/null +++ b/mobile/scripts/import-typescript-module.mjs @@ -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')}`) +} diff --git a/mobile/scripts/terminal-document-module-order.mjs b/mobile/scripts/terminal-document-module-order.mjs new file mode 100644 index 00000000000..7fa99fe5962 --- /dev/null +++ b/mobile/scripts/terminal-document-module-order.mjs @@ -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' +] diff --git a/mobile/src/terminal/document/cell-geometry.ts b/mobile/src/terminal/document/cell-geometry.ts new file mode 100644 index 00000000000..d5f6e950912 --- /dev/null +++ b/mobile/src/terminal/document/cell-geometry.ts @@ -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. diff --git a/mobile/src/terminal/document/document-constants.ts b/mobile/src/terminal/document/document-constants.ts new file mode 100644 index 00000000000..c5c67b29696 --- /dev/null +++ b/mobile/src/terminal/document/document-constants.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 diff --git a/mobile/src/terminal/document/document-module-order.test.ts b/mobile/src/terminal/document/document-module-order.test.ts new file mode 100644 index 00000000000..7a66e9ad29a --- /dev/null +++ b/mobile/src/terminal/document/document-module-order.test.ts @@ -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) + }) +}) diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts new file mode 100644 index 00000000000..046ce3e708e --- /dev/null +++ b/mobile/src/terminal/document/document-scope.ts @@ -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 + +/** 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 | 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 | 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 | 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 | 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() diff --git a/mobile/src/terminal/document/fit-scale.ts b/mobile/src/terminal/document/fit-scale.ts new file mode 100644 index 00000000000..9f9138ed88a --- /dev/null +++ b/mobile/src/terminal/document/fit-scale.ts @@ -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() +} diff --git a/mobile/src/terminal/document/generated-document-region.test-support.ts b/mobile/src/terminal/document/generated-document-region.test-support.ts new file mode 100644 index 00000000000..f93efbfcb0e --- /dev/null +++ b/mobile/src/terminal/document/generated-document-region.test-support.ts @@ -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 { + 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 unknown>( + context: Record, + 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 +} diff --git a/mobile/src/terminal/document/host-message-router.ts b/mobile/src/terminal/document/host-message-router.ts new file mode 100644 index 00000000000..b03bca2cead --- /dev/null +++ b/mobile/src/terminal/document/host-message-router.ts @@ -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[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 {} + } + } +} diff --git a/mobile/src/terminal/document/host-notify.ts b/mobile/src/terminal/document/host-notify.ts new file mode 100644 index 00000000000..310a80e2b39 --- /dev/null +++ b/mobile/src/terminal/document/host-notify.ts @@ -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) { + 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) +} diff --git a/mobile/src/terminal/document/keyboard-avoidance-metrics.ts b/mobile/src/terminal/document/keyboard-avoidance-metrics.ts new file mode 100644 index 00000000000..4c3520ca529 --- /dev/null +++ b/mobile/src/terminal/document/keyboard-avoidance-metrics.ts @@ -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 + }) +} diff --git a/mobile/src/terminal/document/message-bridge.ts b/mobile/src/terminal/document/message-bridge.ts new file mode 100644 index 00000000000..2141b7cfa3b --- /dev/null +++ b/mobile/src/terminal/document/message-bridge.ts @@ -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) +} diff --git a/mobile/src/terminal/document/mode-mirroring.ts b/mobile/src/terminal/document/mode-mirroring.ts new file mode 100644 index 00000000000..c5471bb8db4 --- /dev/null +++ b/mobile/src/terminal/document/mode-mirroring.ts @@ -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 +} diff --git a/mobile/src/terminal/document/mouse-click-drag.ts b/mobile/src/terminal/document/mouse-click-drag.ts new file mode 100644 index 00000000000..aed11e99f5f --- /dev/null +++ b/mobile/src/terminal/document/mouse-click-drag.ts @@ -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 + ) +} diff --git a/mobile/src/terminal/document/mouse-input-encoding.ts b/mobile/src/terminal/document/mouse-input-encoding.ts new file mode 100644 index 00000000000..1ea8360cca3 --- /dev/null +++ b/mobile/src/terminal/document/mouse-input-encoding.ts @@ -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) +} diff --git a/mobile/src/terminal/document/mouse-mode-decset-scan.ts b/mobile/src/terminal/document/mouse-mode-decset-scan.ts new file mode 100644 index 00000000000..f47370ab7e9 --- /dev/null +++ b/mobile/src/terminal/document/mouse-mode-decset-scan.ts @@ -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 + } + } + } +} diff --git a/mobile/src/terminal/document/mouse-report-cell.ts b/mobile/src/terminal/document/mouse-report-cell.ts new file mode 100644 index 00000000000..ca4bbaec3ed --- /dev/null +++ b/mobile/src/terminal/document/mouse-report-cell.ts @@ -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) } +} diff --git a/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts new file mode 100644 index 00000000000..e1ee6dd63e3 --- /dev/null +++ b/mobile/src/terminal/document/normal-buffer-smooth-scroll.ts @@ -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) +} diff --git a/mobile/src/terminal/document/osc-link-tap.ts b/mobile/src/terminal/document/osc-link-tap.ts new file mode 100644 index 00000000000..762c4596459 --- /dev/null +++ b/mobile/src/terminal/document/osc-link-tap.ts @@ -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 + } +} diff --git a/mobile/src/terminal/document/path-tap.ts b/mobile/src/terminal/document/path-tap.ts new file mode 100644 index 00000000000..42b0f25b839 --- /dev/null +++ b/mobile/src/terminal/document/path-tap.ts @@ -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 = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 } +const PATH_TRAILING_TRIM: Record = { + ')': 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) + ) +} diff --git a/mobile/src/terminal/document/query-reply.ts b/mobile/src/terminal/document/query-reply.ts new file mode 100644 index 00000000000..70509735b2c --- /dev/null +++ b/mobile/src/terminal/document/query-reply.ts @@ -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) +} diff --git a/mobile/src/terminal/document/reflow.ts b/mobile/src/terminal/document/reflow.ts new file mode 100644 index 00000000000..d4f031557e3 --- /dev/null +++ b/mobile/src/terminal/document/reflow.ts @@ -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() +} diff --git a/mobile/src/terminal/document/runtime-constants.ts b/mobile/src/terminal/document/runtime-constants.ts new file mode 100644 index 00000000000..a283c629ff8 --- /dev/null +++ b/mobile/src/terminal/document/runtime-constants.ts @@ -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 diff --git a/mobile/src/terminal/document/selection-menu-buttons.ts b/mobile/src/terminal/document/selection-menu-buttons.ts new file mode 100644 index 00000000000..9601cdb3bdf --- /dev/null +++ b/mobile/src/terminal/document/selection-menu-buttons.ts @@ -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 {} +}) diff --git a/mobile/src/terminal/document/selection-overlay.ts b/mobile/src/terminal/document/selection-overlay.ts new file mode 100644 index 00000000000..24e0edcecbe --- /dev/null +++ b/mobile/src/terminal/document/selection-overlay.ts @@ -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. diff --git a/mobile/src/terminal/document/selection-range.ts b/mobile/src/terminal/document/selection-range.ts new file mode 100644 index 00000000000..0c7e77efdf4 --- /dev/null +++ b/mobile/src/terminal/document/selection-range.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() +} diff --git a/mobile/src/terminal/document/selection-state-and-eviction.ts b/mobile/src/terminal/document/selection-state-and-eviction.ts new file mode 100644 index 00000000000..a97c1b9cb72 --- /dev/null +++ b/mobile/src/terminal/document/selection-state-and-eviction.ts @@ -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() + } +} diff --git a/mobile/src/terminal/document/surface-swap.ts b/mobile/src/terminal/document/surface-swap.ts new file mode 100644 index 00000000000..26082493cc3 --- /dev/null +++ b/mobile/src/terminal/document/surface-swap.ts @@ -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 +} diff --git a/mobile/src/terminal/document/surface-tap.ts b/mobile/src/terminal/document/surface-tap.ts new file mode 100644 index 00000000000..475e3992648 --- /dev/null +++ b/mobile/src/terminal/document/surface-tap.ts @@ -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' }) + } +} diff --git a/mobile/src/terminal/document/surface-touch-gestures.ts b/mobile/src/terminal/document/surface-touch-gestures.ts new file mode 100644 index 00000000000..072186a14db --- /dev/null +++ b/mobile/src/terminal/document/surface-touch-gestures.ts @@ -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!) diff --git a/mobile/src/terminal/document/tap-dispatch.ts b/mobile/src/terminal/document/tap-dispatch.ts new file mode 100644 index 00000000000..8e0b07c4bbd --- /dev/null +++ b/mobile/src/terminal/document/tap-dispatch.ts @@ -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 } +) diff --git a/mobile/src/terminal/document/term-observers.ts b/mobile/src/terminal/document/term-observers.ts new file mode 100644 index 00000000000..2e7cd4f12b3 --- /dev/null +++ b/mobile/src/terminal/document/term-observers.ts @@ -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() + }) +} diff --git a/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts b/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts new file mode 100644 index 00000000000..4a799bb1f25 --- /dev/null +++ b/mobile/src/terminal/document/terminal-document-equivalence.test-support.ts @@ -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 `.name`; the declaration stayed where it was. */ + readonly qualifiedReferences: number + /** + * `var name` became `.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: .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` -> `.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` -> `.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 `.` 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 = `` + const start = document.indexOf(opener) + if (start === -1) { + throw new Error('the document does not carry the generated engine script') + } + const scriptStart = document.indexOf('') + if (scriptStart === -1 || scriptEnd <= scriptStart) { + throw new Error('the document does not carry a hand-written script after the engine') + } + return document.slice(scriptStart + ' + + + + +
+
+
+
+
+
+
+
+ + +
+
+ + + + \ No newline at end of file diff --git a/mobile/src/terminal/terminal-document-identity.test.ts b/mobile/src/terminal/terminal-document-identity.test.ts new file mode 100644 index 00000000000..0f4bf152bd7 --- /dev/null +++ b/mobile/src/terminal/terminal-document-identity.test.ts @@ -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) + }) +}) diff --git a/mobile/src/terminal/terminal-document-pre-flip-script.txt b/mobile/src/terminal/terminal-document-pre-flip-script.txt new file mode 100644 index 00000000000..1252cd0ac6b --- /dev/null +++ b/mobile/src/terminal/terminal-document-pre-flip-script.txt @@ -0,0 +1,2758 @@ + +(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; + var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; + var term = null; + 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); + } + + + // 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; + } + + 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 = [0.5,0.75,1,1.25,1.5,2]; + 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 = {"background":"#1a1b26","foreground":"#c0caf5","cursor":"#c0caf5","cursorAccent":"#1a1b26","selectionBackground":"#33467c","selectionForeground":"#c0caf5","black":"#15161e","red":"#f7768e","green":"#9ece6a","yellow":"#e0af68","blue":"#7aa2f7","magenta":"#bb9af7","cyan":"#7dcfff","white":"#a9b1d6","brightBlack":"#414868","brightRed":"#f7768e","brightGreen":"#9ece6a","brightYellow":"#e0af68","brightBlue":"#7aa2f7","brightMagenta":"#bb9af7","brightCyan":"#7dcfff","brightWhite":"#c0caf5"}; + 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); + } + + + 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 || '#1a1b26'; + 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; + } + } + + + 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(); + } + + 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; + } + } + } + } + + 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); + } + + + 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(); + }); + + + 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: false, + cursorStyle: "bar", + // Native TextInput owns focus; initialize xterm's otherwise-gated main-buffer caret. + showCursorImmediately: true, + // A full inactive cell remains visible under the terminal's phone-fit scale. + cursorInactiveStyle: "block", + 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). + + // 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(); + } + + + 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) {} + } + } + } + + // ============================================================ + // 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(); + } + } + + 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 + }; + + + 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 + }); + } + + + 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(); + }); + } + + 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 }; + } + + + 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) }; + } + + + 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); + } + + 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. + + 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) + ); + } + + + var URL_TAP_RE_SOURCE = "\\bhttps?:\\/\\/[^\\s\"'!*(){}|\\\\^<>`]*[^\\s\"':,.!?{}|\\\\^~[\\]`()<>]"; + var FILE_URL_TAP_RE_SOURCE = "\\bfile:\\/\\/[^\\s\"'!*(){}|\\\\^<>`]*[^\\s\"',!?{}|\\\\^~[\\]`()<>]"; + var URL_TAP_MAX_LENGTH = 2048; + 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' }); + } + } + + + 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). + + // ============================================================ + // 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 }); + + + // External mouse / trackpad scroll: see + // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines). + + 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 }); + } + + + // External mouse click/drag: see + // terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines). + + 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); + } + + + 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); + + 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); + } +})(); diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts b/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts deleted file mode 100644 index a3a29ac61f9..00000000000 --- a/mobile/src/terminal/terminal-keyboard-avoidance-metrics-injected.ts +++ /dev/null @@ -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 - }); - } -` diff --git a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts index 203db63bc65..1b028395003 100644 --- a/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts +++ b/mobile/src/terminal/terminal-keyboard-avoidance-webview.test.ts @@ -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 | undefined)[], altScree notify: (message: Record) => 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[] = [] - new Script( - `${TERMINAL_KEYBOARD_AVOIDANCE_METRICS_JS}\nemitKeyboardAvoidanceMetrics();` - ).runInNewContext({ + new Script(`${keyboardAvoidanceMetricsScript}\nemitKeyboardAvoidanceMetrics();`).runInNewContext({ notify: (message: Record) => 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') diff --git a/mobile/src/terminal/terminal-path-tap-injected.ts b/mobile/src/terminal/terminal-path-tap-injected.ts deleted file mode 100644 index 766c381ea22..00000000000 --- a/mobile/src/terminal/terminal-path-tap-injected.ts +++ /dev/null @@ -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) - ); - } -` diff --git a/mobile/src/terminal/terminal-path-tap.test.ts b/mobile/src/terminal/terminal-path-tap.test.ts index ebb8e0b9ec3..417d7ca24cc 100644 --- a/mobile/src/terminal/terminal-path-tap.test.ts +++ b/mobile/src/terminal/terminal-path-tap.test.ts @@ -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 } diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts index ac415922851..08058f3e6f1 100644 --- a/mobile/src/terminal/terminal-webview-engine.test.ts +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -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 })') }) }) diff --git a/mobile/src/terminal/terminal-webview-html-source.test-support.ts b/mobile/src/terminal/terminal-webview-html-source.test-support.ts deleted file mode 100644 index 19a9cfc07ba..00000000000 --- a/mobile/src/terminal/terminal-webview-html-source.test-support.ts +++ /dev/null @@ -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') -} diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 17fadd4d26c..54769545deb 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -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 } diff --git a/mobile/src/terminal/terminal-webview-html/document-close.ts b/mobile/src/terminal/terminal-webview-html/document-close.ts new file mode 100644 index 00000000000..693edf2201e --- /dev/null +++ b/mobile/src/terminal/terminal-webview-html/document-close.ts @@ -0,0 +1,5 @@ +// Closes the document after the generated script. +export const TERMINAL_HTML_DOCUMENT_CLOSE = ` + + +` diff --git a/mobile/src/terminal/terminal-webview-html/document-shell.ts b/mobile/src/terminal/terminal-webview-html/document-shell.ts index d6e733cdd77..7dd869b1df9 100644 --- a/mobile/src/terminal/terminal-webview-html/document-shell.ts +++ b/mobile/src/terminal/terminal-webview-html/document-shell.ts @@ -161,13 +161,4 @@ window.onerror = function(msg) { - -` diff --git a/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts b/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts deleted file mode 100644 index 6f0685df87e..00000000000 --- a/mobile/src/terminal/terminal-webview-html/mouse-mode-decset-scan.ts +++ /dev/null @@ -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; - } - } - } - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts b/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts deleted file mode 100644 index 3b7e7f24cf3..00000000000 --- a/mobile/src/terminal/terminal-webview-html/mouse-report-and-scroll-routing.ts +++ /dev/null @@ -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); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts b/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts deleted file mode 100644 index 44ce1d39042..00000000000 --- a/mobile/src/terminal/terminal-webview-html/runtime-state-and-text-scaling.ts +++ /dev/null @@ -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); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/selection-overlay.ts b/mobile/src/terminal/terminal-webview-html/selection-overlay.ts deleted file mode 100644 index 335407af51a..00000000000 --- a/mobile/src/terminal/terminal-webview-html/selection-overlay.ts +++ /dev/null @@ -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). -` diff --git a/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts b/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts deleted file mode 100644 index 48c05b6ad85..00000000000 --- a/mobile/src/terminal/terminal-webview-html/selection-state-and-eviction.ts +++ /dev/null @@ -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(); - } - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts b/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts deleted file mode 100644 index de9db152fbf..00000000000 --- a/mobile/src/terminal/terminal-webview-html/smooth-scroll-and-cell-geometry.ts +++ /dev/null @@ -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. -` diff --git a/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts b/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts deleted file mode 100644 index 515d7fd9d26..00000000000 --- a/mobile/src/terminal/terminal-webview-html/surface-touch-gestures.ts +++ /dev/null @@ -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); - -` diff --git a/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts b/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts deleted file mode 100644 index b69547affb5..00000000000 --- a/mobile/src/terminal/terminal-webview-html/term-observers-and-mode-mirroring.ts +++ /dev/null @@ -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(); - }); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts b/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts deleted file mode 100644 index b756bcb550c..00000000000 --- a/mobile/src/terminal/terminal-webview-html/terminal-fit-scale.ts +++ /dev/null @@ -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(); - } - -` diff --git a/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts b/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts deleted file mode 100644 index 90dba7cbdbc..00000000000 --- a/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts +++ /dev/null @@ -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). -` diff --git a/mobile/src/terminal/terminal-webview-html/write-queue.ts b/mobile/src/terminal/terminal-webview-html/write-queue.ts deleted file mode 100644 index cdb45bb0b91..00000000000 --- a/mobile/src/terminal/terminal-webview-html/write-queue.ts +++ /dev/null @@ -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); - } - -` diff --git a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts deleted file mode 100644 index 54ee8d430a4..00000000000 --- a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts +++ /dev/null @@ -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); - } -` diff --git a/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts b/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts deleted file mode 100644 index 14baeeaf325..00000000000 --- a/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts +++ /dev/null @@ -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) }; - } -` diff --git a/mobile/src/terminal/terminal-webview-payload-hash.test.ts b/mobile/src/terminal/terminal-webview-payload-hash.test.ts index 8c23de0ac3d..6d4380ba168 100644 --- a/mobile/src/terminal/terminal-webview-payload-hash.test.ts +++ b/mobile/src/terminal/terminal-webview-payload-hash.test.ts @@ -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', () => { diff --git a/mobile/src/terminal/terminal-webview-query-reply-injected.ts b/mobile/src/terminal/terminal-webview-query-reply-injected.ts deleted file mode 100644 index a4ac34e5809..00000000000 --- a/mobile/src/terminal/terminal-webview-query-reply-injected.ts +++ /dev/null @@ -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); - } -` diff --git a/mobile/src/terminal/terminal-webview-query-reply.test.ts b/mobile/src/terminal/terminal-webview-query-reply.test.ts index 4e8add33f2a..9d3b780a530 100644 --- a/mobile/src/terminal/terminal-webview-query-reply.test.ts +++ b/mobile/src/terminal/terminal-webview-query-reply.test.ts @@ -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( diff --git a/mobile/src/terminal/terminal-webview-reflow-injected.ts b/mobile/src/terminal/terminal-webview-reflow-injected.ts deleted file mode 100644 index ef704529e4b..00000000000 --- a/mobile/src/terminal/terminal-webview-reflow-injected.ts +++ /dev/null @@ -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(); - } -` diff --git a/mobile/src/terminal/terminal-webview-reflow.test.ts b/mobile/src/terminal/terminal-webview-reflow.test.ts index 90112740d1d..be24fbb3819 100644 --- a/mobile/src/terminal/terminal-webview-reflow.test.ts +++ b/mobile/src/terminal/terminal-webview-reflow.test.ts @@ -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) diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 9218e5d6ad9..53d580cf196 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -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', () => { diff --git a/mobile/src/terminal/terminal-webview-surface-swap-injected.ts b/mobile/src/terminal/terminal-webview-surface-swap-injected.ts deleted file mode 100644 index 0f7b1c9a21d..00000000000 --- a/mobile/src/terminal/terminal-webview-surface-swap-injected.ts +++ /dev/null @@ -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; - } -` diff --git a/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts b/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts deleted file mode 100644 index 23036f84dfd..00000000000 --- a/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts +++ /dev/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 }); -` diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index d775237ddcc..23964d65969 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -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()') }) diff --git a/mobile/src/terminal/terminal-webview-theme-injected.ts b/mobile/src/terminal/terminal-webview-theme-injected.ts deleted file mode 100644 index 98489b219c7..00000000000 --- a/mobile/src/terminal/terminal-webview-theme-injected.ts +++ /dev/null @@ -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; - } - } -` diff --git a/mobile/src/terminal/terminal-webview-theme-injected.test.ts b/mobile/src/terminal/terminal-webview-theme.test.ts similarity index 62% rename from mobile/src/terminal/terminal-webview-theme-injected.test.ts rename to mobile/src/terminal/terminal-webview-theme.test.ts index d0947ef3e92..2b1f5fcbffa 100644 --- a/mobile/src/terminal/terminal-webview-theme-injected.test.ts +++ b/mobile/src/terminal/terminal-webview-theme.test.ts @@ -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 = {}): Record { - const context: Record = { - defaultTheme: { background: '#1a1b26', foreground: '#c0caf5' }, - ...extra - } - new Script(TERMINAL_WEBVIEW_THEME_JS).runInNewContext(context) + const { term, ...globals } = extra + const context: Record = { ...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 & { 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 & { 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', () => { diff --git a/mobile/src/terminal/terminal-webview-url-tap.test.ts b/mobile/src/terminal/terminal-webview-url-tap.test.ts index bd7ff4bf06b..edb6523993f 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.test.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.test.ts @@ -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 = 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 });') }) }) diff --git a/mobile/src/terminal/terminal-webview-url-tap.ts b/mobile/src/terminal/terminal-webview-url-tap.ts index f5d416d5797..c63cf8d48d6 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.ts @@ -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' }); - } - } -` diff --git a/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts b/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts deleted file mode 100644 index 8e4d27c2348..00000000000 --- a/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts +++ /dev/null @@ -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(); - }); -` diff --git a/mobile/src/terminal/terminal-webview-wheel-scroll-injected.ts b/mobile/src/terminal/terminal-webview-wheel-scroll-injected.ts deleted file mode 100644 index 8d2321e7c25..00000000000 --- a/mobile/src/terminal/terminal-webview-wheel-scroll-injected.ts +++ /dev/null @@ -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 }); - } -`